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
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)))
}

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.
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) {

View File

@ -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

View File

@ -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"`

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.
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

View File

@ -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<ProjectUsageAndCharges[]> {
public async projectsUsageAndCharges(start: Date, end: Date): Promise<ProjectCharges> {
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<ProjectUsagePriceModel> {
const path = `${this.ROOT_PATH}/pricing`;

View File

@ -77,14 +77,12 @@
:on-press="routeToBillingHistory"
/>
</div>
<div class="usage-charges-item-container__detailed-info-container__footer__buttons">
<UsageAndChargesItem
v-for="usageAndCharges in projectUsageAndCharges"
:key="usageAndCharges.projectId"
:item="usageAndCharges"
class="cost-by-project__item"
/>
</div>
<UsageAndChargesItem
v-for="id in projectIDs"
:key="id"
:project-id="id"
class="cost-by-project__item"
/>
<router-view />
</div>
</div>
@ -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 {

View File

@ -12,49 +12,54 @@
Estimated Total &nbsp;
<span
class="usage-charges-item-container__summary__amount"
>{{ item.summary() | centsToDollars }}
>{{ projectCharges.getProjectPrice(projectId) | centsToDollars }}
</span>
</span>
</div>
<div v-if="isDetailedInfoShown" class="usage-charges-item-container__detailed-info-container">
<div class="divider" />
<div class="usage-charges-item-container__detailed-info-container__info-header">
<span class="resource-header">RESOURCE</span>
<span class="period-header">PERIOD</span>
<span class="usage-header">USAGE</span>
<span class="cost-header">COST</span>
</div>
<div class="usage-charges-item-container__detailed-info-container__content-area">
<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>Egress <span class="price-per-month">({{ egressPrice }} per GB)</span></p>
<p>Segments <span class="price-per-month">({{ segmentPrice }} per Segment-Month)</span></p>
<template v-if="isDetailedInfoShown">
<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">
<span class="resource-header">RESOURCE</span>
<span class="period-header">PERIOD</span>
<span class="usage-header">USAGE</span>
<span class="cost-header">COST</span>
</div>
<div class="usage-charges-item-container__detailed-info-container__content-area__period-container">
<p>{{ period }}</p>
<p>{{ period }}</p>
<p>{{ period }}</p>
</div>
<div class="usage-charges-item-container__detailed-info-container__content-area__usage-container">
<p>{{ storageFormatted }} Gigabyte-month</p>
<p>{{ egressAmountAndDimension }}</p>
<p>{{ segmentCountFormatted }} Segment-month</p>
</div>
<div class="usage-charges-item-container__detailed-info-container__content-area__cost-container">
<p class="price">{{ item.storagePrice | centsToDollars }}</p>
<p class="price">{{ item.egressPrice | centsToDollars }}</p>
<p class="price">{{ item.segmentPrice | centsToDollars }}</p>
<div class="usage-charges-item-container__detailed-info-container__content-area">
<div class="usage-charges-item-container__detailed-info-container__content-area__resource-container">
<p>Storage <span class="price-per-month">({{ getStoragePrice(partner) }} per Gigabyte-Month)</span></p>
<p>Egress <span class="price-per-month">({{ getEgressPrice(partner) }} per GB)</span></p>
<p>Segments <span class="price-per-month">({{ getSegmentPrice(partner) }} per Segment-Month)</span></p>
</div>
<div class="usage-charges-item-container__detailed-info-container__content-area__period-container">
<p v-for="i in 3" :key="i">{{ getPeriod(charge) }}</p>
</div>
<div class="usage-charges-item-container__detailed-info-container__content-area__usage-container">
<p>{{ getStorageFormatted(charge) }} Gigabyte-month</p>
<p>{{ getEgressAmountAndDimension(charge) }}</p>
<p>{{ getSegmentCountFormatted(charge) }} Segment-month</p>
</div>
<div class="usage-charges-item-container__detailed-info-container__content-area__cost-container">
<p class="price">{{ charge.storagePrice | centsToDollars }}</p>
<p class="price">{{ charge.egressPrice | centsToDollars }}</p>
<p class="price">{{ charge.segmentPrice | centsToDollars }}</p>
</div>
</div>
</div>
<div class="usage-charges-item-container__detailed-info-container__footer" />
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { ProjectUsageAndCharges, ProjectUsagePriceModel } from '@/types/payments';
import { ProjectCharge, ProjectCharges, ProjectUsagePriceModel } from '@/types/payments';
import { Project } from '@/types/projects';
import { Size } from '@/utils/bytesSize';
import { SHORT_MONTHS_NAMES } from '@/utils/constants/date';
@ -70,11 +75,11 @@ const HOURS_IN_MONTH = 720;
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();
@ -84,83 +89,101 @@ const store = useStore();
*/
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.
*/
const projectName = computed((): string => {
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 || '';
});
/**
* Returns string of date range.
* Returns project usage price model from store.
*/
const period = computed((): string => {
const since = `${SHORT_MONTHS_NAMES[props.item.since.getUTCMonth()]} ${props.item.since.getUTCDate()}`;
const before = `${SHORT_MONTHS_NAMES[props.item.before.getUTCMonth()]} ${props.item.before.getUTCDate()}`;
return `${since} - ${before}`;
const projectCharges = computed((): ProjectCharges => {
return store.state.paymentsModule.projectCharges;
});
/**
* Returns project usage price model from store.
*/
const priceModel = computed((): ProjectUsagePriceModel => {
return store.state.paymentsModule.usagePriceModel;
});
function getPriceModel(partner: string): ProjectUsagePriceModel {
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.
*/
const egressFormatted = computed((): Size => {
return new Size(props.item.egress, 2);
});
function egressFormatted(charge: ProjectCharge): Size {
return new Size(charge.egress, 2);
}
/**
* Returns formatted storage used in GB x month dimension.
*/
const storageFormatted = computed((): string => {
function getStorageFormatted(charge: ProjectCharge): string {
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.
*/
const segmentCountFormatted = computed((): string => {
return (props.item.segmentCount / HOURS_IN_MONTH).toFixed(2);
});
function getSegmentCountFormatted(charge: ProjectCharge): string {
return (charge.segmentCount / HOURS_IN_MONTH).toFixed(2);
}
/**
* Returns storage price per GB.
*/
const storagePrice = computed((): string => {
return formatPrice(decimalShift(priceModel.value.storageMBMonthCents, CENTS_MB_TO_DOLLARS_GB_SHIFT));
});
function getStoragePrice(partner: string): string {
return formatPrice(decimalShift(getPriceModel(partner).storageMBMonthCents, CENTS_MB_TO_DOLLARS_GB_SHIFT));
}
/**
* Returns egress price per GB.
*/
const egressPrice = computed((): string => {
return formatPrice(decimalShift(priceModel.value.egressMBCents, CENTS_MB_TO_DOLLARS_GB_SHIFT));
});
function getEgressPrice(partner: string): string {
return formatPrice(decimalShift(getPriceModel(partner).egressMBCents, CENTS_MB_TO_DOLLARS_GB_SHIFT));
}
/**
* Returns segment price.
*/
const segmentPrice = computed((): string => {
return formatPrice(decimalShift(priceModel.value.segmentMonthCents, 2));
});
function getSegmentPrice(partner: string): string {
return formatPrice(decimalShift(getPriceModel(partner).segmentMonthCents, 2));
}
/**
* Returns string of egress amount and dimension.
*/
const egressAmountAndDimension = computed((): string => {
return `${egressFormatted.value.formattedBytes} ${egressFormatted.value.label}`;
});
function getEgressAmountAndDimension(charge: ProjectCharge): string {
const egress = egressFormatted(charge);
return `${egress.formattedBytes} ${egress.label}`;
}
/**
* toggleDetailedInfo expands an area with detailed information about project charges.
@ -193,12 +216,12 @@ function toggleDetailedInfo(): void {
}
.usage-charges-item-container {
color: var(--c-black);
font-size: 16px;
line-height: 21px;
padding: 20px;
margin-top: 10px;
font-family: 'font_regular', sans-serif;
background-color: #fff;
background-color: var(--c-white);
border-radius: 8px;
box-shadow: 0 0 20px rgb(0 0 0 / 4%);
@ -207,6 +230,7 @@ function toggleDetailedInfo(): void {
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
padding: 20px;
cursor: pointer;
&__name-container {
@ -230,20 +254,18 @@ function toggleDetailedInfo(): void {
}
&__text {
font-size: 16px;
font-size: 17px;
line-height: 21px;
text-align: right;
color: #354049;
display: flex;
align-items: center;
}
&__amount {
font-family: 'font_bold', sans-serif;
font-size: 24px;
line-height: 31px;
font-weight: 800;
text-align: right;
color: #000;
}
}
@ -252,6 +274,13 @@ function toggleDetailedInfo(): void {
flex-direction: column;
align-items: flex-start;
justify-content: space-between;
padding: 24px 20px;
border-top: 1px solid var(--c-grey-2);
&__partner {
font-size: 17px;
margin-bottom: 16px;
}
&__info-header {
display: flex;
@ -263,7 +292,6 @@ function toggleDetailedInfo(): void {
font-weight: 600;
height: 25px;
width: 100%;
padding-top: 10px;
}
&__content-area {
@ -295,63 +323,6 @@ function toggleDetailedInfo(): void {
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;
}
}
@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>

View File

@ -161,6 +161,7 @@ import { APP_STATE_ACTIONS } from '@/utils/constants/actionNames';
import { BUCKET_ACTIONS } from '@/store/modules/buckets';
import { RouteConfig } from '@/router';
import { DataStamp, Project, ProjectLimits } from '@/types/projects';
import { ProjectCharges } from '@/types/payments';
import { Dimensions, Size } from '@/utils/bytesSize';
import { ChartUtils } from '@/utils/chart';
import { AnalyticsHttpApi } from '@/api/analytics';
@ -378,7 +379,9 @@ export default class ProjectDashboard extends Vue {
* estimatedCharges returns estimated charges summary for selected project.
*/
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,
PaymentsHistoryItemStatus,
PaymentsHistoryItemType,
ProjectUsageAndCharges,
ProjectCharges,
ProjectUsagePriceModel,
NativePaymentHistoryItem,
Wallet,
@ -31,8 +31,6 @@ export const PAYMENTS_MUTATIONS = {
SET_PROJECT_USAGE_PRICE_MODEL: 'SET_PROJECT_USAGE_PRICE_MODEL',
SET_CURRENT_ROLLUP_PRICE: 'SET_CURRENT_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',
};
@ -71,8 +69,6 @@ const {
SET_NATIVE_PAYMENTS_HISTORY,
SET_PROJECT_USAGE_AND_CHARGES,
SET_PROJECT_USAGE_PRICE_MODEL: SET_PROJECT_USAGE_PRICE_MODEL,
SET_PRICE_SUMMARY,
SET_PRICE_SUMMARY_FOR_SELECTED_PROJECT,
SET_COUPON,
} = PAYMENTS_MUTATIONS;
@ -106,10 +102,8 @@ export class PaymentsState {
public creditCards: CreditCard[] = [];
public paymentsHistory: PaymentsHistoryItem[] = [];
public nativePaymentsHistory: NativePaymentHistoryItem[] = [];
public usageAndCharges: ProjectUsageAndCharges[] = [];
public projectCharges: ProjectCharges = new ProjectCharges();
public usagePriceModel: ProjectUsagePriceModel = new ProjectUsagePriceModel();
public priceSummary = 0;
public priceSummaryForSelectedProject = 0;
public startDate: Date = new Date();
public endDate: Date = new Date();
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 {
state.nativePaymentsHistory = paymentsHistory;
},
[SET_PROJECT_USAGE_AND_CHARGES](state: PaymentsState, usageAndCharges: ProjectUsageAndCharges[]): void {
state.usageAndCharges = usageAndCharges;
[SET_PROJECT_USAGE_AND_CHARGES](state: PaymentsState, projectPartnerCharges: ProjectCharges): void {
state.projectCharges = projectPartnerCharges;
},
[SET_PROJECT_USAGE_PRICE_MODEL](state: PaymentsState, model: ProjectUsagePriceModel): void {
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 {
state.coupon = coupon;
},
@ -219,10 +188,8 @@ export function makePaymentsModule(api: PaymentsApi): StoreModule<PaymentsState,
state.creditCards = [];
state.paymentsHistory = [];
state.nativePaymentsHistory = [];
state.usageAndCharges = [];
state.projectCharges = new ProjectCharges();
state.usagePriceModel = new ProjectUsagePriceModel();
state.priceSummary = 0;
state.priceSummaryForSelectedProject = 0;
state.startDate = new Date();
state.endDate = new Date();
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 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_PROJECT_USAGE_AND_CHARGES, usageAndCharges);
commit(SET_PRICE_SUMMARY, usageAndCharges);
commit(SET_PRICE_SUMMARY_FOR_SELECTED_PROJECT, rootGetters.selectedProject.id);
commit(SET_PROJECT_USAGE_AND_CHARGES, projectPartnerCharges);
},
[GET_PROJECT_USAGE_AND_CHARGES_PREVIOUS_ROLLUP]: async function({ commit }: PaymentsContext): Promise<void> {
const now = new Date();
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 usageAndCharges: ProjectUsageAndCharges[] = await api.projectsUsageAndCharges(startUTC, endUTC);
const projectPartnerCharges: ProjectCharges = await api.projectsUsageAndCharges(startUTC, endUTC);
commit(SET_DATE, new DateRange(startUTC, endUTC));
commit(SET_PROJECT_USAGE_AND_CHARGES, usageAndCharges);
commit(SET_PRICE_SUMMARY, usageAndCharges);
commit(SET_PROJECT_USAGE_AND_CHARGES, projectPartnerCharges);
},
[GET_PROJECT_USAGE_PRICE_MODEL]: async function({ commit }: PaymentsContext): Promise<void> {
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.
import { formatPrice, decimalShift } from '@/utils/strings';
import { JSONRepresentable } from '@/types/json';
/**
* 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.
*/
projectsUsageAndCharges(since: Date, before: Date): Promise<ProjectUsageAndCharges[]>;
projectsUsageAndCharges(since: Date, before: Date): Promise<ProjectCharges>;
/**
* 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 since: Date = new Date(),
public before: Date = new Date(),
public egress: number = 0,
public storage: number = 0,
public segmentCount: number = 0,
public projectId: string = '',
// storage shows how much cents we should pay for storing GB*Hrs.
public storagePrice: number = 0,
// 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.
*/

View File

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