diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index 4d20fdb787..c48202cd33 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -1,6 +1,7 @@
export const PAGE_LIMIT = 12; // default page limit
export const SPREADSHEET_PAGE_LIMIT = 50; // default sheet page limit
export const CARD_LIMIT = 6; // default card limit
+export const DEFAULT_BILLING_PROJECTS_LIMIT = 5; // default billing projects page limit
export const INTERVAL = 5 * 60000; // default interval to check for feedback
export const NEW_DEV_PRO_UPGRADE_COUPON = 'appw50';
diff --git a/src/routes/(console)/organization-[organization]/billing/+page.ts b/src/routes/(console)/organization-[organization]/billing/+page.ts
index d2334a6364..576765bf54 100644
--- a/src/routes/(console)/organization-[organization]/billing/+page.ts
+++ b/src/routes/(console)/organization-[organization]/billing/+page.ts
@@ -1,4 +1,4 @@
-import { BillingPlan, Dependencies } from '$lib/constants';
+import { BillingPlan, DEFAULT_BILLING_PROJECTS_LIMIT, Dependencies } from '$lib/constants';
import type { Address } from '$lib/sdk/billing';
import { type Organization } from '$lib/stores/organization';
import { sdk } from '$lib/stores/sdk';
@@ -39,7 +39,7 @@ export const load: PageLoad = async ({ parent, depends, url, route }) => {
let billingAggregation = null;
try {
const currentPage = getPage(url) || 1;
- const limit = getLimit(url, route, 5);
+ const limit = getLimit(url, route, DEFAULT_BILLING_PROJECTS_LIMIT);
const offset = pageToOffset(currentPage, limit);
billingAggregation = await sdk.forConsole.billing.getAggregation(
organization.$id,
@@ -94,8 +94,10 @@ export const load: PageLoad = async ({ parent, depends, url, route }) => {
countryList,
locale,
nextPlan: billingPlanDowngrade,
- // expose pagination for components
- limit: getLimit(url, route, 5),
- offset: pageToOffset(getPage(url) || 1, getLimit(url, route, 5))
+ limit: getLimit(url, route, DEFAULT_BILLING_PROJECTS_LIMIT),
+ offset: pageToOffset(
+ getPage(url) || 1,
+ getLimit(url, route, DEFAULT_BILLING_PROJECTS_LIMIT)
+ )
};
};
diff --git a/src/routes/(console)/organization-[organization]/billing/planSummary.svelte b/src/routes/(console)/organization-[organization]/billing/planSummary.svelte
index c49d52a759..4dac599a12 100644
--- a/src/routes/(console)/organization-[organization]/billing/planSummary.svelte
+++ b/src/routes/(console)/organization-[organization]/billing/planSummary.svelte
@@ -5,9 +5,9 @@
import { toLocaleDate } from '$lib/helpers/date';
import { upgradeURL } from '$lib/stores/billing';
import { organization } from '$lib/stores/organization';
- import type { AggregationTeam, Plan } from '$lib/sdk/billing';
+ import type { AggregationTeam, InvoiceUsage, Plan } from '$lib/sdk/billing';
import { formatCurrency } from '$lib/helpers/numbers';
- import { BillingPlan } from '$lib/constants';
+ import { BillingPlan, DEFAULT_BILLING_PROJECTS_LIMIT } from '$lib/constants';
import { Click, trackEvent } from '$lib/actions/analytics';
import {
Typography,
@@ -24,6 +24,7 @@
import CancelDowngradeModel from './cancelDowngradeModal.svelte';
import { IconTag } from '@appwrite.io/pink-icons-svelte';
import { page } from '$app/state';
+ import type { RowFactoryOptions } from '$routes/(console)/organization-[organization]/billing/store';
let {
currentPlan,
@@ -50,6 +51,26 @@
{ id: 'price', align: 'right' as const, width: { min: 100 } }
];
+ const projectsLimit = $derived(
+ limit ?? (Number(page.url.searchParams.get('limit')) || DEFAULT_BILLING_PROJECTS_LIMIT)
+ );
+ const projectsOffset = $derived(
+ offset ?? ((Number(page.url.searchParams.get('page')) || 1) - 1) * projectsLimit
+ );
+ const totalProjects = $derived(
+ (currentAggregation?.resources?.find?.((r) => r.resourceId === 'projects')?.value ??
+ null) ||
+ currentAggregation?.breakdown?.length ||
+ 0
+ );
+ const aggregationKey = $derived(
+ `agg:${Number(page.url.searchParams.get('page')) || 1}:${projectsLimit}`
+ );
+ const billingData = $derived(getBillingData(currentPlan, currentAggregation, $isSmallViewport));
+ const baseAmount = $derived(currentAggregation?.amount ?? currentPlan?.price ?? 0);
+ const creditsApplied = $derived(Math.min(baseAmount, availableCredit ?? 0));
+ const totalAmount = $derived(Math.max(baseAmount - creditsApplied, 0));
+
function formatHumanSize(bytes: number): string {
const size = humanFileSize(bytes || 0);
return `${size.value} ${size.unit}`;
@@ -123,57 +144,94 @@
];
}
- function getProjectsList(currentAggregation) {
- return (
- currentAggregation?.breakdown?.map((projectData) => ({
- projectId: projectData.$id,
- name: projectData.name,
- region: projectData.region,
- amount: projectData.amount,
- storage: projectData?.resources?.find((res) => res.resourceId === 'storage'),
- executions: projectData?.resources?.find(
- (resource) => resource.resourceId === 'executions'
- ),
- gbHours: projectData?.resources?.find(
- (resource) => resource.resourceId === 'GBHours'
- ),
- imageTransformations: projectData?.resources?.find(
- (resource) => resource.resourceId === 'imageTransformations'
- ),
- bandwidth: projectData?.resources?.find(
- (resource) => resource.resourceId === 'bandwidth'
- ),
- databasesReads: projectData?.resources?.find(
- (resource) => resource.resourceId === 'databasesReads'
- ),
- databasesWrites: projectData?.resources?.find(
- (resource) => resource.resourceId === 'databasesWrites'
- ),
- users: projectData?.resources?.find((resource) => resource.resourceId === 'users'),
- authPhone: projectData?.resources?.find(
- (resource) => resource.resourceId === 'authPhone'
- )
- })) || []
- );
+ function getResource(resources: InvoiceUsage[] | undefined, resourceId: string) {
+ return resources?.find((r) => r.resourceId === resourceId);
}
- const projectsLimit = $derived(limit ?? (Number(page.url.searchParams.get('limit')) || 5));
- const projectsOffset = $derived(
- offset ?? ((Number(page.url.searchParams.get('page')) || 1) - 1) * projectsLimit
- );
- const totalProjects = $derived(
- (currentAggregation?.resources?.find?.((r) => r.resourceId === 'projects')?.value ??
- null) ||
- currentAggregation?.breakdown?.length ||
- 0
- );
+ function createRow({
+ id,
+ label,
+ resource,
+ planLimit,
+ formatValue = formatNum,
+ usageFormatter,
+ priceFormatter,
+ progressFactory,
+ maxFactory,
+ includeProgress = true
+ }: RowFactoryOptions) {
+ const hasLimit = !!planLimit;
+ const value = resource?.value || 0;
+ const amount = resource?.amount || 0;
+
+ const usage = usageFormatter
+ ? usageFormatter({ value, planLimit, resource, formatValue, hasLimit })
+ : hasLimit
+ ? `${formatValue(value)} / ${formatValue(planLimit)}`
+ : `${formatValue(value)} / Unlimited`;
+
+ const price = priceFormatter
+ ? priceFormatter({ amount, resource })
+ : formatCurrency(amount);
+
+ const progressData = includeProgress
+ ? progressFactory
+ ? progressFactory({ value, planLimit, resource, hasLimit })
+ : hasLimit
+ ? createProgressData(value, planLimit)
+ : []
+ : undefined;
+
+ const maxValue = includeProgress
+ ? maxFactory
+ ? maxFactory({ planLimit, hasLimit, resource })
+ : hasLimit
+ ? planLimit || null
+ : null
+ : undefined;
+
+ const row: {
+ id: string;
+ cells: { item: string; usage: string; price: string };
+ progressData?: Array<{
+ size: number;
+ color: string;
+ tooltip?: { title: string; label: string };
+ }>;
+ maxValue?: number | null;
+ } = {
+ id,
+ cells: {
+ item: label,
+ usage,
+ price
+ }
+ };
- const aggregationKey = $derived(
- `agg:${Number(page.url.searchParams.get('page')) || 1}:${projectsLimit}`
- );
+ if (includeProgress) {
+ row.progressData = progressData;
+ row.maxValue = maxValue ?? null;
+ }
- function getBillingData(currentPlan, currentAggregation, isSmallViewport) {
- const projectsList = getProjectsList(currentAggregation);
+ return row;
+ }
+
+ function createResourceRow(
+ id: string,
+ label: string,
+ resource: InvoiceUsage | undefined,
+ planLimit: number | null | undefined,
+ formatValue = formatNum
+ ) {
+ return createRow({ id, label, resource, planLimit, formatValue });
+ }
+
+ function getBillingData(
+ currentPlan: Plan,
+ currentAggregation: AggregationTeam | undefined,
+ isSmallViewport: boolean
+ ) {
+ // base plan row
const basePlan = {
id: 'base-plan',
expandable: false,
@@ -182,175 +240,138 @@
usage: '',
price: formatCurrency(nextPlan?.price ?? currentPlan?.price ?? 0)
},
+ badge: null,
children: []
};
+
+ // addons (additional members, projects, etc.)
const addons = (currentAggregation?.resources || [])
- .filter(
- (r) =>
- r.amount &&
- r.amount > 0 &&
- Object.keys(currentPlan?.addons || {}).includes(r.resourceId) &&
- currentPlan.addons[r.resourceId]?.price > 0
- )
- .map((excess) => ({
- id: `addon-${excess.resourceId}`,
+ .filter((r) => r.amount > 0 && currentPlan?.addons?.[r.resourceId]?.price > 0)
+ .map((addon) => ({
+ id: `addon-${addon.resourceId}`,
expandable: false,
cells: {
item:
- excess.resourceId === 'seats'
+ addon.resourceId === 'seats'
? 'Additional members'
- : excess.resourceId === 'projects'
+ : addon.resourceId === 'projects'
? 'Additional projects'
- : `${excess.resourceId} overage (${formatNum(excess.value)})`,
+ : `${addon.resourceId} overage (${formatNum(addon.value)})`,
usage: '',
- price: formatCurrency(excess.amount)
+ price: formatCurrency(addon.amount)
},
- // provide a badge count for additional projects
- badge: excess.resourceId === 'projects' ? formatNum(excess.value) : null,
+ badge: addon.resourceId === 'projects' ? formatNum(addon.value) : null,
children: []
}));
- const projects = projectsList.map((project) => ({
- id: `project-${project.projectId}`,
- expandable: true,
- cells: {
- item: isSmallViewport
- ? truncateForSmall(project.name)
- : project.name || `Project ${project.projectId}`,
- usage: '',
- price: formatCurrency(project.amount || 0)
- },
- children: [
- {
- id: `bandwidth`,
- cells: {
- item: 'Bandwidth',
- usage: `${formatBandwidthUsage(project.bandwidth.value, currentPlan?.bandwidth)}`,
- price: formatCurrency(project.bandwidth.amount || 0)
- },
- progressData: createStorageProgressData(
- project.bandwidth.value || 0,
- currentPlan?.bandwidth || 0
- ),
- maxValue: currentPlan?.bandwidth
- ? currentPlan.bandwidth * 1000 * 1000 * 1000
- : 0
- },
- {
- id: `users`,
- cells: {
- item: 'Users',
- usage: `${formatNum(project.users.value || 0)} / ${currentPlan?.users ? formatNum(currentPlan.users) : 'Unlimited'}`,
- price: formatCurrency(project.users.amount || 0)
- },
- progressData: createProgressData(project.users.value || 0, currentPlan?.users),
- maxValue: currentPlan?.users
+
+ // project breakdown rows
+ const projects = (currentAggregation?.breakdown || []).map((projectData) => {
+ const resources = projectData.resources || [];
+ const bandwidth = getResource(resources, 'bandwidth');
+ const storage = getResource(resources, 'storage');
+ const authPhone = getResource(resources, 'authPhone');
+
+ return {
+ id: `project-${projectData.$id}`,
+ expandable: true,
+ cells: {
+ item: isSmallViewport
+ ? truncateForSmall(projectData.name)
+ : projectData.name || `Project ${projectData.$id}`,
+ usage: '',
+ price: formatCurrency(projectData.amount || 0)
},
- {
- id: `reads`,
- cells: {
- item: 'Database reads',
- usage: `${formatNum(project.databasesReads.value || 0)} / ${currentPlan?.databasesReads ? formatNum(currentPlan.databasesReads) : 'Unlimited'}`,
- price: formatCurrency(project.databasesReads.amount || 0)
- },
- progressData: createProgressData(
- project.databasesReads.value || 0,
+ badge: null,
+ children: [
+ createRow({
+ id: 'bandwidth',
+ label: 'Bandwidth',
+ resource: bandwidth,
+ planLimit: currentPlan?.bandwidth,
+ usageFormatter: ({ value, planLimit, hasLimit }) =>
+ formatBandwidthUsage(
+ value,
+ hasLimit ? (planLimit ?? undefined) : undefined
+ ),
+ priceFormatter: ({ amount }) => formatCurrency(amount),
+ progressFactory: ({ value, planLimit, hasLimit }) =>
+ hasLimit ? createStorageProgressData(value, planLimit || 0) : [],
+ maxFactory: ({ planLimit, hasLimit }) =>
+ hasLimit ? (planLimit || 0) * 1000 * 1000 * 1000 : null
+ }),
+ // standard resources (numeric)
+ createResourceRow(
+ 'users',
+ 'Users',
+ getResource(resources, 'users'),
+ currentPlan?.users
+ ),
+ createResourceRow(
+ 'reads',
+ 'Database reads',
+ getResource(resources, 'databasesReads'),
currentPlan?.databasesReads
),
- maxValue: currentPlan?.databasesReads
- },
- {
- id: `writes`,
- cells: {
- item: 'Database writes',
- usage: `${formatNum(project.databasesWrites.value || 0)} / ${currentPlan?.databasesWrites ? formatNum(currentPlan.databasesWrites) : 'Unlimited'}`,
- price: formatCurrency(project.databasesWrites.amount || 0)
- },
- progressData: createProgressData(
- project.databasesWrites.value || 0,
+ createResourceRow(
+ 'writes',
+ 'Database writes',
+ getResource(resources, 'databasesWrites'),
currentPlan?.databasesWrites
),
- maxValue: currentPlan?.databasesWrites
- },
- {
- id: `executions`,
- cells: {
- item: 'Executions',
- usage: `${formatNum(project.executions.value || 0)} / ${currentPlan?.executions ? formatNum(currentPlan.executions) : 'Unlimited'}`,
- price: formatCurrency(project.executions.amount || 0)
- },
- progressData: createProgressData(
- project.executions.value || 0,
+ createResourceRow(
+ 'executions',
+ 'Executions',
+ getResource(resources, 'executions'),
currentPlan?.executions
),
- maxValue: currentPlan?.executions
- },
- {
- id: `storage`,
- cells: {
- item: 'Storage',
- usage: `${formatHumanSize(project.storage.value || 0)} / ${currentPlan?.storage?.toString() || '0'} GB`,
- price: formatCurrency(project.storage.amount || 0)
- },
- progressData: createStorageProgressData(
- project.storage.value || 0,
- currentPlan?.storage || 0
- ),
- maxValue: currentPlan?.storage ? currentPlan.storage * 1000 * 1000 * 1000 : 0
- },
- {
- id: `image-transformations`,
- cells: {
- item: 'Image transformations',
- usage: `${formatNum(project.imageTransformations.value || 0)} / ${currentPlan?.imageTransformations ? formatNum(currentPlan.imageTransformations) : 'Unlimited'}`,
- price: formatCurrency(project.imageTransformations.amount || 0)
- },
- progressData: createProgressData(
- project.imageTransformations.value || 0,
+ createRow({
+ id: 'storage',
+ label: 'Storage',
+ resource: storage,
+ planLimit: currentPlan?.storage,
+ usageFormatter: ({ value, planLimit, hasLimit }) =>
+ hasLimit
+ ? `${formatHumanSize(value)} / ${planLimit?.toString() || '0'} GB`
+ : `${formatHumanSize(value)} / Unlimited`,
+ priceFormatter: ({ amount }) => formatCurrency(amount),
+ progressFactory: ({ value, planLimit, hasLimit }) =>
+ hasLimit ? createStorageProgressData(value, planLimit || 0) : [],
+ maxFactory: ({ planLimit, hasLimit }) =>
+ hasLimit ? (planLimit || 0) * 1000 * 1000 * 1000 : null
+ }),
+ createResourceRow(
+ 'image-transformations',
+ 'Image transformations',
+ getResource(resources, 'imageTransformations'),
currentPlan?.imageTransformations
),
- maxValue: currentPlan?.imageTransformations
- },
- {
- id: `gb-hours`,
- cells: {
- item: 'GB-hours',
- usage: `${formatNum(project.gbHours.value || 0)} / ${currentPlan?.GBHours ? formatNum(currentPlan.GBHours) : 'Unlimited'}`,
- price: formatCurrency(project.gbHours.amount || 0)
- },
- progressData: currentPlan?.GBHours
- ? createProgressData(project.gbHours.value || 0, currentPlan.GBHours)
- : [],
- maxValue: currentPlan?.GBHours ? currentPlan.GBHours : null
- },
- {
- id: `sms`,
- cells: {
- item: 'Phone OTP',
- usage: `${formatNum(project.authPhone.value || 0)} SMS messages`,
- price: formatCurrency(project.authPhone.amount || 0)
- }
- },
- {
- id: `usage-details`,
- cells: {
- item: `Usage details`,
- usage: '',
- price: ''
- }
- }
- ]
- }));
- const noProjects = [];
- return [basePlan, ...addons, ...projects, ...noProjects];
+ createResourceRow(
+ 'gb-hours',
+ 'GB-hours',
+ getResource(resources, 'GBHours'),
+ currentPlan?.GBHours
+ ),
+ createRow({
+ id: 'sms',
+ label: 'Phone OTP',
+ resource: authPhone,
+ usageFormatter: ({ value }) => `${formatNum(value)} SMS messages`,
+ priceFormatter: ({ amount }) => formatCurrency(amount),
+ includeProgress: false
+ }),
+ createRow({
+ id: 'usage-details',
+ label: `Usage details`,
+ usageFormatter: () => '',
+ priceFormatter: () => '',
+ includeProgress: false
+ })
+ ]
+ };
+ });
+
+ return [basePlan, ...addons, ...projects];
}
-
- const billingData = $derived(getBillingData(currentPlan, currentAggregation, $isSmallViewport));
-
- const creditsApplied = $derived(
- Math.min(currentAggregation?.amount ?? currentPlan?.price ?? 0, availableCredit)
- );
-
- const totalAmount = $derived(Math.max(currentAggregation?.amount - creditsApplied, 0));
{#if $organization}
diff --git a/src/routes/(console)/organization-[organization]/billing/store.ts b/src/routes/(console)/organization-[organization]/billing/store.ts
index 30d9b1c3cb..a8f05930a9 100644
--- a/src/routes/(console)/organization-[organization]/billing/store.ts
+++ b/src/routes/(console)/organization-[organization]/billing/store.ts
@@ -1,7 +1,7 @@
import { page } from '$app/stores';
-import type { WizardStepsType } from '$lib/layout/wizardWithSteps.svelte';
-import type { AggregationList, Invoice } from '$lib/sdk/billing';
import { derived, writable } from 'svelte/store';
+import type { WizardStepsType } from '$lib/layout/wizardWithSteps.svelte';
+import type { AggregationList, Invoice, InvoiceUsage } from '$lib/sdk/billing';
export const aggregationList = derived(
page,
@@ -16,3 +16,31 @@ export const addCreditWizardStore = writable<{ coupon: string; paymentMethodId:
export const selectedInvoice = writable(null);
export const showRetryModal = writable(false);
+
+export type RowFactoryOptions = {
+ id: string;
+ label: string;
+ resource?: InvoiceUsage;
+ planLimit?: number | null;
+ includeProgress?: boolean;
+ formatValue?: (value: number | null | undefined) => string;
+ usageFormatter?: (options: {
+ value: number;
+ planLimit?: number | null;
+ resource?: InvoiceUsage;
+ formatValue: (value: number | null | undefined) => string;
+ hasLimit: boolean;
+ }) => string;
+ priceFormatter?: (options: { amount: number; resource?: InvoiceUsage }) => string;
+ progressFactory?: (options: {
+ value: number;
+ planLimit?: number | null;
+ resource?: InvoiceUsage;
+ hasLimit: boolean;
+ }) => Array<{ size: number; color: string; tooltip?: { title: string; label: string } }>;
+ maxFactory?: (options: {
+ planLimit?: number | null;
+ hasLimit: boolean;
+ resource?: InvoiceUsage;
+ }) => number | null;
+};