diff --git a/src/lib/components/billing/estimatedTotalBox.svelte b/src/lib/components/billing/estimatedTotalBox.svelte index ba4d01acb7..57f4b2f4c2 100644 --- a/src/lib/components/billing/estimatedTotalBox.svelte +++ b/src/lib/components/billing/estimatedTotalBox.svelte @@ -7,6 +7,8 @@ import { AppwriteException, type Models } from '@appwrite.io/console'; import DiscountsApplied from './discountsApplied.svelte'; + type PlanChangeEstimate = Models.EstimationUpdatePlan & Partial; + export let billingPlan: Models.BillingPlan; export let collaborators: string[]; export let couponData: Partial; @@ -14,9 +16,31 @@ export let fixedCoupon = false; // If true, the coupon cannot be removed export let isDowngrade = false; export let organizationId: string | undefined = undefined; + export let estimationOverride: PlanChangeEstimate | null = null; + export let preferExternalEstimate = false; let budgetEnabled = false; - let estimation: Models.Estimation; + let estimation: Models.Estimation | PlanChangeEstimate; + + function getEstimateDetails(value: Models.Estimation | PlanChangeEstimate) { + if (!value) return null; + + return 'estimation' in value && value.estimation ? value.estimation : value; + } + + function normalizeItems(items: unknown): Models.EstimationItem[] { + if (!items) return []; + + if (Array.isArray(items)) { + return items as Models.EstimationItem[]; + } + + if (typeof items === 'object') { + return Object.values(items as Record); + } + + return []; + } async function getEstimate( billingPlan: string, @@ -78,17 +102,24 @@ } } - $: organizationId - ? getUpdatePlanEstimate(organizationId, billingPlan.$id, collaborators, couponData?.code) - : getEstimate(billingPlan.$id, collaborators, couponData?.code); + $: if (estimationOverride) { + estimation = estimationOverride; + } else if (preferExternalEstimate) { + estimation = undefined; + } else if (organizationId) { + getUpdatePlanEstimate(organizationId, billingPlan.$id, collaborators, couponData?.code); + } else { + getEstimate(billingPlan.$id, collaborators, couponData?.code); + } {#if estimation} + {@const estimationDetails = getEstimateDetails(estimation)} - {#each estimation.items ?? [] as item} + {#each normalizeItems(estimationDetails?.items) as item} {item.label} @@ -96,7 +127,7 @@ >{formatCurrency(item.value)} {/each} - {#each estimation.discounts ?? [] as item} + {#each normalizeItems(estimationDetails?.discounts) as item} {/each} @@ -108,15 +139,15 @@ Total due - {formatCurrency(estimation.grossAmount)} + {formatCurrency(estimationDetails?.grossAmount ?? 0)} - You'll pay {formatCurrency(estimation.grossAmount)} + You'll pay {formatCurrency(estimationDetails?.grossAmount ?? 0)} now. {#if couponData?.code}Once your credits run out,{:else}Then{/if} you'll be charged - {formatCurrency(estimation.amount)} every 30 days. + {formatCurrency(estimationDetails?.amount ?? 0)} every 30 days. ) + | null; + estimateError?: string | null; + loading?: boolean; } = $props(); let showSelectProject = $state(false); @@ -34,6 +44,38 @@ let isDeletingProjects = $state(false); let selectedProjectsToDelete = $state>([]); const baseFreePlan = getBasePlanFromGroup(BillingPlanGroup.Starter); + const targetPlanLimits = $derived(planChangeEstimate?.limits ?? null); + const unsupportedAddons = $derived(targetPlanLimits?.unsupportedAddons ?? []); + const nonCompliantProjects = $derived( + targetPlanLimits?.projects?.filter((project) => !project.isCompliant) ?? [] + ); + const projectComplianceRows = $derived.by(() => { + return nonCompliantProjects.flatMap((project) => { + if (project.error) { + return [ + { + id: `${project.$id}-error`, + project: project.name, + resource: 'Project evaluation', + currentUsage: 'Unavailable', + limit: 'Unavailable', + action: project.error + } + ]; + } + + return (project.resources ?? []) + .filter((resource) => resource.status !== 'compliant' || resource.excess > 0) + .map((resource) => ({ + id: `${project.$id}-${resource.type}`, + project: project.name, + resource: formatResourceType(resource.type), + currentUsage: formatNumber(resource.currentUsage), + limit: formatNumber(resource.limit), + action: resource.resolutionHint + })); + }); + }); // Derived state using runes const freePlanLimits = $derived({ @@ -42,7 +84,8 @@ storage: getServiceLimit('storage', null, baseFreePlan) }); - // When preparing to downgrade to Free, enforce Free plan limit locally (2) + // When preparing to downgrade to Free, enforce Free plan limit locally. + const isDowngradingToFree = $derived(targetPlan?.group === BillingPlanGroup.Starter); const allowedProjectsToKeep = $derived(freePlanLimits.projects); const currentUsage = $derived({ @@ -54,13 +97,13 @@ const storageUsageGB = $derived(storageUsage / (1024 * 1024 * 1024)); const isLimitExceeded = $derived({ - projects: currentUsage.projects > freePlanLimits.projects, - members: currentUsage.members > freePlanLimits.members, - storage: storageUsageGB > freePlanLimits.storage + projects: isDowngradingToFree && currentUsage.projects > freePlanLimits.projects, + members: isDowngradingToFree && currentUsage.members > freePlanLimits.members, + storage: isDowngradingToFree && storageUsageGB > freePlanLimits.storage }); const excessUsage = $derived({ - projects: Math.max(0, currentUsage.projects), + projects: Math.max(0, currentUsage.projects - freePlanLimits.projects), members: Math.max(0, currentUsage.members - freePlanLimits.members), storage: Math.max(0, storageUsageGB - freePlanLimits.storage) }); @@ -71,6 +114,15 @@ return formatNumberWithCommas(num); } + function formatResourceType(type: string): string { + return type + .replace(/([A-Z])/g, ' $1') + .replace(/[_-]/g, ' ') + .replace(/\s+/g, ' ') + .trim() + .replace(/\b\w/g, (char) => char.toUpperCase()); + } + function handleManageProjects() { showSelectProject = true; showSelectionReminder = false; @@ -86,6 +138,7 @@ if (!isUnderLimitPostSelection) { error = `You can keep a maximum ${allowedProjectsToKeep} projects on the selected plan.`; + isDeletingProjects = false; return; } @@ -147,6 +200,81 @@ + {#if targetPlanLimits} + {#if unsupportedAddons.length > 0} + + Remove these add-ons before switching to {targetPlan?.name}: {unsupportedAddons.join( + ', ' + )}. + + {/if} + + {#if targetPlanLimits.canChangePlan} + + {targetPlanLimits.totalProjects} project{targetPlanLimits.totalProjects === 1 + ? '' + : 's'} comply with the {targetPlan?.name} plan limits. + + {:else if targetPlanLimits.nonCompliantProjects > 0} + + {targetPlanLimits.nonCompliantProjects} project{targetPlanLimits.nonCompliantProjects === + 1 + ? '' + : 's'} need attention before you can switch to the {targetPlan?.name} plan. + + {/if} + {:else if loading} + + Reviewing your projects against the {targetPlan?.name} plan limits. + + {:else if estimateError} + + {estimateError} + + {/if} + + {#if projectComplianceRows.length > 0} +
+ + + Project + Resource + Current + Limit + Required action + + + {#each projectComplianceRows as row} + + + {row.project} + + + {row.resource} + + + {row.currentUsage} + + + {row.limit} + + + {row.action} + + + {/each} + +
+ {/if} + {#if showSelectionReminder} The Free plan lets you keep {allowedProjectsToKeep} projects. Select them before continuing. @@ -160,126 +288,130 @@ {/if} -
- - - Resource - Free limit - - - Excess usage - - - Usage beyond the Free plan limits. - - - - - - - - - - - Projects - {#if isLimitExceeded.projects} - - {/if} - - - - {formatNumber(allowedProjectsToKeep)} projects - - - {#if isLimitExceeded.projects} + {#if isDowngradingToFree} +
+ + + Resource + Free limit + - - - {formatNumber(excessUsage.projects)} projects - - - {:else} - - {formatNumber(currentUsage.projects)} / {formatNumber( - allowedProjectsToKeep - )} - - {/if} - - - {#if isLimitExceeded.projects} - - + Excess usage + + + Usage beyond the Free plan limits. + - {/if} - - - - - - - Organization members - - - {formatNumber(freePlanLimits.members)} member - - - {#if isLimitExceeded.members} + + + + + + + - - - {formatNumber(excessUsage.members)} members - + Projects + {#if isLimitExceeded.projects} + + {/if} - {:else} - N/A - {/if} - - - - - - - - Storage - - - {freePlanLimits.storage} GB - - - {#if isLimitExceeded.storage} - - - - {excessUsage.storage.toFixed(2)} GB + + + {formatNumber(allowedProjectsToKeep)} projects + + + {#if isLimitExceeded.projects} + + + + {formatNumber(excessUsage.projects)} projects + + + {:else} + + {formatNumber(currentUsage.projects)} / {formatNumber( + allowedProjectsToKeep + )} - - {:else} - - {storageUsageGB.toFixed(2)} / {freePlanLimits.storage} GB - - {/if} - - - - -
+ {/if} +
+ + {#if isLimitExceeded.projects} + + + + {/if} + +
+ + + + + Organization members + + + {formatNumber(freePlanLimits.members)} member + + + {#if isLimitExceeded.members} + + + + {formatNumber(excessUsage.members)} members + + + {:else} + N/A + {/if} + + + + + + + + Storage + + + {freePlanLimits.storage} GB + + + {#if isLimitExceeded.storage} + + + + {excessUsage.storage.toFixed(2)} GB + + + {:else} + + {storageUsageGB.toFixed(2)} / {freePlanLimits.storage} GB + + {/if} + + + +
+
+ {/if}
-{#if showSelectProject} +{#if showSelectProject && isDowngradingToFree} {@const requiredToDelete = currentUsage.projects - allowedProjectsToKeep} ; + export let data; let selectedPlan: Models.BillingPlan = data.plan; @@ -60,6 +62,10 @@ let feedbackMessage: string; let orgUsage: Models.UsageOrganization; let allProjects: { projects: Models.Project[] } = { projects: [] }; + let planChangeEstimate: PlanChangeEstimate | null = null; + let planEstimateError: string | null = null; + let isLoadingPlanEstimate = false; + let planEstimateRequestId = 0; $: paymentMethods = null; @@ -133,13 +139,90 @@ return paymentMethods; } - function hasExcessProjectsForFreePlan(): boolean { - const freeBasePlan = getBasePlanFromGroup(BillingPlanGroup.Starter); - const freePlanProjectLimit = freeBasePlan?.projects ?? 2; - const currentProjectCount = allProjects.projects.length; - return currentProjectCount > freePlanProjectLimit; + function getAdditionalInvites(): string[] { + return (collaborators ?? []).filter( + (collaborator) => + !data?.members?.memberships?.find((member) => member.userEmail === collaborator) + ); + } + + function getPlanChangeLimits(): Models.PlanChangeLimits | null { + return planChangeEstimate?.limits ?? null; + } + + function getPlanChangeBlockingMessage(): string | null { + const limits = getPlanChangeLimits(); + + if (isDowngrade && planEstimateError && !planChangeEstimate) { + return `We couldn't verify whether your organization meets the ${selectedPlan.name} plan limits. Please try again.`; + } + + if (!limits || limits.canChangePlan) { + return null; + } + + if (limits.unsupportedAddons?.length) { + return `Remove unsupported add-ons before switching to ${selectedPlan.name}.`; + } + + if (limits.nonCompliantProjects > 0) { + return `${limits.nonCompliantProjects} project${limits.nonCompliantProjects !== 1 ? 's' : ''} exceed the ${selectedPlan.name} plan limits. Resolve the issues below before continuing.`; + } + + return `This organization doesn't currently meet the ${selectedPlan.name} plan limits.`; } + async function loadPlanChangeEstimate() { + const requestId = ++planEstimateRequestId; + + if ( + !data?.organization?.$id || + !selectedPlan?.$id || + selectedPlan.$id === $organization?.billingPlanId + ) { + planChangeEstimate = null; + planEstimateError = null; + isLoadingPlanEstimate = false; + return; + } + + isLoadingPlanEstimate = true; + planEstimateError = null; + + try { + const estimation = await sdk.forConsole.organizations.estimationUpdatePlan({ + organizationId: data.organization.$id, + billingPlan: selectedPlan.$id, + invites: getAdditionalInvites(), + couponId: + selectedCoupon?.code && selectedCoupon.code.length > 0 + ? selectedCoupon.code + : null + }); + + if (requestId !== planEstimateRequestId) return; + + planChangeEstimate = estimation as PlanChangeEstimate; + } catch (e) { + if (requestId !== planEstimateRequestId) return; + + planChangeEstimate = null; + planEstimateError = e.message; + } finally { + if (requestId === planEstimateRequestId) { + isLoadingPlanEstimate = false; + } + } + } + + let additionalInviteEmails: string[] = []; + let additionalInviteSignature = ''; + let selectedCouponCode: string | null = null; + + $: additionalInviteEmails = getAdditionalInvites(); + $: additionalInviteSignature = additionalInviteEmails.join(','); + $: selectedCouponCode = selectedCoupon?.code ?? null; + async function handleSubmit() { if (isDowngrade) { await downgrade(); @@ -167,14 +250,11 @@ } async function downgrade() { - if (selectedPlan.group === BillingPlanGroup.Starter && hasExcessProjectsForFreePlan()) { - const freeBasePlan = getBasePlanFromGroup(BillingPlanGroup.Starter); - const freePlanProjectLimit = freeBasePlan?.projects ?? 2; - const currentProjectCount = allProjects?.projects?.length ?? 0; - + const blockingMessage = getPlanChangeBlockingMessage(); + if (blockingMessage) { addNotification({ type: 'error', - message: `Please delete ${currentProjectCount - freePlanProjectLimit} project${currentProjectCount - freePlanProjectLimit !== 1 ? 's' : ''} before downgrading` + message: blockingMessage }); return; } @@ -241,13 +321,7 @@ async function upgrade() { try { // Add collaborators - let newCollaborators = []; - if (collaborators?.length) { - newCollaborators = collaborators.filter( - (collaborator) => - !data?.members?.memberships?.find((m) => m.userEmail === collaborator) - ); - } + const newCollaborators = getAdditionalInvites(); const org = await sdk.forConsole.organizations.updatePlan({ organizationId: data.organization.$id, billingPlan: selectedPlan.$id, @@ -313,18 +387,28 @@ $: isUpgrade = selectedPlan.order > $currentPlan?.order; $: isDowngrade = selectedPlan.order < $currentPlan?.order; + $: if ( + data?.organization?.$id && + selectedPlan?.$id && + additionalInviteSignature !== undefined && + selectedCouponCode !== undefined + ) { + loadPlanChangeEstimate(); + } - // Check if projects exceed Free plan limit when downgrading $: isButtonDisabled = (() => { - const freeBasePlan = getBasePlanFromGroup(BillingPlanGroup.Starter); - const freePlanProjectLimit = freeBasePlan?.projects ?? 2; - const hasExcessProjects = allProjects.projects.length > freePlanProjectLimit; - if ($organization?.billingPlanId === selectedPlan.$id) return true; if (isDowngrade && selectedPlan.group === BillingPlanGroup.Starter && data.hasFreeOrgs) return true; + if (isDowngrade) { + if (isLoadingPlanEstimate) return true; + if (planEstimateError && !planChangeEstimate) return true; + + const limits = getPlanChangeLimits(); + if (limits && !limits.canChangePlan) return true; + } - return isDowngrade && selectedPlan.group === BillingPlanGroup.Starter && hasExcessProjects; + return false; })(); @@ -400,7 +484,8 @@ 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. + and service disruptions may occur if usage exceeds {selectedPlan.name} + plan limits. {/if} @@ -408,7 +493,11 @@ organization={data.organization} bind:projects={allProjects.projects} members={data.members?.memberships || []} - storageUsage={orgUsage?.storageTotal ?? 0} /> + storageUsage={orgUsage?.storageTotal ?? 0} + targetPlan={selectedPlan} + {planChangeEstimate} + estimateError={planEstimateError} + loading={isLoadingPlanEstimate} /> {/if}
@@ -484,12 +573,14 @@ {@const isSameGroup = data.organization.billingPlanDetails.group === selectedPlan.group} {#if !isStarter && !isSameGroup && isSelfService} + organizationId={data.organization.$id} + estimationOverride={planChangeEstimate} + preferExternalEstimate /> {:else if isSelfService} {/if}