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
This commit is contained in:
Jeremy Wharton 2023-03-21 01:48:11 -05:00 committed by Storj Robot
parent 556250911c
commit f2ae202024
13 changed files with 400 additions and 267 deletions

View File

@ -103,6 +103,11 @@ func (p *Payments) ProjectsCharges(w http.ResponseWriter, r *http.Request) {
var err error var err error
defer mon.Task()(&ctx)(&err) 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") w.Header().Set("Content-Type", "application/json")
sinceStamp, err := strconv.ParseInt(r.URL.Query().Get("from"), 10, 64) 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 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 { 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") w.Header().Set("Content-Type", "application/json")
pricing, err := p.service.Payments().GetProjectUsagePriceModel(ctx) user, err := console.GetUser(ctx)
if err != nil { if err != nil {
if console.ErrUnauthorized.Has(err) {
p.serveJSONError(w, http.StatusUnauthorized, err)
return
}
p.serveJSONError(w, http.StatusInternalServerError, err) p.serveJSONError(w, http.StatusInternalServerError, err)
return return
} }
pricing := p.service.Payments().GetProjectUsagePriceModel(string(user.UserAgent))
if err = json.NewEncoder(w).Encode(pricing); err != nil { if err = json.NewEncoder(w).Encode(pricing); err != nil {
p.log.Error("failed to encode project usage price model", zap.Error(ErrPaymentsAPI.Wrap(err))) p.log.Error("failed to encode project usage price model", zap.Error(ErrPaymentsAPI.Wrap(err)))
} }

View File

@ -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. // 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) defer mon.Task()(&ctx)(&err)
user, err := payment.service.getUserAndAuditLog(ctx, "project charges") 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 return nil
} }
// GetProjectUsagePriceModel returns the project usage price model for the user. // GetProjectUsagePriceModel returns the project usage price model for the partner.
func (payment Payments) GetProjectUsagePriceModel(ctx context.Context) (_ *payments.ProjectUsagePriceModel, err error) { func (payment Payments) GetProjectUsagePriceModel(partner string) (_ *payments.ProjectUsagePriceModel) {
defer mon.Task()(&ctx)(&err) model := payment.service.accounts.GetProjectUsagePriceModel(partner)
return &model
user, err := GetUser(ctx)
if err != nil {
return nil, Error.Wrap(err)
}
model := payment.service.accounts.GetProjectUsagePriceModel(string(user.UserAgent))
return &model, nil
} }
func findMembershipByProjectID(memberships []ProjectMember, projectID uuid.UUID) (ProjectMember, bool) { func findMembershipByProjectID(memberships []ProjectMember, projectID uuid.UUID) (ProjectMember, bool) {

View File

@ -33,7 +33,7 @@ type Accounts interface {
Balances() Balances Balances() Balances
// ProjectCharges returns how much money current user will be charged for each project. // 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 returns the project usage price model for a partner name.
GetProjectUsagePriceModel(partner string) ProjectUsagePriceModel GetProjectUsagePriceModel(partner string) ProjectUsagePriceModel

View File

@ -10,11 +10,10 @@ import (
"storj.io/storj/satellite/accounting" "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 { type ProjectCharge struct {
accounting.ProjectUsage accounting.ProjectUsage
ProjectID uuid.UUID `json:"projectId"`
// StorageGbHrs shows how much cents we should pay for storing GB*Hrs. // StorageGbHrs shows how much cents we should pay for storing GB*Hrs.
StorageGbHrs int64 `json:"storagePrice"` StorageGbHrs int64 `json:"storagePrice"`
// Egress shows how many cents we should pay for Egress. // Egress shows how many cents we should pay for Egress.
@ -23,6 +22,13 @@ type ProjectCharge struct {
SegmentCount int64 `json:"segmentPrice"` 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. // ProjectUsagePriceModel represents price model for project usage.
type ProjectUsagePriceModel struct { type ProjectUsagePriceModel struct {
StorageMBMonthCents decimal.Decimal `json:"storageMBMonthCents"` StorageMBMonthCents decimal.Decimal `json:"storageMBMonthCents"`

View File

@ -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. // 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) defer mon.Task()(&ctx, userID, since, before)(&err)
// to return empty slice instead of nil if there are no projects charges = make(payments.ProjectChargesResponse)
charges = make([]payments.ProjectCharge, 0)
projects, err := accounts.service.projectsDB.GetOwn(ctx, userID) projects, err := accounts.service.projectsDB.GetOwn(ctx, userID)
if err != nil { if err != nil {
@ -138,37 +137,34 @@ func (accounts *accounts) ProjectCharges(ctx context.Context, userID uuid.UUID,
} }
for _, project := range projects { 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) usages, err := accounts.service.usageDB.GetProjectTotalByPartner(ctx, project.ID, accounts.service.partnerNames, since, before)
if err != nil { if err != nil {
return nil, Error.Wrap(err) return nil, Error.Wrap(err)
} }
var totalPrice projectUsagePrice partnerCharges := make(map[string]payments.ProjectCharge)
for partner, usage := range usages { for partner, usage := range usages {
priceModel := accounts.GetProjectUsagePriceModel(partner) priceModel := accounts.GetProjectUsagePriceModel(partner)
price := accounts.service.calculateProjectUsagePrice(usage.Egress, usage.Storage, usage.SegmentCount, priceModel) price := accounts.service.calculateProjectUsagePrice(usage.Egress, usage.Storage, usage.SegmentCount, priceModel)
totalPrice.Egress = totalPrice.Egress.Add(price.Egress) partnerCharges[partner] = payments.ProjectCharge{
totalPrice.Segments = totalPrice.Segments.Add(price.Segments) ProjectUsage: usage,
totalPrice.Storage = totalPrice.Storage.Add(price.Storage)
totalUsage.Egress += usage.Egress Egress: price.Egress.IntPart(),
totalUsage.ObjectCount += usage.ObjectCount SegmentCount: price.Segments.IntPart(),
totalUsage.SegmentCount += usage.SegmentCount StorageGbHrs: price.Storage.IntPart(),
totalUsage.Storage += usage.Storage }
} }
charges = append(charges, payments.ProjectCharge{ // to return unpartnered empty charge if there's no usage
ProjectUsage: totalUsage, if len(partnerCharges) == 0 {
partnerCharges[""] = payments.ProjectCharge{
ProjectUsage: accounting.ProjectUsage{Since: since, Before: before},
}
}
ProjectID: project.PublicID, charges[project.PublicID] = partnerCharges
Egress: totalPrice.Egress.IntPart(),
SegmentCount: totalPrice.Segments.IntPart(),
StorageGbHrs: totalPrice.Storage.IntPart(),
})
} }
return charges, nil return charges, nil

View File

@ -10,7 +10,7 @@ import {
CreditCard, CreditCard,
PaymentsApi, PaymentsApi,
PaymentsHistoryItem, PaymentsHistoryItem,
ProjectUsageAndCharges, ProjectCharges,
ProjectUsagePriceModel, ProjectUsagePriceModel,
TokenAmount, TokenAmount,
NativePaymentHistoryItem, 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. * 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<ProjectUsageAndCharges[]> { public async projectsUsageAndCharges(start: Date, end: Date): Promise<ProjectCharges> {
const since = Time.toUnixTimestamp(start).toString(); const since = Time.toUnixTimestamp(start).toString();
const before = Time.toUnixTimestamp(end).toString(); const before = Time.toUnixTimestamp(end).toString();
const path = `${this.ROOT_PATH}/account/charges?from=${since}&to=${before}`; 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'); throw new Error('can not get projects charges');
} }
const charges = await response.json(); return ProjectCharges.fromJSON(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 [];
} }
/** /**
* 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<ProjectUsagePriceModel> { public async projectUsagePriceModel(): Promise<ProjectUsagePriceModel> {
const path = `${this.ROOT_PATH}/pricing`; const path = `${this.ROOT_PATH}/pricing`;

View File

@ -77,14 +77,12 @@
:on-press="routeToBillingHistory" :on-press="routeToBillingHistory"
/> />
</div> </div>
<div class="usage-charges-item-container__detailed-info-container__footer__buttons">
<UsageAndChargesItem <UsageAndChargesItem
v-for="usageAndCharges in projectUsageAndCharges" v-for="id in projectIDs"
:key="usageAndCharges.projectId" :key="id"
:item="usageAndCharges" :project-id="id"
class="cost-by-project__item" class="cost-by-project__item"
/> />
</div>
<router-view /> <router-view />
</div> </div>
</div> </div>
@ -95,7 +93,7 @@ import { computed, onMounted, ref } from 'vue';
import { RouteConfig } from '@/router'; import { RouteConfig } from '@/router';
import { SHORT_MONTHS_NAMES } from '@/utils/constants/date'; 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 { PAYMENTS_ACTIONS } from '@/store/modules/payments';
import { PROJECTS_ACTIONS } from '@/store/modules/projects'; import { PROJECTS_ACTIONS } from '@/store/modules/projects';
import { AnalyticsHttpApi } from '@/api/analytics'; 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[] => { const projectIDs = computed((): string[] => {
return store.state.paymentsModule.usageAndCharges; 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. * priceSummary returns price summary of usages for all the projects.
*/ */
const priceSummary = computed((): number => { const priceSummary = computed((): number => {
return store.state.paymentsModule.priceSummary; return store.state.paymentsModule.projectCharges.getPrice();
}); });
function routeToBillingHistory(): void { function routeToBillingHistory(): void {

View File

@ -12,12 +12,19 @@
Estimated Total &nbsp; Estimated Total &nbsp;
<span <span
class="usage-charges-item-container__summary__amount" class="usage-charges-item-container__summary__amount"
>{{ item.summary() | centsToDollars }} >{{ projectCharges.getProjectPrice(projectId) | centsToDollars }}
</span> </span>
</span> </span>
</div> </div>
<div v-if="isDetailedInfoShown" class="usage-charges-item-container__detailed-info-container"> <template v-if="isDetailedInfoShown">
<div class="divider" /> <div
v-for="[partner, charge] in partnerCharges"
:key="partner"
class="usage-charges-item-container__detailed-info-container"
>
<p v-if="partnerCharges.length > 1 || partner" class="usage-charges-item-container__detailed-info-container__partner">
{{ partner || 'Standard Usage' }}
</p>
<div class="usage-charges-item-container__detailed-info-container__info-header"> <div class="usage-charges-item-container__detailed-info-container__info-header">
<span class="resource-header">RESOURCE</span> <span class="resource-header">RESOURCE</span>
<span class="period-header">PERIOD</span> <span class="period-header">PERIOD</span>
@ -26,35 +33,33 @@
</div> </div>
<div class="usage-charges-item-container__detailed-info-container__content-area"> <div class="usage-charges-item-container__detailed-info-container__content-area">
<div class="usage-charges-item-container__detailed-info-container__content-area__resource-container"> <div class="usage-charges-item-container__detailed-info-container__content-area__resource-container">
<p>Storage <span class="price-per-month">({{ storagePrice }} per Gigabyte-Month)</span></p> <p>Storage <span class="price-per-month">({{ getStoragePrice(partner) }} per Gigabyte-Month)</span></p>
<p>Egress <span class="price-per-month">({{ egressPrice }} per GB)</span></p> <p>Egress <span class="price-per-month">({{ getEgressPrice(partner) }} per GB)</span></p>
<p>Segments <span class="price-per-month">({{ segmentPrice }} per Segment-Month)</span></p> <p>Segments <span class="price-per-month">({{ getSegmentPrice(partner) }} per Segment-Month)</span></p>
</div> </div>
<div class="usage-charges-item-container__detailed-info-container__content-area__period-container"> <div class="usage-charges-item-container__detailed-info-container__content-area__period-container">
<p>{{ period }}</p> <p v-for="i in 3" :key="i">{{ getPeriod(charge) }}</p>
<p>{{ period }}</p>
<p>{{ period }}</p>
</div> </div>
<div class="usage-charges-item-container__detailed-info-container__content-area__usage-container"> <div class="usage-charges-item-container__detailed-info-container__content-area__usage-container">
<p>{{ storageFormatted }} Gigabyte-month</p> <p>{{ getStorageFormatted(charge) }} Gigabyte-month</p>
<p>{{ egressAmountAndDimension }}</p> <p>{{ getEgressAmountAndDimension(charge) }}</p>
<p>{{ segmentCountFormatted }} Segment-month</p> <p>{{ getSegmentCountFormatted(charge) }} Segment-month</p>
</div> </div>
<div class="usage-charges-item-container__detailed-info-container__content-area__cost-container"> <div class="usage-charges-item-container__detailed-info-container__content-area__cost-container">
<p class="price">{{ item.storagePrice | centsToDollars }}</p> <p class="price">{{ charge.storagePrice | centsToDollars }}</p>
<p class="price">{{ item.egressPrice | centsToDollars }}</p> <p class="price">{{ charge.egressPrice | centsToDollars }}</p>
<p class="price">{{ item.segmentPrice | centsToDollars }}</p> <p class="price">{{ charge.segmentPrice | centsToDollars }}</p>
</div> </div>
</div> </div>
<div class="usage-charges-item-container__detailed-info-container__footer" />
</div> </div>
</template>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { ProjectUsageAndCharges, ProjectUsagePriceModel } from '@/types/payments'; import { ProjectCharge, ProjectCharges, ProjectUsagePriceModel } from '@/types/payments';
import { Project } from '@/types/projects'; import { Project } from '@/types/projects';
import { Size } from '@/utils/bytesSize'; import { Size } from '@/utils/bytesSize';
import { SHORT_MONTHS_NAMES } from '@/utils/constants/date'; import { SHORT_MONTHS_NAMES } from '@/utils/constants/date';
@ -70,11 +75,11 @@ const HOURS_IN_MONTH = 720;
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
/** /**
* item represents usage and charges of current project by period. * The ID of the project for which to show the usage and charge information.
*/ */
item?: ProjectUsageAndCharges; projectId?: string;
}>(), { }>(), {
item: () => new ProjectUsageAndCharges(), projectId: '',
}); });
const store = useStore(); const store = useStore();
@ -84,83 +89,101 @@ const store = useStore();
*/ */
const isDetailedInfoShown = ref<boolean>(false); const isDetailedInfoShown = ref<boolean>(false);
/**
* An array of tuples containing the partner name and usage charge for the specified project ID.
*/
const partnerCharges = computed((): [partner: string, charge: ProjectCharge][] => {
const arr = store.state.paymentsModule.projectCharges.toArray();
arr.sort(([partner1], [partner2]) => partner1.localeCompare(partner2));
const tuple = arr.find(tuple => tuple[0] === props.projectId);
return tuple ? tuple[1] : [];
});
/** /**
* projectName returns project name. * projectName returns project name.
*/ */
const projectName = computed((): string => { const projectName = computed((): string => {
const projects: Project[] = store.state.projectsModule.projects; const projects: Project[] = store.state.projectsModule.projects;
const project: Project | undefined = projects.find(project => project.id === props.item.projectId); const project: Project | undefined = projects.find(project => project.id === props.projectId);
return project?.name || ''; return project?.name || '';
}); });
/** /**
* Returns string of date range. * Returns project usage price model from store.
*/ */
const period = computed((): string => { const projectCharges = computed((): ProjectCharges => {
const since = `${SHORT_MONTHS_NAMES[props.item.since.getUTCMonth()]} ${props.item.since.getUTCDate()}`; return store.state.paymentsModule.projectCharges;
const before = `${SHORT_MONTHS_NAMES[props.item.before.getUTCMonth()]} ${props.item.before.getUTCDate()}`;
return `${since} - ${before}`;
}); });
/** /**
* Returns project usage price model from store. * Returns project usage price model from store.
*/ */
const priceModel = computed((): ProjectUsagePriceModel => { function getPriceModel(partner: string): ProjectUsagePriceModel {
return store.state.paymentsModule.usagePriceModel; return projectCharges.value.getUsagePriceModel(partner) || store.state.paymentsModule.usagePriceModel;
}); }
/**
* Returns string of date range.
*/
function getPeriod(charge: ProjectCharge): string {
const since = `${SHORT_MONTHS_NAMES[charge.since.getUTCMonth()]} ${charge.since.getUTCDate()}`;
const before = `${SHORT_MONTHS_NAMES[charge.before.getUTCMonth()]} ${charge.before.getUTCDate()}`;
return `${since} - ${before}`;
}
/** /**
* Returns formatted egress depending on amount of bytes. * Returns formatted egress depending on amount of bytes.
*/ */
const egressFormatted = computed((): Size => { function egressFormatted(charge: ProjectCharge): Size {
return new Size(props.item.egress, 2); return new Size(charge.egress, 2);
}); }
/** /**
* Returns formatted storage used in GB x month dimension. * Returns formatted storage used in GB x month dimension.
*/ */
const storageFormatted = computed((): string => { function getStorageFormatted(charge: ProjectCharge): string {
const bytesInGB = 1000000000; const bytesInGB = 1000000000;
return (props.item.storage / HOURS_IN_MONTH / bytesInGB).toFixed(2); return (charge.storage / HOURS_IN_MONTH / bytesInGB).toFixed(2);
}); }
/** /**
* Returns formatted segment count in segment x month dimension. * Returns formatted segment count in segment x month dimension.
*/ */
const segmentCountFormatted = computed((): string => { function getSegmentCountFormatted(charge: ProjectCharge): string {
return (props.item.segmentCount / HOURS_IN_MONTH).toFixed(2); return (charge.segmentCount / HOURS_IN_MONTH).toFixed(2);
}); }
/** /**
* Returns storage price per GB. * Returns storage price per GB.
*/ */
const storagePrice = computed((): string => { function getStoragePrice(partner: string): string {
return formatPrice(decimalShift(priceModel.value.storageMBMonthCents, CENTS_MB_TO_DOLLARS_GB_SHIFT)); return formatPrice(decimalShift(getPriceModel(partner).storageMBMonthCents, CENTS_MB_TO_DOLLARS_GB_SHIFT));
}); }
/** /**
* Returns egress price per GB. * Returns egress price per GB.
*/ */
const egressPrice = computed((): string => { function getEgressPrice(partner: string): string {
return formatPrice(decimalShift(priceModel.value.egressMBCents, CENTS_MB_TO_DOLLARS_GB_SHIFT)); return formatPrice(decimalShift(getPriceModel(partner).egressMBCents, CENTS_MB_TO_DOLLARS_GB_SHIFT));
}); }
/** /**
* Returns segment price. * Returns segment price.
*/ */
const segmentPrice = computed((): string => { function getSegmentPrice(partner: string): string {
return formatPrice(decimalShift(priceModel.value.segmentMonthCents, 2)); return formatPrice(decimalShift(getPriceModel(partner).segmentMonthCents, 2));
}); }
/** /**
* Returns string of egress amount and dimension. * Returns string of egress amount and dimension.
*/ */
const egressAmountAndDimension = computed((): string => { function getEgressAmountAndDimension(charge: ProjectCharge): string {
return `${egressFormatted.value.formattedBytes} ${egressFormatted.value.label}`; const egress = egressFormatted(charge);
}); return `${egress.formattedBytes} ${egress.label}`;
}
/** /**
* toggleDetailedInfo expands an area with detailed information about project charges. * toggleDetailedInfo expands an area with detailed information about project charges.
@ -193,12 +216,12 @@ function toggleDetailedInfo(): void {
} }
.usage-charges-item-container { .usage-charges-item-container {
color: var(--c-black);
font-size: 16px; font-size: 16px;
line-height: 21px; line-height: 21px;
padding: 20px;
margin-top: 10px; margin-top: 10px;
font-family: 'font_regular', sans-serif; font-family: 'font_regular', sans-serif;
background-color: #fff; background-color: var(--c-white);
border-radius: 8px; border-radius: 8px;
box-shadow: 0 0 20px rgb(0 0 0 / 4%); box-shadow: 0 0 20px rgb(0 0 0 / 4%);
@ -207,6 +230,7 @@ function toggleDetailedInfo(): void {
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
padding: 20px;
cursor: pointer; cursor: pointer;
&__name-container { &__name-container {
@ -230,20 +254,18 @@ function toggleDetailedInfo(): void {
} }
&__text { &__text {
font-size: 16px; font-size: 17px;
line-height: 21px; line-height: 21px;
text-align: right; text-align: right;
color: #354049;
display: flex; display: flex;
align-items: center; align-items: center;
} }
&__amount { &__amount {
font-family: 'font_bold', sans-serif;
font-size: 24px; font-size: 24px;
line-height: 31px; line-height: 31px;
font-weight: 800;
text-align: right; text-align: right;
color: #000;
} }
} }
@ -252,6 +274,13 @@ function toggleDetailedInfo(): void {
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
justify-content: space-between; justify-content: space-between;
padding: 24px 20px;
border-top: 1px solid var(--c-grey-2);
&__partner {
font-size: 17px;
margin-bottom: 16px;
}
&__info-header { &__info-header {
display: flex; display: flex;
@ -263,7 +292,6 @@ function toggleDetailedInfo(): void {
font-weight: 600; font-weight: 600;
height: 25px; height: 25px;
width: 100%; width: 100%;
padding-top: 10px;
} }
&__content-area { &__content-area {
@ -295,63 +323,6 @@ function toggleDetailedInfo(): void {
width: 40%; width: 40%;
} }
} }
&__footer {
display: flex;
justify-content: space-between;
align-content: center;
flex-wrap: wrap;
padding-top: 10px;
width: 100%;
&__payment-type {
display: flex;
flex-direction: column;
padding-top: 10px;
&__method {
color: var(--c-grey-6);
font-weight: 600;
font-size: 12px;
}
&__type {
font-weight: 400;
font-size: 16px;
}
}
&__buttons {
display: flex;
align-self: center;
flex-wrap: wrap;
padding-top: 10px;
&__assigned {
padding: 5px 10px;
}
&__none-assigned {
padding: 5px 10px;
margin-left: 10px;
}
}
}
&__link-container {
width: 100%;
display: flex;
justify-content: flex-end;
align-items: center;
margin-top: 25px;
&__link {
font-size: 13px;
line-height: 19px;
color: #2683ff;
cursor: pointer;
}
}
} }
} }
@ -411,12 +382,4 @@ function toggleDetailedInfo(): void {
width: auto; width: auto;
} }
} }
@media only screen and (max-width: 507px) {
.usage-charges-item-container__detailed-info-container__footer__buttons__none-assigned {
margin-left: 0;
margin-top: 5px;
}
}
</style> </style>

View File

@ -161,6 +161,7 @@ import { APP_STATE_ACTIONS } from '@/utils/constants/actionNames';
import { BUCKET_ACTIONS } from '@/store/modules/buckets'; import { BUCKET_ACTIONS } from '@/store/modules/buckets';
import { RouteConfig } from '@/router'; import { RouteConfig } from '@/router';
import { DataStamp, Project, ProjectLimits } from '@/types/projects'; import { DataStamp, Project, ProjectLimits } from '@/types/projects';
import { ProjectCharges } from '@/types/payments';
import { Dimensions, Size } from '@/utils/bytesSize'; import { Dimensions, Size } from '@/utils/bytesSize';
import { ChartUtils } from '@/utils/chart'; import { ChartUtils } from '@/utils/chart';
import { AnalyticsHttpApi } from '@/api/analytics'; import { AnalyticsHttpApi } from '@/api/analytics';
@ -378,7 +379,9 @@ export default class ProjectDashboard extends Vue {
* estimatedCharges returns estimated charges summary for selected project. * estimatedCharges returns estimated charges summary for selected project.
*/ */
public get estimatedCharges(): number { public get estimatedCharges(): number {
return this.$store.state.paymentsModule.priceSummaryForSelectedProject; const projID: string = this.$store.getters.selectedProject.id;
const charges: ProjectCharges = this.$store.state.paymentsModule.projectCharges;
return charges.getProjectPrice(projID);
} }
/** /**

View File

@ -10,7 +10,7 @@ import {
PaymentsHistoryItem, PaymentsHistoryItem,
PaymentsHistoryItemStatus, PaymentsHistoryItemStatus,
PaymentsHistoryItemType, PaymentsHistoryItemType,
ProjectUsageAndCharges, ProjectCharges,
ProjectUsagePriceModel, ProjectUsagePriceModel,
NativePaymentHistoryItem, NativePaymentHistoryItem,
Wallet, Wallet,
@ -31,8 +31,6 @@ export const PAYMENTS_MUTATIONS = {
SET_PROJECT_USAGE_PRICE_MODEL: 'SET_PROJECT_USAGE_PRICE_MODEL', SET_PROJECT_USAGE_PRICE_MODEL: 'SET_PROJECT_USAGE_PRICE_MODEL',
SET_CURRENT_ROLLUP_PRICE: 'SET_CURRENT_ROLLUP_PRICE', SET_CURRENT_ROLLUP_PRICE: 'SET_CURRENT_ROLLUP_PRICE',
SET_PREVIOUS_ROLLUP_PRICE: 'SET_PREVIOUS_ROLLUP_PRICE', SET_PREVIOUS_ROLLUP_PRICE: 'SET_PREVIOUS_ROLLUP_PRICE',
SET_PRICE_SUMMARY: 'SET_PRICE_SUMMARY',
SET_PRICE_SUMMARY_FOR_SELECTED_PROJECT: 'SET_PRICE_SUMMARY_FOR_SELECTED_PROJECT',
SET_COUPON: 'SET_COUPON', SET_COUPON: 'SET_COUPON',
}; };
@ -71,8 +69,6 @@ const {
SET_NATIVE_PAYMENTS_HISTORY, SET_NATIVE_PAYMENTS_HISTORY,
SET_PROJECT_USAGE_AND_CHARGES, SET_PROJECT_USAGE_AND_CHARGES,
SET_PROJECT_USAGE_PRICE_MODEL: SET_PROJECT_USAGE_PRICE_MODEL, SET_PROJECT_USAGE_PRICE_MODEL: SET_PROJECT_USAGE_PRICE_MODEL,
SET_PRICE_SUMMARY,
SET_PRICE_SUMMARY_FOR_SELECTED_PROJECT,
SET_COUPON, SET_COUPON,
} = PAYMENTS_MUTATIONS; } = PAYMENTS_MUTATIONS;
@ -106,10 +102,8 @@ export class PaymentsState {
public creditCards: CreditCard[] = []; public creditCards: CreditCard[] = [];
public paymentsHistory: PaymentsHistoryItem[] = []; public paymentsHistory: PaymentsHistoryItem[] = [];
public nativePaymentsHistory: NativePaymentHistoryItem[] = []; public nativePaymentsHistory: NativePaymentHistoryItem[] = [];
public usageAndCharges: ProjectUsageAndCharges[] = []; public projectCharges: ProjectCharges = new ProjectCharges();
public usagePriceModel: ProjectUsagePriceModel = new ProjectUsagePriceModel(); public usagePriceModel: ProjectUsagePriceModel = new ProjectUsagePriceModel();
public priceSummary = 0;
public priceSummaryForSelectedProject = 0;
public startDate: Date = new Date(); public startDate: Date = new Date();
public endDate: Date = new Date(); public endDate: Date = new Date();
public coupon: Coupon | null = null; public coupon: Coupon | null = null;
@ -180,37 +174,12 @@ export function makePaymentsModule(api: PaymentsApi): StoreModule<PaymentsState,
[SET_NATIVE_PAYMENTS_HISTORY](state: PaymentsState, paymentsHistory: NativePaymentHistoryItem[]): void { [SET_NATIVE_PAYMENTS_HISTORY](state: PaymentsState, paymentsHistory: NativePaymentHistoryItem[]): void {
state.nativePaymentsHistory = paymentsHistory; state.nativePaymentsHistory = paymentsHistory;
}, },
[SET_PROJECT_USAGE_AND_CHARGES](state: PaymentsState, usageAndCharges: ProjectUsageAndCharges[]): void { [SET_PROJECT_USAGE_AND_CHARGES](state: PaymentsState, projectPartnerCharges: ProjectCharges): void {
state.usageAndCharges = usageAndCharges; state.projectCharges = projectPartnerCharges;
}, },
[SET_PROJECT_USAGE_PRICE_MODEL](state: PaymentsState, model: ProjectUsagePriceModel): void { [SET_PROJECT_USAGE_PRICE_MODEL](state: PaymentsState, model: ProjectUsagePriceModel): void {
state.usagePriceModel = model; state.usagePriceModel = model;
}, },
[SET_PRICE_SUMMARY](state: PaymentsState, charges: ProjectUsageAndCharges[]): void {
if (charges.length === 0) {
state.priceSummary = 0;
return;
}
const usageItemSummaries: number[] = charges.map(item => item.summary());
state.priceSummary = usageItemSummaries.reduce((accumulator, current) => accumulator + current);
},
[SET_PRICE_SUMMARY_FOR_SELECTED_PROJECT](state: PaymentsState, selectedProjectId: string): void {
let usageAndChargesForSelectedProject: ProjectUsageAndCharges | undefined;
if (state.usageAndCharges.length) {
usageAndChargesForSelectedProject = state.usageAndCharges.find(item => item.projectId === selectedProjectId);
}
if (!usageAndChargesForSelectedProject) {
state.priceSummaryForSelectedProject = 0;
return;
}
state.priceSummaryForSelectedProject = usageAndChargesForSelectedProject.summary();
},
[SET_COUPON](state: PaymentsState, coupon: Coupon): void { [SET_COUPON](state: PaymentsState, coupon: Coupon): void {
state.coupon = coupon; state.coupon = coupon;
}, },
@ -219,10 +188,8 @@ export function makePaymentsModule(api: PaymentsApi): StoreModule<PaymentsState,
state.creditCards = []; state.creditCards = [];
state.paymentsHistory = []; state.paymentsHistory = [];
state.nativePaymentsHistory = []; state.nativePaymentsHistory = [];
state.usageAndCharges = []; state.projectCharges = new ProjectCharges();
state.usagePriceModel = new ProjectUsagePriceModel(); state.usagePriceModel = new ProjectUsagePriceModel();
state.priceSummary = 0;
state.priceSummaryForSelectedProject = 0;
state.startDate = new Date(); state.startDate = new Date();
state.endDate = new Date(); state.endDate = new Date();
state.coupon = null; state.coupon = null;
@ -296,23 +263,20 @@ export function makePaymentsModule(api: PaymentsApi): StoreModule<PaymentsState,
const endUTC = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), now.getUTCHours(), now.getUTCMinutes())); const endUTC = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), now.getUTCHours(), now.getUTCMinutes()));
const startUTC = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1, 0, 0)); const startUTC = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1, 0, 0));
const usageAndCharges: ProjectUsageAndCharges[] = await api.projectsUsageAndCharges(startUTC, endUTC); const projectPartnerCharges: ProjectCharges = await api.projectsUsageAndCharges(startUTC, endUTC);
commit(SET_DATE, new DateRange(startUTC, endUTC)); commit(SET_DATE, new DateRange(startUTC, endUTC));
commit(SET_PROJECT_USAGE_AND_CHARGES, usageAndCharges); commit(SET_PROJECT_USAGE_AND_CHARGES, projectPartnerCharges);
commit(SET_PRICE_SUMMARY, usageAndCharges);
commit(SET_PRICE_SUMMARY_FOR_SELECTED_PROJECT, rootGetters.selectedProject.id);
}, },
[GET_PROJECT_USAGE_AND_CHARGES_PREVIOUS_ROLLUP]: async function({ commit }: PaymentsContext): Promise<void> { [GET_PROJECT_USAGE_AND_CHARGES_PREVIOUS_ROLLUP]: async function({ commit }: PaymentsContext): Promise<void> {
const now = new Date(); const now = new Date();
const startUTC = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1, 0, 0)); const startUTC = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1, 0, 0));
const endUTC = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 0, 23, 59, 59)); const endUTC = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 0, 23, 59, 59));
const usageAndCharges: ProjectUsageAndCharges[] = await api.projectsUsageAndCharges(startUTC, endUTC); const projectPartnerCharges: ProjectCharges = await api.projectsUsageAndCharges(startUTC, endUTC);
commit(SET_DATE, new DateRange(startUTC, endUTC)); commit(SET_DATE, new DateRange(startUTC, endUTC));
commit(SET_PROJECT_USAGE_AND_CHARGES, usageAndCharges); commit(SET_PROJECT_USAGE_AND_CHARGES, projectPartnerCharges);
commit(SET_PRICE_SUMMARY, usageAndCharges);
}, },
[GET_PROJECT_USAGE_PRICE_MODEL]: async function({ commit }: PaymentsContext): Promise<void> { [GET_PROJECT_USAGE_PRICE_MODEL]: async function({ commit }: PaymentsContext): Promise<void> {
const model: ProjectUsagePriceModel = await api.projectUsagePriceModel(); const model: ProjectUsagePriceModel = await api.projectUsagePriceModel();

View File

@ -0,0 +1,20 @@
// Copyright (C) 2023 Storj Labs, Inc.
// See LICENSE for copying information.
/**
* Represents a generic JSON object.
*/
export type JSONObject = string | number | boolean | null | JSONObject[] | {
[key: string]: [value: JSONObject];
};
/**
* Represents a JSON object which is a subset of T containing only properties
* whose values are JSON-representable.
*/
export type JSONRepresentable<T> =
T extends undefined ? never :
T extends JSONObject ? T :
Pick<T, {
[P in keyof T]: T[P] extends JSONObject ? P : never;
}[keyof T]>;

View File

@ -2,6 +2,7 @@
// See LICENSE for copying information. // See LICENSE for copying information.
import { formatPrice, decimalShift } from '@/utils/strings'; import { formatPrice, decimalShift } from '@/utils/strings';
import { JSONRepresentable } from '@/types/json';
/** /**
* Exposes all payments-related functionality * Exposes all payments-related functionality
@ -25,7 +26,7 @@ export interface PaymentsApi {
/** /**
* projectsUsagesAndCharges returns usage and how much money current user will be charged for each project which he owns. * projectsUsagesAndCharges returns usage and how much money current user will be charged for each project which he owns.
*/ */
projectsUsageAndCharges(since: Date, before: Date): Promise<ProjectUsageAndCharges[]>; projectsUsageAndCharges(since: Date, before: Date): Promise<ProjectCharges>;
/** /**
* projectUsagePriceModel returns the project usage price model for the user. * projectUsagePriceModel returns the project usage price model for the user.
@ -304,16 +305,15 @@ class Amount {
} }
/** /**
* ProjectUsageAndCharges shows usage and how much money current project will charge in the end of the month. * ProjectCharge shows usage and how much money current project will charge in the end of the month.
*/ */
export class ProjectUsageAndCharges { export class ProjectCharge {
public constructor( public constructor(
public since: Date = new Date(), public since: Date = new Date(),
public before: Date = new Date(), public before: Date = new Date(),
public egress: number = 0, public egress: number = 0,
public storage: number = 0, public storage: number = 0,
public segmentCount: number = 0, public segmentCount: number = 0,
public projectId: string = '',
// storage shows how much cents we should pay for storing GB*Hrs. // storage shows how much cents we should pay for storing GB*Hrs.
public storagePrice: number = 0, public storagePrice: number = 0,
// egress shows how many cents we should pay for Egress. // egress shows how many cents we should pay for Egress.
@ -329,6 +329,194 @@ export class ProjectUsageAndCharges {
} }
} }
/**
* The JSON representation of ProjectCharges returned from the API.
*/
type ProjectChargesJSON = {
priceModels: {
[partner: string]: JSONRepresentable<ProjectUsagePriceModel>
}
charges: {
[projectID: string]: {
[partner: string]: JSONRepresentable<ProjectCharge> & {
since: string;
before: string;
};
};
}
};
/**
* Represents a collection of project usage charges grouped by project ID and partner name
* in addition to project usage price models for each partner.
*/
export class ProjectCharges {
private map = new Map<string, Map<string, ProjectCharge>>();
private priceModels = new Map<string, ProjectUsagePriceModel>();
/**
* Set the usage charge for a project and partner.
*
* @param projectID - The ID of the project.
* @param partner - The name of the partner.
* @param charge - The usage and charges for the project and partner.
*/
public set(projectID: string, partner: string, charge: ProjectCharge): void {
const map = this.map.get(projectID) || new Map<string, ProjectCharge>();
map.set(partner, charge);
this.map.set(projectID, map);
}
/**
* Set the project usage price model for a partner.
*
* @param partner - The name of the partner.
* @param model - The price model for the partner.
*/
public setUsagePriceModel(partner: string, model: ProjectUsagePriceModel): void {
this.priceModels.set(partner, model);
}
/**
* Returns the usage charge for a project and partner or undefined if it does not exist.
*
* @param projectID - The ID of the project.
* @param partner - The name of the partner.
*/
public get(projectID: string, partner: string): ProjectCharge | undefined {
const map = this.map.get(projectID);
if (!map) return undefined;
return map.get(partner);
}
/**
* Returns the project usage price model for a partner or undefined if it does not exist.
*
* @param partner - The name of the partner.
*/
public getUsagePriceModel(partner: string): ProjectUsagePriceModel | undefined {
return this.priceModels.get(partner);
}
/**
* Returns the sum of all usage charges.
*/
public getPrice(): number {
let sum = 0;
this.forEachCharge(charge => {
sum += charge.summary();
});
return sum;
}
/**
* Returns the sum of all usage charges for the project ID.
*
* @param projectID - The ID of the project.
*/
public getProjectPrice(projectID: string): number {
let sum = 0;
this.forEachProjectCharge(projectID, charge => {
sum += charge.summary();
});
return sum;
}
/**
* Returns whether this collection contains information for a project.
*
* @param projectID - The ID of the project.
*/
public hasProject(projectID: string): boolean {
return this.map.has(projectID);
}
/**
* Iterate over each usage charge for all projects and partners.
*
* @param callback - A function to be called for each usage charge.
*/
public forEachCharge(callback: (charge: ProjectCharge, partner: string, projectID: string) => void): void {
this.map.forEach((partnerCharges, projectID) => {
partnerCharges.forEach((charge, partner) => {
callback(charge, partner, projectID);
});
});
}
/**
* Calls a provided function once for each usage charge associated with a given project.
*
* @param projectID The project ID for which to iterate over usage charges.
* @param callback The function to call for each usage charge, taking the charge object, partner name, and project ID as arguments.
*/
public forEachProjectCharge(projectID: string, callback: (charge: ProjectCharge, partner: string) => void): void {
const partnerCharges = this.map.get(projectID);
if (!partnerCharges) return;
partnerCharges.forEach((charge, partner) => {
callback(charge, partner);
});
}
/**
* Returns the collection as an array of nested arrays, where each inner array represents a project and its
* associated partner charges. The inner arrays have the format [projectID, [partnerCharge1, partnerCharge2, ...]],
* where each partnerCharge is a [partnerName, charge] tuple.
*/
public toArray(): [projectID: string, partnerCharges: [partner: string, charge: ProjectCharge][]][] {
const result: [string, [string, ProjectCharge][]][] = [];
this.map.forEach((partnerCharges, projectID) => {
const partnerChargeArray: [string, ProjectCharge][] = [];
partnerCharges.forEach((charge, partner) => {
partnerChargeArray.push([partner, charge]);
});
result.push([projectID, partnerChargeArray]);
});
return result;
}
/**
* Returns an array of all of the project IDs in the collection.
*/
public getProjectIDs(): string[] {
return Array.from(this.map.keys()).sort();
}
/**
* Returns a new ProjectPartnerCharges instance from a JSON representation.
*
* @param json - The JSON representation of the ProjectPartnerCharges.
*/
public static fromJSON(json: ProjectChargesJSON): ProjectCharges {
const charges = new ProjectCharges();
Object.entries(json.priceModels).forEach(([partner, model]) => {
charges.setUsagePriceModel(partner, new ProjectUsagePriceModel(
model.storageMBMonthCents,
model.egressMBCents,
model.segmentMonthCents,
));
});
Object.entries(json.charges).forEach(([projectID, partnerCharges]) => {
Object.entries(partnerCharges).forEach(([partner, charge]) => {
charges.set(projectID, partner, new ProjectCharge(
new Date(charge.since),
new Date(charge.before),
charge.egress,
charge.storage,
charge.segmentCount,
charge.storagePrice,
charge.egressPrice,
charge.segmentPrice,
));
});
});
return charges;
}
}
/** /**
* Holds start and end dates. * Holds start and end dates.
*/ */

View File

@ -7,11 +7,11 @@ import {
CreditCard, CreditCard,
PaymentsApi, PaymentsApi,
PaymentsHistoryItem, PaymentsHistoryItem,
ProjectUsageAndCharges,
ProjectUsagePriceModel, ProjectUsagePriceModel,
TokenDeposit, TokenDeposit,
NativePaymentHistoryItem, NativePaymentHistoryItem,
Wallet, Wallet,
ProjectCharges,
} from '@/types/payments'; } from '@/types/payments';
/** /**
@ -32,8 +32,8 @@ export class PaymentsMock implements PaymentsApi {
return Promise.resolve(new AccountBalance()); return Promise.resolve(new AccountBalance());
} }
projectsUsageAndCharges(): Promise<ProjectUsageAndCharges[]> { projectsUsageAndCharges(): Promise<ProjectCharges> {
return Promise.resolve([]); return Promise.resolve(new ProjectCharges());
} }
projectUsagePriceModel(): Promise<ProjectUsagePriceModel> { projectUsagePriceModel(): Promise<ProjectUsagePriceModel> {