mirror of
https://github.com/appwrite/console.git
synced 2026-06-06 19:27:48 +00:00
feat: use plan change estimation in billing flow
This commit is contained in:
@@ -7,6 +7,8 @@
|
||||
import { AppwriteException, type Models } from '@appwrite.io/console';
|
||||
import DiscountsApplied from './discountsApplied.svelte';
|
||||
|
||||
type PlanChangeEstimate = Models.EstimationUpdatePlan & Partial<Models.EstimationPlanChange>;
|
||||
|
||||
export let billingPlan: Models.BillingPlan;
|
||||
export let collaborators: string[];
|
||||
export let couponData: Partial<Models.Coupon>;
|
||||
@@ -14,9 +16,30 @@
|
||||
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;
|
||||
|
||||
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<string, Models.EstimationItem>);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
async function getEstimate(
|
||||
billingPlan: string,
|
||||
@@ -78,17 +101,22 @@
|
||||
}
|
||||
}
|
||||
|
||||
$: organizationId
|
||||
? getUpdatePlanEstimate(organizationId, billingPlan.$id, collaborators, couponData?.code)
|
||||
: getEstimate(billingPlan.$id, collaborators, couponData?.code);
|
||||
$: if (estimationOverride) {
|
||||
estimation = estimationOverride;
|
||||
} else if (organizationId) {
|
||||
getUpdatePlanEstimate(organizationId, billingPlan.$id, collaborators, couponData?.code);
|
||||
} else {
|
||||
getEstimate(billingPlan.$id, collaborators, couponData?.code);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if estimation}
|
||||
{@const estimationDetails = getEstimateDetails(estimation)}
|
||||
<Card.Base padding="s">
|
||||
<Layout.Stack>
|
||||
<slot />
|
||||
|
||||
{#each estimation.items ?? [] as item}
|
||||
{#each normalizeItems(estimationDetails?.items) as item}
|
||||
<Layout.Stack direction="row" justifyContent="space-between">
|
||||
<Typography.Text variant={isDowngrade ? 'm-500' : 'm-400'}
|
||||
>{item.label}</Typography.Text>
|
||||
@@ -96,7 +124,7 @@
|
||||
>{formatCurrency(item.value)}</Typography.Text>
|
||||
</Layout.Stack>
|
||||
{/each}
|
||||
{#each estimation.discounts ?? [] as item}
|
||||
{#each normalizeItems(estimationDetails?.discounts) as item}
|
||||
<DiscountsApplied {couponData} {...item} />
|
||||
{/each}
|
||||
|
||||
@@ -108,15 +136,15 @@
|
||||
<Layout.Stack direction="row" justifyContent="space-between">
|
||||
<Typography.Text>Total due</Typography.Text>
|
||||
<Typography.Text>
|
||||
{formatCurrency(estimation.grossAmount)}
|
||||
{formatCurrency(estimationDetails?.grossAmount ?? 0)}
|
||||
</Typography.Text>
|
||||
</Layout.Stack>
|
||||
|
||||
<Typography.Text>
|
||||
You'll pay <b>{formatCurrency(estimation.grossAmount)}</b>
|
||||
You'll pay <b>{formatCurrency(estimationDetails?.grossAmount ?? 0)}</b>
|
||||
now.
|
||||
{#if couponData?.code}Once your credits run out,{:else}Then{/if} you'll be charged
|
||||
<b>{formatCurrency(estimation.amount)}</b> every 30 days.
|
||||
<b>{formatCurrency(estimationDetails?.amount ?? 0)}</b> every 30 days.
|
||||
</Typography.Text>
|
||||
|
||||
<InputChoice
|
||||
|
||||
@@ -18,12 +18,22 @@
|
||||
let {
|
||||
members = [],
|
||||
storageUsage = 0,
|
||||
projects = $bindable([])
|
||||
projects = $bindable([]),
|
||||
targetPlan = null,
|
||||
planChangeEstimate = null,
|
||||
estimateError = null,
|
||||
loading = false
|
||||
}: {
|
||||
storageUsage?: number;
|
||||
projects?: Models.Project[];
|
||||
members?: Models.Membership[];
|
||||
organization: Models.Organization;
|
||||
targetPlan?: Models.BillingPlan | null;
|
||||
planChangeEstimate?:
|
||||
| (Models.EstimationUpdatePlan & Partial<Models.EstimationPlanChange>)
|
||||
| null;
|
||||
estimateError?: string | null;
|
||||
loading?: boolean;
|
||||
} = $props();
|
||||
|
||||
let showSelectProject = $state(false);
|
||||
@@ -34,6 +44,38 @@
|
||||
let isDeletingProjects = $state(false);
|
||||
let selectedProjectsToDelete = $state<Array<string>>([]);
|
||||
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 @@
|
||||
</script>
|
||||
|
||||
<Layout.Stack gap="l">
|
||||
{#if targetPlanLimits}
|
||||
{#if unsupportedAddons.length > 0}
|
||||
<Alert.Inline status="warning" title="Unsupported add-ons detected">
|
||||
Remove these add-ons before switching to {targetPlan?.name}: {unsupportedAddons.join(
|
||||
', '
|
||||
)}.
|
||||
</Alert.Inline>
|
||||
{/if}
|
||||
|
||||
{#if targetPlanLimits.canChangePlan}
|
||||
<Alert.Inline status="success" title="All projects fit this plan">
|
||||
{targetPlanLimits.totalProjects} project{targetPlanLimits.totalProjects === 1
|
||||
? ''
|
||||
: 's'} comply with the {targetPlan?.name} plan limits.
|
||||
</Alert.Inline>
|
||||
{:else if targetPlanLimits.nonCompliantProjects > 0}
|
||||
<Alert.Inline status="warning" title="Some projects exceed the selected plan limits">
|
||||
{targetPlanLimits.nonCompliantProjects} project{targetPlanLimits.nonCompliantProjects ===
|
||||
1
|
||||
? ''
|
||||
: 's'} need attention before you can switch to the {targetPlan?.name} plan.
|
||||
</Alert.Inline>
|
||||
{/if}
|
||||
{:else if loading}
|
||||
<Alert.Inline status="info" title="Checking plan limits">
|
||||
Reviewing your projects against the {targetPlan?.name} plan limits.
|
||||
</Alert.Inline>
|
||||
{:else if estimateError}
|
||||
<Alert.Inline status="warning" title="We couldn't load plan compliance details">
|
||||
{estimateError}
|
||||
</Alert.Inline>
|
||||
{/if}
|
||||
|
||||
{#if projectComplianceRows.length > 0}
|
||||
<div class="responsive-table">
|
||||
<Table.Root
|
||||
columns={[
|
||||
{ id: 'project', width: { min: 180 } },
|
||||
{ id: 'resource', width: { min: 140 } },
|
||||
{ id: 'currentUsage', width: { min: 110 } },
|
||||
{ id: 'limit', width: { min: 110 } },
|
||||
{ id: 'action', width: { min: 260 } }
|
||||
]}
|
||||
let:root>
|
||||
<svelte:fragment slot="header" let:root>
|
||||
<Table.Header.Cell column="project" {root}>Project</Table.Header.Cell>
|
||||
<Table.Header.Cell column="resource" {root}>Resource</Table.Header.Cell>
|
||||
<Table.Header.Cell column="currentUsage" {root}>Current</Table.Header.Cell>
|
||||
<Table.Header.Cell column="limit" {root}>Limit</Table.Header.Cell>
|
||||
<Table.Header.Cell column="action" {root}>Required action</Table.Header.Cell>
|
||||
</svelte:fragment>
|
||||
|
||||
{#each projectComplianceRows as row}
|
||||
<Table.Row.Base {root}>
|
||||
<Table.Cell column="project" {root}>
|
||||
<Typography.Text>{row.project}</Typography.Text>
|
||||
</Table.Cell>
|
||||
<Table.Cell column="resource" {root}>
|
||||
<Typography.Text>{row.resource}</Typography.Text>
|
||||
</Table.Cell>
|
||||
<Table.Cell column="currentUsage" {root}>
|
||||
<Typography.Text>{row.currentUsage}</Typography.Text>
|
||||
</Table.Cell>
|
||||
<Table.Cell column="limit" {root}>
|
||||
<Typography.Text>{row.limit}</Typography.Text>
|
||||
</Table.Cell>
|
||||
<Table.Cell column="action" {root}>
|
||||
<Typography.Text>{row.action}</Typography.Text>
|
||||
</Table.Cell>
|
||||
</Table.Row.Base>
|
||||
{/each}
|
||||
</Table.Root>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showSelectionReminder}
|
||||
<Alert.Inline status="warning" title="Choose projects to keep">
|
||||
The Free plan lets you keep {allowedProjectsToKeep} projects. Select them before continuing.
|
||||
@@ -160,126 +288,130 @@
|
||||
</Alert.Inline>
|
||||
{/if}
|
||||
|
||||
<div class="responsive-table">
|
||||
<Table.Root
|
||||
columns={[
|
||||
{ id: 'resource', width: { min: 215 } },
|
||||
{ id: 'freeLimit', width: { min: 100 } },
|
||||
{ id: 'excessUsage', width: { min: 120 } },
|
||||
{ id: 'manage', width: { min: 110 } }
|
||||
]}
|
||||
let:root>
|
||||
<svelte:fragment slot="header" let:root>
|
||||
<Table.Header.Cell column="resource" {root}>Resource</Table.Header.Cell>
|
||||
<Table.Header.Cell column="freeLimit" {root}>Free limit</Table.Header.Cell>
|
||||
<Table.Header.Cell column="excessUsage" {root}>
|
||||
<Layout.Stack direction="row" alignItems="center" gap="xs">
|
||||
<Typography.Text>Excess usage</Typography.Text>
|
||||
<Tooltip placement="bottom" portal>
|
||||
<Icon icon={IconInfo} size="s" />
|
||||
<span slot="tooltip">Usage beyond the Free plan limits.</span>
|
||||
</Tooltip>
|
||||
</Layout.Stack>
|
||||
</Table.Header.Cell>
|
||||
<Table.Header.Cell column="manage" {root}></Table.Header.Cell>
|
||||
</svelte:fragment>
|
||||
{#if isDowngradingToFree}
|
||||
<div class="responsive-table">
|
||||
<Table.Root
|
||||
columns={[
|
||||
{ id: 'resource', width: { min: 215 } },
|
||||
{ id: 'freeLimit', width: { min: 100 } },
|
||||
{ id: 'excessUsage', width: { min: 120 } },
|
||||
{ id: 'manage', width: { min: 110 } }
|
||||
]}
|
||||
let:root>
|
||||
<svelte:fragment slot="header" let:root>
|
||||
<Table.Header.Cell column="resource" {root}>Resource</Table.Header.Cell>
|
||||
<Table.Header.Cell column="freeLimit" {root}>Free limit</Table.Header.Cell>
|
||||
<Table.Header.Cell column="excessUsage" {root}>
|
||||
<Layout.Stack direction="row" alignItems="center" gap="xs">
|
||||
<Typography.Text>Excess usage</Typography.Text>
|
||||
<Tooltip placement="bottom" portal>
|
||||
<Icon icon={IconInfo} size="s" />
|
||||
<span slot="tooltip">Usage beyond the Free plan limits.</span>
|
||||
</Tooltip>
|
||||
</Layout.Stack>
|
||||
</Table.Header.Cell>
|
||||
<Table.Header.Cell column="manage" {root}></Table.Header.Cell>
|
||||
</svelte:fragment>
|
||||
|
||||
<!-- Projects Row -->
|
||||
<Table.Row.Base {root}>
|
||||
<Table.Cell column="resource" {root}>
|
||||
<Layout.Stack direction="row" alignItems="center" gap="xs">
|
||||
<Typography.Text>Projects</Typography.Text>
|
||||
<!-- Projects Row -->
|
||||
<Table.Row.Base {root}>
|
||||
<Table.Cell column="resource" {root}>
|
||||
<Layout.Stack direction="row" alignItems="center" gap="xs">
|
||||
<Typography.Text>Projects</Typography.Text>
|
||||
{#if isLimitExceeded.projects}
|
||||
<Badge
|
||||
size="xs"
|
||||
content="Action required"
|
||||
variant="secondary"
|
||||
type="warning" />
|
||||
{/if}
|
||||
</Layout.Stack>
|
||||
</Table.Cell>
|
||||
<Table.Cell column="freeLimit" {root}>
|
||||
<Typography.Text
|
||||
>{formatNumber(allowedProjectsToKeep)} projects</Typography.Text>
|
||||
</Table.Cell>
|
||||
<Table.Cell column="excessUsage" {root}>
|
||||
{#if isLimitExceeded.projects}
|
||||
<Badge
|
||||
size="xs"
|
||||
content="Action required"
|
||||
variant="secondary"
|
||||
type="warning" />
|
||||
<Layout.Stack direction="row" alignItems="center" gap="xs">
|
||||
<Icon icon={IconArrowUp} size="s" color="--fgcolor-error" />
|
||||
<Typography.Text color="--fgcolor-error">
|
||||
{formatNumber(excessUsage.projects)} projects
|
||||
</Typography.Text>
|
||||
</Layout.Stack>
|
||||
{:else}
|
||||
<Typography.Text color="--fgcolor-neutral-secondary">
|
||||
{formatNumber(currentUsage.projects)} / {formatNumber(
|
||||
allowedProjectsToKeep
|
||||
)}
|
||||
</Typography.Text>
|
||||
{/if}
|
||||
</Layout.Stack>
|
||||
</Table.Cell>
|
||||
<Table.Cell column="freeLimit" {root}>
|
||||
<Typography.Text
|
||||
>{formatNumber(allowedProjectsToKeep)} projects</Typography.Text>
|
||||
</Table.Cell>
|
||||
<Table.Cell column="excessUsage" {root}>
|
||||
{#if isLimitExceeded.projects}
|
||||
<Layout.Stack direction="row" alignItems="center" gap="xs">
|
||||
<Icon icon={IconArrowUp} size="s" color="--fgcolor-error" />
|
||||
<Typography.Text color="--fgcolor-error">
|
||||
{formatNumber(excessUsage.projects)} projects
|
||||
</Typography.Text>
|
||||
</Layout.Stack>
|
||||
{:else}
|
||||
<Typography.Text color="--fgcolor-neutral-secondary">
|
||||
{formatNumber(currentUsage.projects)} / {formatNumber(
|
||||
allowedProjectsToKeep
|
||||
)}
|
||||
</Typography.Text>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell column="manage" {root}>
|
||||
{#if isLimitExceeded.projects}
|
||||
<Layout.Stack direction="row" justifyContent="flex-end">
|
||||
<Button size="xs" secondary on:click={handleManageProjects}
|
||||
>Manage projects</Button>
|
||||
</Layout.Stack>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
</Table.Row.Base>
|
||||
</Table.Cell>
|
||||
<Table.Cell column="manage" {root}>
|
||||
{#if isLimitExceeded.projects}
|
||||
<Layout.Stack direction="row" justifyContent="flex-end">
|
||||
<Button size="xs" secondary on:click={handleManageProjects}
|
||||
>Manage projects</Button>
|
||||
</Layout.Stack>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
</Table.Row.Base>
|
||||
|
||||
<!-- Organization Members Row -->
|
||||
<Table.Row.Base {root}>
|
||||
<Table.Cell column="resource" {root}>
|
||||
<Typography.Text>Organization members</Typography.Text>
|
||||
</Table.Cell>
|
||||
<Table.Cell column="freeLimit" {root}>
|
||||
<Typography.Text>{formatNumber(freePlanLimits.members)} member</Typography.Text>
|
||||
</Table.Cell>
|
||||
<Table.Cell column="excessUsage" {root}>
|
||||
{#if isLimitExceeded.members}
|
||||
<Layout.Stack direction="row" alignItems="center" gap="xs">
|
||||
<Icon icon={IconArrowUp} size="s" color="--fgcolor-error" />
|
||||
<Typography.Text color="--fgcolor-error">
|
||||
{formatNumber(excessUsage.members)} members
|
||||
</Typography.Text>
|
||||
</Layout.Stack>
|
||||
{:else}
|
||||
<Typography.Text color="--fgcolor-neutral-secondary">N/A</Typography.Text>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell column="manage" {root}></Table.Cell>
|
||||
</Table.Row.Base>
|
||||
<!-- Organization Members Row -->
|
||||
<Table.Row.Base {root}>
|
||||
<Table.Cell column="resource" {root}>
|
||||
<Typography.Text>Organization members</Typography.Text>
|
||||
</Table.Cell>
|
||||
<Table.Cell column="freeLimit" {root}>
|
||||
<Typography.Text
|
||||
>{formatNumber(freePlanLimits.members)} member</Typography.Text>
|
||||
</Table.Cell>
|
||||
<Table.Cell column="excessUsage" {root}>
|
||||
{#if isLimitExceeded.members}
|
||||
<Layout.Stack direction="row" alignItems="center" gap="xs">
|
||||
<Icon icon={IconArrowUp} size="s" color="--fgcolor-error" />
|
||||
<Typography.Text color="--fgcolor-error">
|
||||
{formatNumber(excessUsage.members)} members
|
||||
</Typography.Text>
|
||||
</Layout.Stack>
|
||||
{:else}
|
||||
<Typography.Text color="--fgcolor-neutral-secondary"
|
||||
>N/A</Typography.Text>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell column="manage" {root}></Table.Cell>
|
||||
</Table.Row.Base>
|
||||
|
||||
<!-- Storage Row -->
|
||||
<Table.Row.Base {root}>
|
||||
<Table.Cell column="resource" {root}>
|
||||
<Typography.Text>Storage</Typography.Text>
|
||||
</Table.Cell>
|
||||
<Table.Cell column="freeLimit" {root}>
|
||||
<Typography.Text>{freePlanLimits.storage} GB</Typography.Text>
|
||||
</Table.Cell>
|
||||
<Table.Cell column="excessUsage" {root}>
|
||||
{#if isLimitExceeded.storage}
|
||||
<Layout.Stack direction="row" alignItems="center" gap="xs">
|
||||
<Icon icon={IconArrowUp} size="s" color="--fgcolor-error" />
|
||||
<Typography.Text color="--fgcolor-error">
|
||||
{excessUsage.storage.toFixed(2)} GB
|
||||
<!-- Storage Row -->
|
||||
<Table.Row.Base {root}>
|
||||
<Table.Cell column="resource" {root}>
|
||||
<Typography.Text>Storage</Typography.Text>
|
||||
</Table.Cell>
|
||||
<Table.Cell column="freeLimit" {root}>
|
||||
<Typography.Text>{freePlanLimits.storage} GB</Typography.Text>
|
||||
</Table.Cell>
|
||||
<Table.Cell column="excessUsage" {root}>
|
||||
{#if isLimitExceeded.storage}
|
||||
<Layout.Stack direction="row" alignItems="center" gap="xs">
|
||||
<Icon icon={IconArrowUp} size="s" color="--fgcolor-error" />
|
||||
<Typography.Text color="--fgcolor-error">
|
||||
{excessUsage.storage.toFixed(2)} GB
|
||||
</Typography.Text>
|
||||
</Layout.Stack>
|
||||
{:else}
|
||||
<Typography.Text color="--fgcolor-neutral-secondary">
|
||||
{storageUsageGB.toFixed(2)} / {freePlanLimits.storage} GB
|
||||
</Typography.Text>
|
||||
</Layout.Stack>
|
||||
{:else}
|
||||
<Typography.Text color="--fgcolor-neutral-secondary">
|
||||
{storageUsageGB.toFixed(2)} / {freePlanLimits.storage} GB
|
||||
</Typography.Text>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell column="manage" {root}></Table.Cell>
|
||||
</Table.Row.Base>
|
||||
</Table.Root>
|
||||
</div>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell column="manage" {root}></Table.Cell>
|
||||
</Table.Row.Base>
|
||||
</Table.Root>
|
||||
</div>
|
||||
{/if}
|
||||
</Layout.Stack>
|
||||
|
||||
{#if showSelectProject}
|
||||
{#if showSelectProject && isDowngradingToFree}
|
||||
{@const requiredToDelete = currentUsage.projects - allowedProjectsToKeep}
|
||||
<Modal
|
||||
bind:show={showSelectProject}
|
||||
|
||||
@@ -38,6 +38,8 @@
|
||||
import { BillingPlanGroup, type Models, Query } from '@appwrite.io/console';
|
||||
import { toLocaleDate } from '$lib/helpers/date';
|
||||
|
||||
type PlanChangeEstimate = Models.EstimationUpdatePlan & Partial<Models.EstimationPlanChange>;
|
||||
|
||||
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,11 +139,76 @@
|
||||
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 (!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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
@@ -167,14 +238,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 +309,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 +375,22 @@
|
||||
|
||||
$: isUpgrade = selectedPlan.order > $currentPlan?.order;
|
||||
$: isDowngrade = selectedPlan.order < $currentPlan?.order;
|
||||
$: if (data?.organization?.$id && selectedPlan?.$id) {
|
||||
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 && selectedPlan.$id !== $organization?.billingPlanId) {
|
||||
if (isLoadingPlanEstimate) return true;
|
||||
|
||||
return isDowngrade && selectedPlan.group === BillingPlanGroup.Starter && hasExcessProjects;
|
||||
const limits = getPlanChangeLimits();
|
||||
if (limits && !limits.canChangePlan) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -400,7 +466,8 @@
|
||||
features until your billing period ends. After that,
|
||||
<span class="u-bold"
|
||||
>all team members except the owner will be removed,</span>
|
||||
and service disruptions may occur if usage exceeds Free plan limits.
|
||||
and service disruptions may occur if usage exceeds {selectedPlan.name}
|
||||
plan limits.
|
||||
</Alert.Inline>
|
||||
{/if}
|
||||
|
||||
@@ -408,7 +475,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}
|
||||
</Layout.Stack>
|
||||
</Fieldset>
|
||||
@@ -484,12 +555,13 @@
|
||||
{@const isSameGroup = data.organization.billingPlanDetails.group === selectedPlan.group}
|
||||
{#if !isStarter && !isSameGroup && isSelfService}
|
||||
<EstimatedTotalBox
|
||||
{collaborators}
|
||||
collaborators={getAdditionalInvites()}
|
||||
{isDowngrade}
|
||||
billingPlan={selectedPlan}
|
||||
bind:couponData={selectedCoupon}
|
||||
bind:billingBudget
|
||||
organizationId={data.organization.$id} />
|
||||
organizationId={data.organization.$id}
|
||||
estimationOverride={planChangeEstimate} />
|
||||
{:else if isSelfService}
|
||||
<PlanComparisonBox downgrade={data.hasFreeOrgs ? false : isDowngrade} />
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user