diff --git a/package.json b/package.json index 3104f604a4..02cb4f1718 100644 --- a/package.json +++ b/package.json @@ -22,11 +22,11 @@ }, "dependencies": { "@ai-sdk/svelte": "^1.1.24", - "@appwrite.io/console": "https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@2428", + "@appwrite.io/console": "https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@6031134", "@appwrite.io/pink-icons": "0.25.0", - "@appwrite.io/pink-icons-svelte": "^2.0.0-RC.1", + "@appwrite.io/pink-icons-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@2cf27e0", "@appwrite.io/pink-legacy": "^1.0.3", - "@appwrite.io/pink-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@18188b7", + "@appwrite.io/pink-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@2cf27e0", "@faker-js/faker": "^9.9.0", "@popperjs/core": "^2.11.8", "@sentry/sveltekit": "^8.38.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 28f2888f7f..a6ff6011a7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,20 +12,20 @@ importers: specifier: ^1.1.24 version: 1.1.24(svelte@5.25.3)(zod@3.24.3) '@appwrite.io/console': - specifier: https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@2428 - version: https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@2428 + specifier: https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@6031134 + version: https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@6031134 '@appwrite.io/pink-icons': specifier: 0.25.0 version: 0.25.0 '@appwrite.io/pink-icons-svelte': - specifier: ^2.0.0-RC.1 - version: https://try-module.cloud/module/@appwrite/%40appwrite.io%2Fpink-icons-svelte@12707b9(svelte@5.25.3) + specifier: https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@2cf27e0 + version: https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@2cf27e0(svelte@5.25.3) '@appwrite.io/pink-legacy': specifier: ^1.0.3 version: 1.0.3 '@appwrite.io/pink-svelte': - specifier: https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@18188b7 - version: https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@18188b7(svelte@5.25.3) + specifier: https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@2cf27e0 + version: https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@2cf27e0(svelte@5.25.3) '@faker-js/faker': specifier: ^9.9.0 version: 9.9.0 @@ -260,8 +260,8 @@ packages: '@analytics/type-utils@0.6.2': resolution: {integrity: sha512-TD+xbmsBLyYy/IxFimW/YL/9L2IEnM7/EoV9Aeh56U64Ify8o27HJcKjo38XY9Tcn0uOq1AX3thkKgvtWvwFQg==} - '@appwrite.io/console@https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@2428': - resolution: {tarball: https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@2428} + '@appwrite.io/console@https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@6031134': + resolution: {tarball: https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@6031134} version: 1.10.0 '@appwrite.io/pink-icons-svelte@2.0.0-RC.1': @@ -269,8 +269,8 @@ packages: peerDependencies: svelte: ^4.0.0 - '@appwrite.io/pink-icons-svelte@https://try-module.cloud/module/@appwrite/%40appwrite.io%2Fpink-icons-svelte@12707b9': - resolution: {tarball: https://try-module.cloud/module/@appwrite/%40appwrite.io%2Fpink-icons-svelte@12707b9} + '@appwrite.io/pink-icons-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@2cf27e0': + resolution: {tarball: https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@2cf27e0} version: 2.0.0-RC.1 peerDependencies: svelte: ^4.0.0 @@ -284,8 +284,8 @@ packages: '@appwrite.io/pink-legacy@1.0.3': resolution: {integrity: sha512-GGde5fmPhs+s6/3aFeMPc/kKADG/gTFkYQSy6oBN8pK0y0XNCLrZZgBv+EBbdhwdtqVEWXa0X85Mv9w7jcIlwQ==} - '@appwrite.io/pink-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@18188b7': - resolution: {tarball: https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@18188b7} + '@appwrite.io/pink-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@2cf27e0': + resolution: {tarball: https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@2cf27e0} version: 2.0.0-RC.2 peerDependencies: svelte: ^4.0.0 @@ -3700,13 +3700,13 @@ snapshots: '@analytics/type-utils@0.6.2': {} - '@appwrite.io/console@https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@2428': {} + '@appwrite.io/console@https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@6031134': {} '@appwrite.io/pink-icons-svelte@2.0.0-RC.1(svelte@5.25.3)': dependencies: svelte: 5.25.3 - '@appwrite.io/pink-icons-svelte@https://try-module.cloud/module/@appwrite/%40appwrite.io%2Fpink-icons-svelte@12707b9(svelte@5.25.3)': + '@appwrite.io/pink-icons-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@2cf27e0(svelte@5.25.3)': dependencies: svelte: 5.25.3 @@ -3719,7 +3719,7 @@ snapshots: '@appwrite.io/pink-icons': 1.0.0 the-new-css-reset: 1.11.3 - '@appwrite.io/pink-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@18188b7(svelte@5.25.3)': + '@appwrite.io/pink-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@2cf27e0(svelte@5.25.3)': dependencies: '@appwrite.io/pink-icons-svelte': 2.0.0-RC.1(svelte@5.25.3) '@floating-ui/dom': 1.6.13 diff --git a/src/lib/components/archiveProject.svelte b/src/lib/components/archiveProject.svelte new file mode 100644 index 0000000000..3ebb938c10 --- /dev/null +++ b/src/lib/components/archiveProject.svelte @@ -0,0 +1,292 @@ + + +{#if projectsToArchive.length > 0} +
+ + + These projects have been archived and are read-only. You can view and migrate their + data. + + +
+ + {#each projectsToArchive as project} + {@const platforms = filterPlatforms( + project.platforms.map((platform) => getPlatformInfo(platform.type)) + )} + {@const formatted = formatName(project.name)} + + + {project?.platforms?.length ? project?.platforms?.length : 'No'} apps + + {formatted} + +
+ + { + e.preventDefault(); + e.stopPropagation(); + readOnlyInfoOpen = { + ...readOnlyInfoOpen, + [project.$id]: !readOnlyInfoOpen[project.$id] + }; + }}> + + Read only + + +
  • + + Archived projects are read-only. You can view + and migrate their data, but they no longer + accept edits or requests. + +
  • +
    +
    + + + + handleUnarchiveProject(project)} + >Unarchive project + handleMigrateProject(project)} + >Migrate project + + +
    +
    + + {#each platforms.slice(0, 2) as platform} + {@const icon = getIconForPlatform(platform.icon)} + + + + {/each} + + {#if platforms.length > 3} + + {/if} + + + {#if isCloud && $regionsStore?.regions} + {@const region = findRegion(project)} + {region?.name} + {/if} + +
    + {/each} +
    +
    +
    +
    +{/if} + + + +

    Are you sure you want to unarchive {projectToUnarchive?.name}?

    +

    This will move the project back to your active projects list.

    + + + + + + + +
    + + diff --git a/src/lib/components/billing/alerts/limitReached.svelte b/src/lib/components/billing/alerts/limitReached.svelte index 11d832107c..4885db75e1 100644 --- a/src/lib/components/billing/alerts/limitReached.svelte +++ b/src/lib/components/billing/alerts/limitReached.svelte @@ -20,9 +20,14 @@ plan. Consider upgrading to increase your resource usage. - + {#if !page.data.currentPlan?.usagePerProject} + + {/if} + + + {/if} + +
    + + + Resource + Free limit + + + Excess usage + + + Usage beyond the Free plan limits. + + + + + + + + + + + Projects + {#if isLimitExceeded.projects} + + {/if} + + + + {formatNumber(allowedProjectsToKeep)} projects + + + {#if isLimitExceeded.projects} + + + + {formatNumber(excessUsage.projects)} projects + + + {:else} + + {formatNumber(currentUsage.projects)} / {formatNumber( + allowedProjectsToKeep + )} + + {/if} + + + {#if isLimitExceeded.projects} + + + + {/if} + + + + + + + Organization members + + + {formatNumber(freePlanLimits.members)} member + + + {#if isLimitExceeded.members} + + + + {formatNumber(excessUsage.members)} members + + + {:else} + + {formatNumber(currentUsage.members)} / {formatNumber( + freePlanLimits.members + )} + + {/if} + + + + + + + + Storage + + + {freePlanLimits.storage} GB + + + {#if isLimitExceeded.storage} + + + + {excessUsage.storage.toFixed(2)} GB + + + {:else} + + {storageUsageGB.toFixed(2)} / {freePlanLimits.storage} GB + + {/if} + + + + +
    + + + + + Choose which two projects to keep. Projects over the limit will be blocked after this date. + + + {#if error} + {error} + {/if} + +
    + + + Project Name + Created + + {#each projects as project} + + {project.name} + + {toLocaleDateTime(project.$createdAt)} + + + {/each} + +
    + {#if selectedProjects.length === allowedProjectsToKeep} + {@const difference = projects.length - selectedProjects.length} + {@const messagePrefix = difference > 1 ? `${difference} projects` : `${difference} project`} + + {formatProjectsToArchive()} will be archived + + {/if} + + + + + +
    + + diff --git a/src/lib/components/progressbar/ProgressBar.svelte b/src/lib/components/progressbar/ProgressBar.svelte index 58b3a3a394..a4cebc8eed 100644 --- a/src/lib/components/progressbar/ProgressBar.svelte +++ b/src/lib/components/progressbar/ProgressBar.svelte @@ -66,6 +66,7 @@ flex-direction: row; gap: 2px; margin-top: 1rem; + overflow: hidden; } &__content { diff --git a/src/lib/helpers/string.ts b/src/lib/helpers/string.ts index c138a7483d..91dd9a37cb 100644 --- a/src/lib/helpers/string.ts +++ b/src/lib/helpers/string.ts @@ -49,6 +49,19 @@ export function formatNum(number: number): string { return formatter.format(number); } +/** + * Format a string with optional mobile-aware truncation. + */ +export function formatName( + name: string, + limit: number = 19, + isSmallViewport: boolean = false +): string { + const mobileLimit = 16; + const actualLimit = isSmallViewport ? mobileLimit : limit; + return name ? (name.length > actualLimit ? `${name.slice(0, actualLimit)}...` : name) : '-'; +} + /** * Returns a regex to check hostname validity. Supports wildcards too! */ diff --git a/src/lib/layout/createProject.svelte b/src/lib/layout/createProject.svelte index 5d951babd3..52915971d8 100644 --- a/src/lib/layout/createProject.svelte +++ b/src/lib/layout/createProject.svelte @@ -4,13 +4,15 @@ import { CustomId } from '$lib/components/index.js'; import { getFlagUrl } from '$lib/helpers/flag'; import { isCloud } from '$lib/system.js'; - import { currentPlan } from '$lib/stores/organization'; + import { currentPlan, organization } from '$lib/stores/organization'; import { Button } from '$lib/elements/forms'; import { base } from '$app/paths'; import { page } from '$app/state'; import type { Models } from '@appwrite.io/console'; import { filterRegions } from '$lib/helpers/regions'; import type { Snippet } from 'svelte'; + import { BillingPlan } from '$lib/constants'; + import { formatCurrency } from '$lib/helpers/numbers'; let { projectName = $bindable(''), @@ -18,6 +20,7 @@ regions = [], region = $bindable(''), showTitle = true, + billingPlan = undefined, projects = undefined, submit }: { @@ -26,13 +29,17 @@ regions: Array; region: string; showTitle: boolean; + billingPlan?: BillingPlan; projects?: number; submit?: Snippet; } = $props(); let showCustomId = $state(false); + let isProPlan = $derived((billingPlan ?? $organization?.billingPlan) === BillingPlan.PRO); let projectsLimited = $derived( - $currentPlan?.projects > 0 && projects && projects >= $currentPlan?.projects + isProPlan + ? projects && projects >= 2 + : $currentPlan?.projects > 0 && projects && projects >= $currentPlan?.projects ); @@ -46,26 +53,12 @@ {#if showTitle} Create your project {/if} - {#if projectsLimited} - - Extra projects are available on paid plans for an additional fee - - - - - {/if} + + {#if isCloud && regions.length > 0} Region cannot be changed after creation {/if} + {#if projectsLimited} + {#if isProPlan} + + Each added project comes with its own dedicated pool of resources. + + {:else} + + Extra projects are available on paid plans for an additional fee + + + + + {/if} + {/if} {@render submit?.()} diff --git a/src/lib/sdk/billing.ts b/src/lib/sdk/billing.ts index 3dbd0b9f9d..7ae79e199f 100644 --- a/src/lib/sdk/billing.ts +++ b/src/lib/sdk/billing.ts @@ -151,6 +151,79 @@ export type CreditList = { total: number; }; +export type AggregationTeam = { + $id: string; + /** + * Aggregation creation time in ISO 8601 format. + */ + $createdAt: string; + /** + * Aggregation update date in ISO 8601 format. + */ + $updatedAt: string; + /** + * Beginning date of the invoice. + */ + from: string; + /** + * End date of the invoice. + */ + to: string; + /** + * Total amount of the invoice. + */ + amount: number; + additionalMembers: number; + + /** + * Price for additional members + */ + additionalMemberAmount: number; + /** + * Total storage usage. + */ + usageStorage: number; + /** + * Total active users for the billing period. + */ + usageUsers: number; + /** + * Total number of executions for the billing period. + */ + usageExecutions: number; + /** + * Total bandwidth usage for the billing period. + */ + usageBandwidth: number; + /** + * Total realtime usage for the billing period. + */ + usageRealtime: number; + /** + * Usage logs for the billing period. + */ + resources: InvoiceUsage[]; + /** + * Aggregation billing plan + */ + plan: string; + breakdown: AggregationBreakdown[]; +}; + +export type AggregationBreakdown = { + $id: string; + name: string; + amount: number; + region: string; + resources: InvoiceUsage[]; +}; + +export type InvoiceUsage = { + resourceId: string; + value: number; + amount: number; +}; + export type AvailableCredit = { available: number; }; @@ -316,10 +389,13 @@ export type Plan = { projects: number; databases: number; databasesAllowEncrypt: boolean; + databasesReads: number; + databasesWrites: number; buckets: number; fileSize: number; functions: number; executions: number; + GBHours: number; realtime: number; logs: number; authPhone: number; @@ -330,9 +406,14 @@ export type Plan = { realtime: AdditionalResource; storage: AdditionalResource; users: AdditionalResource; + databasesReads: AdditionalResource; + databasesWrites: AdditionalResource; + GBHours: AdditionalResource; + imageTransformations: AdditionalResource; }; addons: { seats: PlanAddon; + projects: PlanAddon; }; trialDays: number; budgetCapEnabled: boolean; @@ -348,6 +429,7 @@ export type Plan = { supportsOrganizationRoles: boolean; buildSize: number; // in MB deploymentSize: number; // in MB + usagePerProject: boolean; }; export type PlanList = { @@ -355,7 +437,7 @@ export type PlanList = { total: number; }; -export type PlansMap = Map; +export type PlansMap = Map; export type Roles = { scopes: string[]; @@ -492,6 +574,22 @@ export class Billing { }); } + async listPlans(queries: string[] = []): Promise { + const path = `/console/plans`; + const uri = new URL(this.client.config.endpoint + path); + const params = { + queries + }; + return await this.client.call( + 'get', + uri, + { + 'content-type': 'application/json' + }, + params + ); + } + async getPlan(planId: string): Promise { const path = `/console/plans/${planId}`; const uri = new URL(this.client.config.endpoint + path); @@ -835,7 +933,7 @@ export class Billing { ); } - async getAggregation(organizationId: string, aggregationId: string): Promise { + async getAggregation(organizationId: string, aggregationId: string): Promise { const path = `/organizations/${organizationId}/aggregations/${aggregationId}`; const params = { organizationId, diff --git a/src/lib/stores/billing.ts b/src/lib/stores/billing.ts index 993e01fbe7..2a88fa83af 100644 --- a/src/lib/stores/billing.ts +++ b/src/lib/stores/billing.ts @@ -14,7 +14,7 @@ import { cachedStore } from '$lib/helpers/cache'; import { type Size, sizeToBytes } from '$lib/helpers/sizeConvertion'; import type { AddressesList, - Aggregation, + AggregationTeam, Invoice, InvoiceList, PaymentList, @@ -69,7 +69,6 @@ export const roles = [ export const teamStatusReadonly = 'readonly'; export const billingLimitOutstandingInvoice = 'outstanding_invoice'; -export const billingProjectsLimitDate = '2025-09-01'; export const paymentMethods = derived(page, ($page) => $page.data.paymentMethods as PaymentList); export const addressList = derived(page, ($page) => $page.data.addressList as AddressesList); @@ -162,8 +161,13 @@ export function getServiceLimit(serviceId: PlanServices, tier: Tier = null, plan // the correct info for members/seats, resides in `addons`. // plan > addons > seats/others if (serviceId === 'members') { - // some don't include `limit`, so we fallback! - return plan?.['addons']['seats']['limit'] ?? 1; + // pro and scale plans have unlimited seats (per-project NEW pricing model) + const currentTier = tier ?? get(organization)?.billingPlan; + if (currentTier === BillingPlan.PRO || currentTier === BillingPlan.SCALE) { + return Infinity; // unlimited seats for Pro and Scale plans + } + // Free plan still has 1 member limit + return (plan?.['addons']['seats'] || [])['limit'] ?? 1; } return plan?.[serviceId] ?? 0; @@ -235,6 +239,7 @@ export const tierEnterprise: TierData = { }; export const showUsageRatesModal = writable(false); +export const useNewPricingModal = derived(currentPlan, ($plan) => $plan?.usagePerProject === true); export function checkForUsageFees(plan: Tier, id: PlanServices) { if (plan === BillingPlan.PRO || plan === BillingPlan.SCALE) { @@ -253,11 +258,19 @@ export function checkForUsageFees(plan: Tier, id: PlanServices) { } export function checkForProjectLimitation(id: PlanServices) { + // Members are no longer limited on Pro and Scale plans (unlimited seats) + if (id === 'members') { + const currentTier = get(organization)?.billingPlan; + if (currentTier === BillingPlan.PRO || currentTier === BillingPlan.SCALE) { + return false; // No project limitation for members on Pro/Scale plans + } + } + switch (id) { case 'databases': case 'functions': case 'buckets': - case 'members': + case 'members': // Only applies to Free plan now case 'platforms': case 'webhooks': case 'teams': @@ -325,7 +338,8 @@ export async function checkForProjectsLimit(org: Organization, orgProjectCount?: if (!plan) return; if (plan.$id !== BillingPlan.FREE) return; - if (org.projects?.length > 0) return; + if (!org.projects) return; + if (org.projects.length > 0) return; const projectCount = orgProjectCount; if (projectCount === undefined) return; @@ -383,13 +397,8 @@ export async function checkForUsageLimit(org: Organization) { ]; const members = org.total; - const plan = get(currentPlan); - const membersOverflow = - // `plan` can be null on `onboarding/create-organization` route. - // nested null checks needed: GitHub Education plan have empty addons. - members > plan?.addons?.seats?.limit - ? members - (plan?.addons?.seats?.limit || members) - : 0; + const memberLimit = getServiceLimit('members'); + const membersOverflow = memberLimit === Infinity ? 0 : Math.max(0, members - memberLimit); if (resources.some((r) => r.value >= 100) || membersOverflow > 0) { readOnly.set(true); @@ -609,7 +618,7 @@ export const billingURL = derived( export const hideBillingHeaderRoutes = ['/console/create-organization', '/console/account']; -export function calculateExcess(addon: Aggregation, plan: Plan) { +export function calculateExcess(addon: AggregationTeam, plan: Plan) { return { bandwidth: calculateResourceSurplus(addon.usageBandwidth, plan.bandwidth), storage: calculateResourceSurplus(addon.usageStorage, plan.storage, 'GB'), diff --git a/src/routes/(console)/+layout.ts b/src/routes/(console)/+layout.ts index 5aa188f90d..3cf1478a3a 100644 --- a/src/routes/(console)/+layout.ts +++ b/src/routes/(console)/+layout.ts @@ -1,7 +1,7 @@ +import { Dependencies } from '$lib/constants'; import { sdk } from '$lib/stores/sdk'; import { isCloud } from '$lib/system'; import type { LayoutLoad } from './$types'; -import { Dependencies } from '$lib/constants'; import type { Tier } from '$lib/stores/billing'; import type { Plan, PlanList } from '$lib/sdk/billing'; import { Query } from '@appwrite.io/console'; diff --git a/src/routes/(console)/create-organization/+page.ts b/src/routes/(console)/create-organization/+page.ts index 690baa2bbe..57d6280fe4 100644 --- a/src/routes/(console)/create-organization/+page.ts +++ b/src/routes/(console)/create-organization/+page.ts @@ -7,10 +7,10 @@ import type { Organization } from '$lib/stores/organization'; export const load: PageLoad = async ({ url, parent, depends }) => { const { organizations } = await parent(); depends(Dependencies.ORGANIZATIONS); - - const [coupon, paymentMethods] = await Promise.all([ + const [coupon, paymentMethods, plans] = await Promise.all([ getCoupon(url), - sdk.forConsole.billing.listPaymentMethods() + sdk.forConsole.billing.listPaymentMethods(), + sdk.forConsole.billing.listPlans() ]); let plan = getPlanFromUrl(url); const hasFreeOrganizations = organizations.teams?.some( @@ -24,6 +24,7 @@ export const load: PageLoad = async ({ url, parent, depends }) => { return { plan, coupon, + plans, hasFreeOrganizations, paymentMethods, name: url.searchParams.get('name') ?? '' diff --git a/src/routes/(console)/organization-[organization]/+page.svelte b/src/routes/(console)/organization-[organization]/+page.svelte index 10169fe819..2339498ec3 100644 --- a/src/routes/(console)/organization-[organization]/+page.svelte +++ b/src/routes/(console)/organization-[organization]/+page.svelte @@ -7,6 +7,7 @@ import { GRACE_PERIOD_OVERRIDE, isCloud } from '$lib/system'; import { page } from '$app/state'; import { registerCommands } from '$lib/commandCenter'; + import { formatName as formatNameHelper } from '$lib/helpers/string'; import { CardContainer, Empty, @@ -17,7 +18,7 @@ } from '$lib/components'; import { trackEvent, Click } from '$lib/actions/analytics'; import { type Models } from '@appwrite.io/console'; - import { billingProjectsLimitDate, readOnly, upgradeURL } from '$lib/stores/billing'; + import { readOnly, upgradeURL } from '$lib/stores/billing'; import { onMount, type ComponentType } from 'svelte'; import { canWriteProjects } from '$lib/stores/roles'; import { checkPricingRefAndRedirect } from '$lib/helpers/pricingRedirect'; @@ -34,8 +35,9 @@ } from '@appwrite.io/pink-icons-svelte'; import { getPlatformInfo } from '$lib/helpers/platform'; import CreateProjectCloud from './createProjectCloud.svelte'; - import { currentPlan, regions as regionsStore } from '$lib/stores/organization'; + import { currentPlan, organization, regions as regionsStore } from '$lib/stores/organization'; import SelectProjectCloud from '$lib/components/billing/alerts/selectProjectCloud.svelte'; + import ArchiveProject from '$lib/components/archiveProject.svelte'; import { toLocaleDate } from '$lib/helpers/date'; export let data; @@ -99,24 +101,16 @@ function isSetToArchive(project: Models.Project): boolean { if (!isCloud) return false; - if (data.organization.projects?.length === 0) return false; if (!project || !project.$id) return false; - return !data.organization.projects?.includes(project.$id); + return project.status !== 'active'; } - function formatName(name: string, limit: number = 19) { - const mobileLimit = 16; - const actualLimit = $isSmallViewport ? mobileLimit : limit; - return name ? (name.length > actualLimit ? `${name.slice(0, actualLimit)}...` : name) : '-'; - } + $: projectsToArchive = data.projects.projects.filter((project) => project.status !== 'active'); + $: activeProjects = data.projects.projects.filter((project) => project.status === 'active'); function clearSearch() { searchQuery?.clearInput(); } - - $: projectsToArchive = data.projects.projects.filter( - (project) => !data.organization.projects?.includes(project.$id) - ); - {#if isCloud && $currentPlan?.projects && $currentPlan?.projects > 0 && data.organization.projects.length > 0 && data.projects.total > $currentPlan.projects && $canWriteProjects} + {#if isCloud && $currentPlan?.projects && $currentPlan?.projects > 0 && data.organization.projects.length > 0 && $canWriteProjects && (projectsToArchive.length > 0 || data.projects.total > $currentPlan.projects)} + {@const difference = projectsToArchive} + {@const messagePrefix = + difference.length > 1 ? `${difference} projects` : `${difference} project`} - - {#each projectsToArchive as project, index}{@const text = `${project.name}`} - {@html text}{index === projectsToArchive.length - 2 - ? ', and ' - : index < projectsToArchive.length - 1 - ? ', ' - : ''} - {/each} - will be archived - + title={`${messagePrefix} will be archived on ${toLocaleDate($organization.billingNextInvoiceDate)}`}> + Upgrade your plan to restore archived projects - @@ -176,7 +166,7 @@ {/if} {:else} - Add credits + (show = true)}>Add credits {/if} {/if} diff --git a/src/routes/(console)/organization-[organization]/billing/cancelDowngradeModal.svelte b/src/routes/(console)/organization-[organization]/billing/cancelDowngradeModal.svelte index 44c9c97102..ac649dd837 100644 --- a/src/routes/(console)/organization-[organization]/billing/cancelDowngradeModal.svelte +++ b/src/routes/(console)/organization-[organization]/billing/cancelDowngradeModal.svelte @@ -28,7 +28,12 @@ } - +

    Your organization is set to change to {tierToPlan($organization?.billingPlanDowngrade).name} diff --git a/src/routes/(console)/organization-[organization]/billing/planSummary.svelte b/src/routes/(console)/organization-[organization]/billing/planSummary.svelte index 3f27119c9a..51c39a931b 100644 --- a/src/routes/(console)/organization-[organization]/billing/planSummary.svelte +++ b/src/routes/(console)/organization-[organization]/billing/planSummary.svelte @@ -1,180 +1,533 @@ {#if $organization} - - Payment estimates - A breakdown of your estimated upcoming payment for the current billing period. Totals displayed - exclude accumulated credits and applicable taxes. - -

    - Due at: {toLocaleDate($organization?.billingNextInvoiceDate)} -

    - - - - - {currentPlan.name} plan - - - {isTrial || $organization?.billingPlan === BillingPlan.GITHUB_EDUCATION - ? formatCurrency(0) - : currentPlan - ? formatCurrency(currentPlan?.price) - : ''} - - - - {#if currentPlan.budgeting && extraUsage > 0} - 0 - ? currentInvoice.usage.length + 1 - : currentInvoice.usage.length - ).toString()}> - - {formatCurrency(extraUsage >= 0 ? extraUsage : 0)} - - - {#if currentAggregation.additionalMembers} - - - Additional members - - {formatCurrency( - currentAggregation.additionalMemberAmount - )} - - - - {currentAggregation.additionalMembers} - - - {/if} - {#if currentInvoice?.usage} - {#each currentInvoice.usage as excess, i} - {#if i > 0 || currentAggregation.additionalMembers} - - {/if} - - - - - {excess.name} - - - {formatCurrency(excess.amount)} - - - - - - {formatNumberWithCommas(excess.value)} - - {abbreviateNumber(excess.value)} - - - - {/each} + + {currentPlan.name} plan + + {#if totalAmount > 0} + + Next payment of {formatCurrency(totalAmount)} + will occur on + {toLocaleDate($organization?.billingNextInvoiceDate)}. + + {/if} + +
    + + Current billing cycle ({new Date( + $organization?.billingCurrentInvoiceDate + ).toLocaleDateString('en', { day: 'numeric', month: 'short' })}-{new Date( + $organization?.billingNextInvoiceDate + ).toLocaleDateString('en', { day: 'numeric', month: 'short' })}) + + + Estimate, subject to change based on usage. + +
    + +
    + + {#each billingData as row} + + {#each columns as col} + root.toggle(row.id)}> + {#if col.id === 'item'} +
    + + {row.cells?.[col.id] ?? ''} + +
    + {:else} + + {row.cells?.[col.id] ?? ''} + {/if} - - - {/if} +
    + {/each} + + + {#if row.children} + {#each row.children as child (child.id)} + + {/each} +
    + {/each} + {/if} +
    + + {/each} + {#if availableCredit > 0} + + {}}> + + - {#if currentPlan.supportsCredits && availableCredit > 0} - - - Credits to be applied + >Credits - - -{formatCurrency( - Math.min(availableCredit, currentInvoice?.amount ?? 0) - )} - - - {/if} - - {#if $organization?.billingPlan !== BillingPlan.FREE && $organization?.billingPlan !== BillingPlan.GITHUB_EDUCATION} - - - - - Current total (USD) - - - - Estimates are updated daily and may differ from your - final invoice. - - - + + {}}> + - - {formatCurrency( - Math.max( - (currentInvoice?.amount ?? 0) - - Math.min(availableCredit, currentInvoice?.amount ?? 0), - 0 - ) - )} + + {}}> + + -{formatCurrency(creditsApplied)} - - {/if} - - - - + + + {/if} + + + {}}> + + Total + + + {}}> + + + + {}}> + + {formatCurrency(totalAmount)} + + + + + + + +
    {#if $organization?.billingPlan === BillingPlan.FREE || $organization?.billingPlan === BillingPlan.GITHUB_EDUCATION}
    - + class="u-flex u-cross-center u-gap-8 u-flex-wrap u-width-full-line u-main-end actions-mobile"> + {#if !currentPlan?.usagePerProject} + + {/if} {:else} @@ -204,20 +557,24 @@ Change plan {/if} - + {#if !currentPlan?.usagePerProject} + + {/if}
    {/if} - - +
    + {/if} diff --git a/src/routes/(console)/organization-[organization]/budgetLimitAlert.svelte b/src/routes/(console)/organization-[organization]/budgetLimitAlert.svelte index ca84dbf213..338be255e2 100644 --- a/src/routes/(console)/organization-[organization]/budgetLimitAlert.svelte +++ b/src/routes/(console)/organization-[organization]/budgetLimitAlert.svelte @@ -17,9 +17,14 @@ Appwrite services, update the budget limit. - + {#if !page.data.currentPlan?.usagePerProject} + + {/if} diff --git a/src/routes/(console)/organization-[organization]/change-plan/+page.svelte b/src/routes/(console)/organization-[organization]/change-plan/+page.svelte index b5ef36e437..934ddf3b5e 100644 --- a/src/routes/(console)/organization-[organization]/change-plan/+page.svelte +++ b/src/routes/(console)/organization-[organization]/change-plan/+page.svelte @@ -4,7 +4,6 @@ import { page } from '$app/state'; import { Submit, trackError, trackEvent } from '$lib/actions/analytics'; import { PlanComparisonBox, PlanSelection, SelectPaymentMethod } from '$lib/components/billing'; - import PlanExcess from '$lib/components/billing/planExcess.svelte'; import ValidateCreditModal from '$lib/components/billing/validateCreditModal.svelte'; import { BillingPlan, Dependencies, feedbackDowngradeOptions } from '$lib/constants'; import { Button, Form, InputSelect, InputTags, InputTextarea } from '$lib/elements/forms'; @@ -33,7 +32,11 @@ import { onMount } from 'svelte'; import { loadAvailableRegions } from '$routes/(console)/regions'; import EstimatedTotalBox from '$lib/components/billing/estimatedTotalBox.svelte'; + import OrganizationUsageLimits from '$lib/components/organizationUsageLimits.svelte'; import { Query } from '@appwrite.io/console'; + import type { OrganizationUsage } from '$lib/sdk/billing'; + import type { Models } from '@appwrite.io/console'; + import { toLocaleDate } from '$lib/helpers/date'; export let data; @@ -43,6 +46,9 @@ let previousPage: string = base; let showExitModal = false; let formComponent: Form; + let usageLimitsComponent: + | { validateOrAlert: () => boolean; getSelectedProjects: () => string[] } + | undefined; let isSubmitting = writable(false); let collaborators: string[] = data?.members?.memberships @@ -55,6 +61,8 @@ let showCreditModal = false; let feedbackDowngradeReason: string; let feedbackMessage: string; + let orgUsage: OrganizationUsage; + let allProjects: { projects: Models.Project[] } | undefined; $: paymentMethods = null; @@ -91,6 +99,21 @@ selectedPlan = $currentPlan?.$id === BillingPlan.SCALE ? BillingPlan.SCALE : BillingPlan.PRO; + + try { + orgUsage = await sdk.forConsole.billing.listUsage(data.organization.$id); + } catch { + orgUsage = undefined; + } + + try { + allProjects = await sdk.forConsole.projects.list([ + Query.equal('teamId', data.organization.$id), + Query.limit(1000) + ]); + } catch { + allProjects = { projects: [] }; + } }); async function loadPaymentMethods() { @@ -100,6 +123,13 @@ async function handleSubmit() { if (isDowngrade) { + // If target plan has a non-zero project limit, ensure selection made + const targetProjectsLimit = $plansInfo?.get(selectedPlan)?.projects ?? 0; + if (targetProjectsLimit > 0 && usageLimitsComponent?.validateOrAlert) { + const ok = usageLimitsComponent.validateOrAlert(); + if (!ok) return; + } + await downgrade(); } else if (isUpgrade) { await upgrade(); @@ -136,6 +166,7 @@ async function downgrade() { try { + // 1) update the plan first await sdk.forConsole.billing.updatePlan( data.organization.$id, selectedPlan, @@ -143,6 +174,22 @@ null ); + // 2) If the target plan has a project limit, apply selected projects now + const targetProjectsLimit = $plansInfo?.get(selectedPlan)?.projects ?? 0; + if (targetProjectsLimit > 0 && usageLimitsComponent) { + const selected = usageLimitsComponent.getSelectedProjects(); + if (selected?.length) { + try { + await sdk.forConsole.billing.updateSelectedProjects( + data.organization.$id, + selected + ); + } catch (projectError) { + console.warn('Project selection failed after plan update:', projectError); + } + } + } + await Promise.all([trackDowngradeFeedback(), invalidate(Dependencies.ORGANIZATION)]); await goto(previousPage); @@ -308,14 +355,12 @@ {/if} {#if isDowngrade} - {#if selectedPlan === BillingPlan.FREE && !data.hasFreeOrgs} - - {:else if selectedPlan === BillingPlan.PRO && data.organization.billingPlan === BillingPlan.SCALE && collaborators?.length > 0} - {@const extraMembers = collaborators?.length ?? 0} - {@const price = formatCurrency( - extraMembers * - ($plansInfo?.get(selectedPlan)?.addons?.seats?.price ?? 0) - )} + {@const extraMembers = collaborators?.length ?? 0} + {@const price = formatCurrency( + extraMembers * + ($plansInfo?.get(selectedPlan)?.addons?.seats?.price ?? 0) + )} + {#if selectedPlan === BillingPlan.PRO} Your monthly payments will be adjusted for the Pro plan @@ -325,7 +370,26 @@ >you will be charged {price} monthly for {extraMembers} team members. This will be reflected in your next invoice. + {:else if selectedPlan === BillingPlan.FREE} + + You will retain access to {tierToPlan($organization.billingPlan) + .name} plan features until your billing period ends. After that, + all team members except the owner will be removed, + and service disruptions may occur if usage exceeds Free plan limits. + {/if} + + {/if} diff --git a/src/routes/(console)/organization-[organization]/change-plan/+page.ts b/src/routes/(console)/organization-[organization]/change-plan/+page.ts index ecb712008f..c6a137f96a 100644 --- a/src/routes/(console)/organization-[organization]/change-plan/+page.ts +++ b/src/routes/(console)/organization-[organization]/change-plan/+page.ts @@ -1,11 +1,20 @@ import type { PageLoad } from './$types'; import type { Organization } from '$lib/stores/organization'; import { BillingPlan, Dependencies } from '$lib/constants'; +import { sdk } from '$lib/stores/sdk'; export const load: PageLoad = async ({ depends, parent }) => { const { members, currentPlan, organizations } = await parent(); depends(Dependencies.UPGRADE_PLAN); + let plans; + try { + plans = await sdk.forConsole.billing.listPlans(); + } catch (error) { + console.error('Failed to load billing plans:', error); + plans = { plans: {} }; + } + let plan: BillingPlan; if (currentPlan?.$id === BillingPlan.SCALE) { @@ -22,6 +31,7 @@ export const load: PageLoad = async ({ depends, parent }) => { return { members, plan, + plans, selfService, hasFreeOrgs }; diff --git a/src/routes/(console)/organization-[organization]/header.svelte b/src/routes/(console)/organization-[organization]/header.svelte index f114dcc22f..1a81ae48cd 100644 --- a/src/routes/(console)/organization-[organization]/header.svelte +++ b/src/routes/(console)/organization-[organization]/header.svelte @@ -66,7 +66,11 @@ event: 'usage', title: 'Usage', hasChildren: true, - disabled: !(isCloud && ($isOwner || $isBilling)) + disabled: !( + isCloud && + ($isOwner || $isBilling) && + !page.data.currentPlan?.usagePerProject + ) }, { href: `${path}/billing`, diff --git a/src/routes/(console)/organization-[organization]/members/+page.svelte b/src/routes/(console)/organization-[organization]/members/+page.svelte index 4a9f16b99c..05c3500802 100644 --- a/src/routes/(console)/organization-[organization]/members/+page.svelte +++ b/src/routes/(console)/organization-[organization]/members/+page.svelte @@ -47,9 +47,7 @@ // Calculate if button should be disabled and tooltip should show $: memberCount = data.organizationMembers?.total ?? 0; $: isFreeWithMembers = $organization?.billingPlan === BillingPlan.FREE && memberCount >= 1; - $: isButtonDisabled = isCloud - ? isFreeWithMembers || !$currentPlan?.addons?.seats?.supported - : false; + $: isButtonDisabled = isCloud ? isFreeWithMembers : false; const resend = async (member: Models.Membership) => { try { diff --git a/src/routes/(console)/organization-[organization]/usage/[[invoice]]/+page.svelte b/src/routes/(console)/organization-[organization]/usage/[[invoice]]/+page.svelte index e273536246..a9422df60b 100644 --- a/src/routes/(console)/organization-[organization]/usage/[[invoice]]/+page.svelte +++ b/src/routes/(console)/organization-[organization]/usage/[[invoice]]/+page.svelte @@ -8,7 +8,8 @@ getServiceLimit, showUsageRatesModal, type Tier, - upgradeURL + upgradeURL, + useNewPricingModal } from '$lib/stores/billing'; import { organization } from '$lib/stores/organization'; import ProjectBreakdown from './ProjectBreakdown.svelte'; @@ -76,14 +77,32 @@ {#if $organization.billingPlan === BillingPlan.SCALE}

    On the Scale plan, you'll be charged only for any usage that exceeds the thresholds per - resource listed below. ($showUsageRatesModal = true)} - >Learn more + resource listed below. + {#if $useNewPricingModal} + ($showUsageRatesModal = true)}>Learn more + {:else} + + Learn more + + {/if}

    {:else if $organization.billingPlan === BillingPlan.PRO}

    On the Pro plan, you'll be charged only for any usage that exceeds the thresholds per - resource listed below. ($showUsageRatesModal = true)} - >Learn more + resource listed below. + {#if $useNewPricingModal} + ($showUsageRatesModal = true)}>Learn more + {:else} + + Learn more + + {/if}

    {:else if $organization.billingPlan === BillingPlan.FREE}

    diff --git a/src/routes/(console)/project-[region]-[project]/settings/usage/[[invoice]]/+page.svelte b/src/routes/(console)/project-[region]-[project]/settings/usage/[[invoice]]/+page.svelte index 24b82ae7b8..31fc890179 100644 --- a/src/routes/(console)/project-[region]-[project]/settings/usage/[[invoice]]/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/settings/usage/[[invoice]]/+page.svelte @@ -243,7 +243,7 @@ Executions - Calculated for all functions that are executed in all projects in your project. + Calculated for all functions that are executed in this project. {#if executions} {@const current = formatNum(executionsTotal)} diff --git a/src/routes/(console)/project-[region]-[project]/settings/usage/[[invoice]]/+page.ts b/src/routes/(console)/project-[region]-[project]/settings/usage/[[invoice]]/+page.ts index 4afa9181ad..e5e1ee5a6c 100644 --- a/src/routes/(console)/project-[region]-[project]/settings/usage/[[invoice]]/+page.ts +++ b/src/routes/(console)/project-[region]-[project]/settings/usage/[[invoice]]/+page.ts @@ -1,4 +1,4 @@ -import type { Aggregation, Invoice } from '$lib/sdk/billing'; +import type { AggregationTeam, Invoice, InvoiceUsage } from '$lib/sdk/billing'; import { accumulateUsage } from '$lib/sdk/usage'; import { sdk } from '$lib/stores/sdk'; import { Query } from '@appwrite.io/console'; @@ -11,7 +11,7 @@ export const load: PageLoad = async ({ params, parent }) => { let startDate: string = organization.billingCurrentInvoiceDate; let endDate: string = organization.billingNextInvoiceDate; let currentInvoice: Invoice = undefined; - let currentAggregation: Aggregation = undefined; + let currentAggregation: AggregationTeam = undefined; if (invoice) { currentInvoice = await sdk.forConsole.billing.getInvoice(organization.$id, invoice); @@ -22,6 +22,15 @@ export const load: PageLoad = async ({ params, parent }) => { startDate = currentInvoice.from; endDate = currentInvoice.to; + } else { + try { + currentAggregation = await sdk.forConsole.billing.getAggregation( + organization.$id, + organization.billingAggregationId + ); + } catch (e) { + // ignore error if no aggregation found + } } const [invoices, usage] = await Promise.all([ @@ -29,10 +38,24 @@ export const load: PageLoad = async ({ params, parent }) => { sdk.forProject(region, project).project.getUsage({ startDate, endDate }) ]); - if (invoice) { - usage.usersTotal = currentAggregation.usageUsers; - usage.executionsTotal = currentAggregation.usageExecutions; - usage.filesStorageTotal = currentAggregation.usageStorage; + if (currentAggregation) { + let projectSpecificData = null; + if (currentAggregation.breakdown) { + projectSpecificData = currentAggregation.breakdown.find((p) => p.$id === project); + } + + if (projectSpecificData) { + const executionsResource = projectSpecificData.resources?.find?.( + (r: InvoiceUsage) => r.resourceId === 'executions' + ); + if (executionsResource) { + usage.executionsTotal = executionsResource.value || usage.executionsTotal; + } + } else { + usage.usersTotal = currentAggregation.usageUsers; + usage.executionsTotal = currentAggregation.usageExecutions; + usage.filesStorageTotal = currentAggregation.usageStorage; + } } usage.users = accumulateUsage(usage.users, usage.usersTotal);