From f2ae20202488ffcdd9f026562eeb5e670dbf825b Mon Sep 17 00:00:00 2001 From: Jeremy Wharton Date: Tue, 21 Mar 2023 01:48:11 -0500 Subject: [PATCH] satellite/payments,web/satellite: separate UI cost estimates by partner Components related to project usage costs have been updated to show different estimations for each partner, and the satellite has been updated to send the client the information it needs to do this. Previously, project costs in the satellite frontend were estimated using only the price model corresponding to the partner that the user registered with. This caused users who had a project containing differently-attributed buckets to see an incorrect price estimation. Resolves storj/storj-private#186 Change-Id: I2531643bc49f24fcb2e5f87e528b552285b6ff20 --- .../console/consoleweb/consoleapi/payments.go | 32 ++- satellite/console/service.go | 17 +- satellite/payments/account.go | 2 +- satellite/payments/projectcharges.go | 10 +- .../payments/stripecoinpayments/accounts.go | 36 ++- web/satellite/src/api/payments.ts | 25 +- .../account/billing/billingTabs/Overview.vue | 27 +- .../billingTabs/UsageAndChargesItem.vue | 237 ++++++++---------- .../project/dashboard/ProjectDashboard.vue | 5 +- web/satellite/src/store/modules/payments.ts | 54 +--- web/satellite/src/types/json.ts | 20 ++ web/satellite/src/types/payments.ts | 196 ++++++++++++++- web/satellite/tests/unit/mock/api/payments.ts | 6 +- 13 files changed, 400 insertions(+), 267 deletions(-) create mode 100644 web/satellite/src/types/json.ts diff --git a/satellite/console/consoleweb/consoleapi/payments.go b/satellite/console/consoleweb/consoleapi/payments.go index c73e1856e..f29b1e5a9 100644 --- a/satellite/console/consoleweb/consoleapi/payments.go +++ b/satellite/console/consoleweb/consoleapi/payments.go @@ -103,6 +103,11 @@ func (p *Payments) ProjectsCharges(w http.ResponseWriter, r *http.Request) { var err error defer mon.Task()(&ctx)(&err) + var response struct { + PriceModels map[string]payments.ProjectUsagePriceModel `json:"priceModels"` + Charges payments.ProjectChargesResponse `json:"charges"` + } + w.Header().Set("Content-Type", "application/json") sinceStamp, err := strconv.ParseInt(r.URL.Query().Get("from"), 10, 64) @@ -130,9 +135,23 @@ func (p *Payments) ProjectsCharges(w http.ResponseWriter, r *http.Request) { return } - err = json.NewEncoder(w).Encode(charges) + response.Charges = charges + response.PriceModels = make(map[string]payments.ProjectUsagePriceModel) + + seen := make(map[string]struct{}) + for _, partnerCharges := range charges { + for partner := range partnerCharges { + if _, ok := seen[partner]; ok { + continue + } + response.PriceModels[partner] = *p.service.Payments().GetProjectUsagePriceModel(partner) + seen[partner] = struct{}{} + } + } + + err = json.NewEncoder(w).Encode(response) if err != nil { - p.log.Error("failed to write json response", zap.Error(ErrPaymentsAPI.Wrap(err))) + p.log.Error("failed to write json project usage and charges response", zap.Error(ErrPaymentsAPI.Wrap(err))) } } @@ -457,17 +476,14 @@ func (p *Payments) GetProjectUsagePriceModel(w http.ResponseWriter, r *http.Requ w.Header().Set("Content-Type", "application/json") - pricing, err := p.service.Payments().GetProjectUsagePriceModel(ctx) + user, err := console.GetUser(ctx) if err != nil { - if console.ErrUnauthorized.Has(err) { - p.serveJSONError(w, http.StatusUnauthorized, err) - return - } - p.serveJSONError(w, http.StatusInternalServerError, err) return } + pricing := p.service.Payments().GetProjectUsagePriceModel(string(user.UserAgent)) + if err = json.NewEncoder(w).Encode(pricing); err != nil { p.log.Error("failed to encode project usage price model", zap.Error(ErrPaymentsAPI.Wrap(err))) } diff --git a/satellite/console/service.go b/satellite/console/service.go index f3b1bb137..b145b0ab5 100644 --- a/satellite/console/service.go +++ b/satellite/console/service.go @@ -400,7 +400,7 @@ func (payment Payments) MakeCreditCardDefault(ctx context.Context, cardID string } // ProjectsCharges returns how much money current user will be charged for each project which he owns. -func (payment Payments) ProjectsCharges(ctx context.Context, since, before time.Time) (_ []payments.ProjectCharge, err error) { +func (payment Payments) ProjectsCharges(ctx context.Context, since, before time.Time) (_ payments.ProjectChargesResponse, err error) { defer mon.Task()(&ctx)(&err) user, err := payment.service.getUserAndAuditLog(ctx, "project charges") @@ -3217,17 +3217,10 @@ func (payment Payments) ApplyCredit(ctx context.Context, amount int64, desc stri return nil } -// GetProjectUsagePriceModel returns the project usage price model for the user. -func (payment Payments) GetProjectUsagePriceModel(ctx context.Context) (_ *payments.ProjectUsagePriceModel, err error) { - defer mon.Task()(&ctx)(&err) - - user, err := GetUser(ctx) - if err != nil { - return nil, Error.Wrap(err) - } - - model := payment.service.accounts.GetProjectUsagePriceModel(string(user.UserAgent)) - return &model, nil +// GetProjectUsagePriceModel returns the project usage price model for the partner. +func (payment Payments) GetProjectUsagePriceModel(partner string) (_ *payments.ProjectUsagePriceModel) { + model := payment.service.accounts.GetProjectUsagePriceModel(partner) + return &model } func findMembershipByProjectID(memberships []ProjectMember, projectID uuid.UUID) (ProjectMember, bool) { diff --git a/satellite/payments/account.go b/satellite/payments/account.go index 42703897b..3c26a08ac 100644 --- a/satellite/payments/account.go +++ b/satellite/payments/account.go @@ -33,7 +33,7 @@ type Accounts interface { Balances() Balances // ProjectCharges returns how much money current user will be charged for each project. - ProjectCharges(ctx context.Context, userID uuid.UUID, since, before time.Time) ([]ProjectCharge, error) + ProjectCharges(ctx context.Context, userID uuid.UUID, since, before time.Time) (ProjectChargesResponse, error) // GetProjectUsagePriceModel returns the project usage price model for a partner name. GetProjectUsagePriceModel(partner string) ProjectUsagePriceModel diff --git a/satellite/payments/projectcharges.go b/satellite/payments/projectcharges.go index 9d24a5c5b..dfdba94fd 100644 --- a/satellite/payments/projectcharges.go +++ b/satellite/payments/projectcharges.go @@ -10,11 +10,10 @@ import ( "storj.io/storj/satellite/accounting" ) -// ProjectCharge shows project usage and how much money current project will charge in the end of the month. +// ProjectCharge contains project usage and how much it will cost at the end of the month. type ProjectCharge struct { accounting.ProjectUsage - ProjectID uuid.UUID `json:"projectId"` // StorageGbHrs shows how much cents we should pay for storing GB*Hrs. StorageGbHrs int64 `json:"storagePrice"` // Egress shows how many cents we should pay for Egress. @@ -23,6 +22,13 @@ type ProjectCharge struct { SegmentCount int64 `json:"segmentPrice"` } +// ProjectChargesResponse represents a collection of project usage charges grouped by project ID and partner name. +// It is implemented as a map of project public IDs to a nested map of partner names to ProjectCharge structs. +// +// The values of the inner map are ProjectCharge structs which contain information about the charges associated +// with a particular project-partner combination. +type ProjectChargesResponse map[uuid.UUID]map[string]ProjectCharge + // ProjectUsagePriceModel represents price model for project usage. type ProjectUsagePriceModel struct { StorageMBMonthCents decimal.Decimal `json:"storageMBMonthCents"` diff --git a/satellite/payments/stripecoinpayments/accounts.go b/satellite/payments/stripecoinpayments/accounts.go index d029bcf0b..df15b2c5e 100644 --- a/satellite/payments/stripecoinpayments/accounts.go +++ b/satellite/payments/stripecoinpayments/accounts.go @@ -126,11 +126,10 @@ func (accounts *accounts) GetPackageInfo(ctx context.Context, userID uuid.UUID) } // ProjectCharges returns how much money current user will be charged for each project. -func (accounts *accounts) ProjectCharges(ctx context.Context, userID uuid.UUID, since, before time.Time) (charges []payments.ProjectCharge, err error) { +func (accounts *accounts) ProjectCharges(ctx context.Context, userID uuid.UUID, since, before time.Time) (charges payments.ProjectChargesResponse, err error) { defer mon.Task()(&ctx, userID, since, before)(&err) - // to return empty slice instead of nil if there are no projects - charges = make([]payments.ProjectCharge, 0) + charges = make(payments.ProjectChargesResponse) projects, err := accounts.service.projectsDB.GetOwn(ctx, userID) if err != nil { @@ -138,37 +137,34 @@ func (accounts *accounts) ProjectCharges(ctx context.Context, userID uuid.UUID, } for _, project := range projects { - totalUsage := accounting.ProjectUsage{Since: since, Before: before} - usages, err := accounts.service.usageDB.GetProjectTotalByPartner(ctx, project.ID, accounts.service.partnerNames, since, before) if err != nil { return nil, Error.Wrap(err) } - var totalPrice projectUsagePrice + partnerCharges := make(map[string]payments.ProjectCharge) for partner, usage := range usages { priceModel := accounts.GetProjectUsagePriceModel(partner) price := accounts.service.calculateProjectUsagePrice(usage.Egress, usage.Storage, usage.SegmentCount, priceModel) - totalPrice.Egress = totalPrice.Egress.Add(price.Egress) - totalPrice.Segments = totalPrice.Segments.Add(price.Segments) - totalPrice.Storage = totalPrice.Storage.Add(price.Storage) + partnerCharges[partner] = payments.ProjectCharge{ + ProjectUsage: usage, - totalUsage.Egress += usage.Egress - totalUsage.ObjectCount += usage.ObjectCount - totalUsage.SegmentCount += usage.SegmentCount - totalUsage.Storage += usage.Storage + Egress: price.Egress.IntPart(), + SegmentCount: price.Segments.IntPart(), + StorageGbHrs: price.Storage.IntPart(), + } } - charges = append(charges, payments.ProjectCharge{ - ProjectUsage: totalUsage, + // to return unpartnered empty charge if there's no usage + if len(partnerCharges) == 0 { + partnerCharges[""] = payments.ProjectCharge{ + ProjectUsage: accounting.ProjectUsage{Since: since, Before: before}, + } + } - ProjectID: project.PublicID, - Egress: totalPrice.Egress.IntPart(), - SegmentCount: totalPrice.Segments.IntPart(), - StorageGbHrs: totalPrice.Storage.IntPart(), - }) + charges[project.PublicID] = partnerCharges } return charges, nil diff --git a/web/satellite/src/api/payments.ts b/web/satellite/src/api/payments.ts index 83377cda8..7c61edb63 100644 --- a/web/satellite/src/api/payments.ts +++ b/web/satellite/src/api/payments.ts @@ -10,7 +10,7 @@ import { CreditCard, PaymentsApi, PaymentsHistoryItem, - ProjectUsageAndCharges, + ProjectCharges, ProjectUsagePriceModel, TokenAmount, NativePaymentHistoryItem, @@ -69,7 +69,7 @@ export class PaymentsHttpApi implements PaymentsApi { /** * projectsUsageAndCharges returns usage and how much money current user will be charged for each project which he owns. */ - public async projectsUsageAndCharges(start: Date, end: Date): Promise { + public async projectsUsageAndCharges(start: Date, end: Date): Promise { const since = Time.toUnixTimestamp(start).toString(); const before = Time.toUnixTimestamp(end).toString(); const path = `${this.ROOT_PATH}/account/charges?from=${since}&to=${before}`; @@ -79,28 +79,11 @@ export class PaymentsHttpApi implements PaymentsApi { throw new Error('can not get projects charges'); } - const charges = await response.json(); - if (charges) { - return charges.map(charge => - new ProjectUsageAndCharges( - new Date(charge.since), - new Date(charge.before), - charge.egress, - charge.storage, - charge.segmentCount, - charge.projectId, - charge.storagePrice, - charge.egressPrice, - charge.segmentPrice, - ), - ); - } - - return []; + return ProjectCharges.fromJSON(await response.json()); } /** - * projectUsagePriceModel returns usage and how much money current user will be charged for each project which he owns. + * projectUsagePriceModel returns the user's default price model for project usage. */ public async projectUsagePriceModel(): Promise { const path = `${this.ROOT_PATH}/pricing`; diff --git a/web/satellite/src/components/account/billing/billingTabs/Overview.vue b/web/satellite/src/components/account/billing/billingTabs/Overview.vue index 6e5cb62c5..5267b573f 100644 --- a/web/satellite/src/components/account/billing/billingTabs/Overview.vue +++ b/web/satellite/src/components/account/billing/billingTabs/Overview.vue @@ -77,14 +77,12 @@ :on-press="routeToBillingHistory" /> - + @@ -95,7 +93,7 @@ import { computed, onMounted, ref } from 'vue'; import { RouteConfig } from '@/router'; import { SHORT_MONTHS_NAMES } from '@/utils/constants/date'; -import { AccountBalance, ProjectUsageAndCharges } from '@/types/payments'; +import { AccountBalance } from '@/types/payments'; import { PAYMENTS_ACTIONS } from '@/store/modules/payments'; import { PROJECTS_ACTIONS } from '@/store/modules/projects'; import { AnalyticsHttpApi } from '@/api/analytics'; @@ -134,17 +132,20 @@ const hasZeroCoins = computed((): boolean => { }); /** - * projectUsageAndCharges is an array of all stored ProjectUsageAndCharges. + * projectIDs is an array of all of the project IDs for which there exist project usage charges. */ -const projectUsageAndCharges = computed((): ProjectUsageAndCharges[] => { - return store.state.paymentsModule.usageAndCharges; +const projectIDs = computed((): string[] => { + return store.state.projectsModule.projects + .filter(proj => store.state.paymentsModule.projectCharges.hasProject(proj.id)) + .sort((proj1, proj2) => proj1.name.localeCompare(proj2.name)) + .map(proj => proj.id); }); /** * priceSummary returns price summary of usages for all the projects. */ const priceSummary = computed((): number => { - return store.state.paymentsModule.priceSummary; + return store.state.paymentsModule.projectCharges.getPrice(); }); function routeToBillingHistory(): void { diff --git a/web/satellite/src/components/account/billing/billingTabs/UsageAndChargesItem.vue b/web/satellite/src/components/account/billing/billingTabs/UsageAndChargesItem.vue index b0c5ea3ee..ef35b687d 100644 --- a/web/satellite/src/components/account/billing/billingTabs/UsageAndChargesItem.vue +++ b/web/satellite/src/components/account/billing/billingTabs/UsageAndChargesItem.vue @@ -12,49 +12,54 @@ Estimated Total   {{ item.summary() | centsToDollars }} + >{{ projectCharges.getProjectPrice(projectId) | centsToDollars }} -
-
-
- RESOURCE - PERIOD - USAGE - COST -
-
-
-

Storage ({{ storagePrice }} per Gigabyte-Month)

-

Egress ({{ egressPrice }} per GB)

-

Segments ({{ segmentPrice }} per Segment-Month)

+