Merge pull request #2722 from appwrite/billing-sdk-refactor

This commit is contained in:
Darshan
2026-01-28 18:11:00 +05:30
committed by GitHub
218 changed files with 2924 additions and 4174 deletions
+8 -2
View File
@@ -24,9 +24,15 @@ export async function createFreeProject(page: Page): Promise<Metadata> {
const regionPicker = dialog.locator('button[role="combobox"]');
if (await regionPicker.isVisible()) {
await regionPicker.click();
await page.getByRole('option', { name: /New York/i }).click();
const firstEnabledOption = page
.locator('[role="option"]:not([data-disabled="true"])')
.first();
region = 'nyc';
if ((await firstEnabledOption.count()) > 0) {
const selectedRegion = await firstEnabledOption.getAttribute('data-value');
await firstEnabledOption.click();
region = selectedRegion?.replace(/"/g, '') || 'fra';
}
}
await dialog.getByRole('button', { name: 'create' }).click();
+8 -2
View File
@@ -57,9 +57,15 @@ export async function createProProject(page: Page): Promise<Metadata> {
const regionPicker = dialog.locator('button[role="combobox"]');
if (await regionPicker.isVisible()) {
await regionPicker.click();
await page.getByRole('option', { name: /New York/i }).click();
const firstEnabledOption = page
.locator('[role="option"]:not([data-disabled="true"])')
.first();
region = 'nyc';
if ((await firstEnabledOption.count()) > 0) {
const selectedRegion = await firstEnabledOption.getAttribute('data-value');
await firstEnabledOption.click();
region = selectedRegion?.replace(/"/g, '') || 'fra';
}
}
await dialog.getByRole('button', { name: 'create' }).click();
@@ -1,16 +1,10 @@
import { resolve } from '$app/paths';
import { goto } from '$app/navigation';
import { base } from '$app/paths';
import { sdk } from '$lib/stores/sdk';
import type { Searcher } from '../commands';
import { isCloud } from '$lib/system';
import { Platform, Query } from '@appwrite.io/console';
import { getTeamOrOrganizationList } from '$lib/stores/organization';
export const orgSearcher = (async (query: string) => {
const { teams } = !isCloud
? await sdk.forConsole.teams.list()
: await sdk.forConsole.billing.listOrganization([
Query.equal('platform', Platform.Appwrite)
]);
const { teams } = await getTeamOrOrganizationList();
return teams
.filter((organization) => organization.name.toLowerCase().includes(query.toLowerCase()))
@@ -18,7 +12,11 @@ export const orgSearcher = (async (query: string) => {
return {
label: organization.name,
callback: () => {
goto(`${base}/organization-${organization.$id}`);
goto(
resolve('/(console)/organization-[organization]', {
organization: organization.$id
})
);
},
group: 'organizations'
} as const;
+4 -6
View File
@@ -38,23 +38,21 @@
import { isSmallViewport } from '$lib/stores/viewport';
import { isCloud } from '$lib/system';
import { regions as regionsStore } from '$lib/stores/organization';
import type { Organization } from '$lib/stores/organization';
import type { Plan } from '$lib/sdk/billing';
// props
interface Props {
currentPlan: Models.BillingPlan;
organization: Models.Organization;
projectsToArchive: Models.Project[];
organization: Organization;
currentPlan: Plan;
archivedTotalOverall: number;
archivedOffset: number;
limit: number;
}
let {
projectsToArchive,
organization,
currentPlan,
organization,
projectsToArchive,
archivedTotalOverall,
archivedOffset,
limit
@@ -1,11 +1,10 @@
<script lang="ts">
import { page } from '$app/state';
import { BillingPlan } from '$lib/constants';
import { Button } from '$lib/elements/forms';
import { organization } from '$lib/stores/organization';
import { HeaderAlert } from '$lib/layout';
import { isCloud } from '$lib/system';
import { upgradeURL } from '$lib/stores/billing';
import { getChangePlanUrl } from '$lib/stores/billing';
import { hideNotification } from '$lib/helpers/notifications';
import { backupsBannerId, showPolicyAlert } from '$lib/stores/database';
import { IconX } from '@appwrite.io/pink-icons-svelte';
@@ -18,14 +17,16 @@
</script>
{#if $showPolicyAlert && isCloud && $organization?.$id && page.url.pathname.match(/\/databases\/database-[^/]+$/)}
{@const isFreePlan = $organization?.billingPlan === BillingPlan.FREE}
{@const areBackupsAvailable = $organization?.billingPlanDetails.backupsEnabled}
{@const subtitle = isFreePlan
{@const subtitle = !areBackupsAvailable
? 'Upgrade your plan to ensure your data stays safe and backed up'
: 'Protect your data by quickly adding a backup policy'}
{@const ctaText = isFreePlan ? 'Upgrade plan' : 'Create policy'}
{@const ctaURL = isFreePlan ? $upgradeURL : `${page.url.pathname}/backups`}
{@const ctaText = !areBackupsAvailable ? 'Upgrade plan' : 'Create policy'}
{@const ctaURL = !areBackupsAvailable
? getChangePlanUrl($organization.$id)
: `${page.url.pathname}/backups`}
<HeaderAlert type="warning" title="Your database has no backup policy">
<svelte:fragment>{subtitle}</svelte:fragment>
@@ -35,7 +36,7 @@
href={ctaURL}
secondary
fullWidthMobile
event={isFreePlan ? 'backup_banner_upgrade' : 'backup_banner_add'}>
event={!areBackupsAvailable ? 'backup_banner_upgrade' : 'backup_banner_add'}>
<span class="text">{ctaText}</span>
</Button>
+3 -3
View File
@@ -4,7 +4,7 @@
import { onMount } from 'svelte';
import { isCloud, isSelfHosted } from '$lib/system';
import { organization } from '$lib/stores/organization';
import { BillingPlan, Dependencies } from '$lib/constants';
import { Dependencies } from '$lib/constants';
import { goto, invalidate } from '$app/navigation';
import { page } from '$app/state';
import { addNotification } from '$lib/stores/notifications';
@@ -125,8 +125,8 @@
}
onMount(() => {
// fast path: don't subscribe if org is on a free plan or is self-hosted.
if (isSelfHosted || (isCloud && $organization?.billingPlan === BillingPlan.FREE)) return;
// fast path: don't subscribe if org doesn't support backups or is self-hosted.
if (isSelfHosted || (isCloud && !$organization?.billingPlanDetails.backupsEnabled)) return;
return realtime.forProject(page.params.region, 'console', (response) => {
if (!response.channels.includes(`projects.${getProjectId()}`)) return;
@@ -2,21 +2,19 @@
import { base } from '$app/paths';
import { page } from '$app/state';
import { Click, trackEvent } from '$lib/actions/analytics';
import { BillingPlan } from '$lib/constants';
import { Button } from '$lib/elements/forms';
import { HeaderAlert } from '$lib/layout';
import { hideBillingHeaderRoutes, readOnly, tierToPlan, upgradeURL } from '$lib/stores/billing';
import { hideBillingHeaderRoutes, readOnly, getChangePlanUrl } from '$lib/stores/billing';
import { organization } from '$lib/stores/organization';
</script>
{#if $organization?.$id && $organization?.billingPlan === BillingPlan.FREE && $readOnly && !hideBillingHeaderRoutes.includes(page.url.pathname)}
{#if $organization?.$id && !$organization?.billingPlanDetails.usage && $readOnly && !hideBillingHeaderRoutes.includes(page.url.pathname)}
<HeaderAlert
type="error"
title={`${$organization.name} usage has reached the ${tierToPlan($organization.billingPlan).name} plan limit`}>
title={`${$organization.name} usage has reached the ${$organization.billingPlanDetails.name} plan limit`}>
<svelte:fragment>
Usage for the <b>{$organization.name}</b> organization has reached the limits of the {tierToPlan(
$organization.billingPlan
).name}
Usage for the <b>{$organization.name}</b> organization has reached the limits of the {$organization
.billingPlanDetails.name}
plan. Consider upgrading to increase your resource usage.
</svelte:fragment>
<svelte:fragment slot="buttons">
@@ -29,7 +27,7 @@
</Button>
{/if}
<Button
href={$upgradeURL}
href={getChangePlanUrl($organization.$id)}
on:click={() => {
trackEvent(Click.OrganizationClickUpgrade, {
from: 'button',
@@ -1,14 +1,36 @@
<script lang="ts">
import { base } from '$app/paths';
import { page } from '$app/state';
import { BillingPlan } from '$lib/constants';
import { Button } from '$lib/elements/forms';
import { HeaderAlert } from '$lib/layout';
import { hideBillingHeaderRoutes } from '$lib/stores/billing';
import { orgMissingPaymentMethod } from '$routes/(console)/store';
// exists
$: hasOrgBillingContext = !!$orgMissingPaymentMethod;
// needs any methods
$: requiresPaymentMethod =
hasOrgBillingContext && $orgMissingPaymentMethod.billingPlanDetails.requiresPaymentMethod;
// has any methods
$: hasAnyPaymentMethod =
hasOrgBillingContext &&
(!!$orgMissingPaymentMethod.paymentMethodId ||
!!$orgMissingPaymentMethod.backupPaymentMethodId);
// is url excluded
$: isBillingHeaderHidden = hideBillingHeaderRoutes.includes(page.url.pathname);
// should show header
$: shouldShowBillingHeader =
hasOrgBillingContext &&
requiresPaymentMethod &&
!hasAnyPaymentMethod &&
!isBillingHeaderHidden;
</script>
{#if ($orgMissingPaymentMethod.billingPlan === BillingPlan.PRO || $orgMissingPaymentMethod.billingPlan === BillingPlan.SCALE) && !$orgMissingPaymentMethod.paymentMethodId && !$orgMissingPaymentMethod.backupPaymentMethodId && !hideBillingHeaderRoutes.includes(page.url.pathname)}
{#if shouldShowBillingHeader}
<HeaderAlert
type="error"
title={`Payment method required for ${$orgMissingPaymentMethod.name}`}>
@@ -2,7 +2,7 @@
import { base } from '$app/paths';
import { page } from '$app/state';
import { Click, trackEvent } from '$lib/actions/analytics';
import { BillingPlan, NEW_DEV_PRO_UPGRADE_COUPON } from '$lib/constants';
import { NEW_DEV_PRO_UPGRADE_COUPON } from '$lib/constants';
import { Button } from '$lib/elements/forms';
import { organization } from '$lib/stores/organization';
import { activeHeaderAlert } from '$routes/(console)/store';
@@ -23,7 +23,7 @@
}
</script>
{#if show && $organization?.$id && $organization?.billingPlan === BillingPlan.FREE && !page.url.pathname.includes(base + '/account')}
{#if show && $organization?.$id && !$organization?.billingPlanDetails.supportsCredits && !page.url.pathname.includes(base + '/account')}
<GradientBanner on:close={handleClose}>
<Layout.Stack
gap="m"
@@ -3,7 +3,7 @@
import { Click } from '$lib/actions/analytics';
import { Button } from '$lib/elements/forms';
import { HeaderAlert } from '$lib/layout';
import { hideBillingHeaderRoutes, upgradeURL } from '$lib/stores/billing';
import { hideBillingHeaderRoutes, getChangePlanUrl } from '$lib/stores/billing';
import { currentPlan, organization } from '$lib/stores/organization';
import SelectProjectCloud from './selectProjectCloud.svelte';
import { toLocaleDate } from '$lib/helpers/date';
@@ -22,7 +22,7 @@
<SelectProjectCloud bind:showSelectProject bind:selectedProjects {organizationId} />
{#if $currentPlan && $currentPlan.projects > 0 && !hideBillingHeaderRoutes.includes(page.url.pathname)}
{#if organizationId && $currentPlan && $currentPlan.projects > 0 && !hideBillingHeaderRoutes.includes(page.url.pathname)}
<HeaderAlert
type="warning"
title="Action required: You have more than {$currentPlan.projects} projects.">
@@ -38,7 +38,7 @@
showSelectProject = true;
}}>Manage projects</Button>
<Button
href={$upgradeURL}
href={getChangePlanUrl(organizationId)}
event={Click.OrganizationClickUpgrade}
eventData={{
from: 'button',
@@ -31,10 +31,10 @@
async function updateSelected() {
try {
await sdk.forConsole.billing.updateSelectedProjects(
projects[0].teamId,
selectedProjects
);
await sdk.forConsole.organizations.updateProjects({
organizationId,
projects: selectedProjects
});
showSelectProject = false;
invalidate(Dependencies.ORGANIZATION);
@@ -1,16 +1,16 @@
<script lang="ts">
import { Button, InputText } from '$lib/elements/forms';
import { formatCurrency } from '$lib/helpers/numbers';
import type { Coupon } from '$lib/sdk/billing';
import { sdk } from '$lib/stores/sdk';
import { Layout } from '@appwrite.io/pink-svelte';
import { createEventDispatcher } from 'svelte';
import { Layout } from '@appwrite.io/pink-svelte';
import type { Models } from '@appwrite.io/console';
import { formatCurrency } from '$lib/helpers/numbers';
import { Button, InputText } from '$lib/elements/forms';
const dispatch = createEventDispatcher();
export let required = false;
export let coupon: string = '';
export let couponData: Partial<Coupon> = {
export let couponData: Partial<Models.Coupon> = {
code: null,
status: null,
credits: null
@@ -18,8 +18,10 @@
async function addCoupon() {
try {
const response = await sdk.forConsole.billing.getCouponAccount(coupon);
couponData = response;
couponData = await sdk.forConsole.account.getCoupon({
couponId: coupon
});
dispatch('validation', couponData);
coupon = null;
} catch (error) {
@@ -1,11 +1,11 @@
<script lang="ts">
import { Button } from '$lib/elements/forms';
import type { Coupon } from '$lib/sdk/billing';
import type { Models } from '@appwrite.io/console';
import { formatCurrency } from '$lib/helpers/numbers';
import { IconTag, IconX } from '@appwrite.io/pink-icons-svelte';
import { Badge, Icon, Layout, Tooltip, Typography } from '@appwrite.io/pink-svelte';
export let couponData: Partial<Coupon> = {
export let couponData: Partial<Models.Coupon> = {
code: null,
status: null,
credits: null
@@ -1,12 +1,12 @@
<script lang="ts">
import type { Coupon } from '$lib/sdk/billing';
import type { Models } from '@appwrite.io/console';
import { formatCurrency } from '$lib/helpers/numbers';
import { IconTag } from '@appwrite.io/pink-icons-svelte';
import { Badge, Icon, Layout, Typography } from '@appwrite.io/pink-svelte';
export let label: string;
export let value: number;
export let couponData: Partial<Coupon> = {
export let couponData: Partial<Models.Coupon> = {
code: null,
status: null,
credits: null
@@ -1,27 +1,41 @@
<script lang="ts">
import { Click, trackEvent } from '$lib/actions/analytics';
import { BillingPlan } from '$lib/constants';
import { Button } from '$lib/elements/forms';
import { tierToPlan, upgradeURL } from '$lib/stores/billing';
import { Layout, Typography } from '@appwrite.io/pink-svelte';
import { Card } from '..';
import type { Snippet } from 'svelte';
import { Button } from '$lib/elements/forms';
import { BillingPlanGroup } from '@appwrite.io/console';
import { Click, trackEvent } from '$lib/actions/analytics';
import { Layout, Typography } from '@appwrite.io/pink-svelte';
import { getBasePlanFromGroup, getChangePlanUrl } from '$lib/stores/billing';
export let service: string;
export let eventSource: string;
let {
service,
eventSource,
organizationId = null,
children = null
}: {
service: string;
eventSource: string;
organizationId?: string;
children?: Snippet;
} = $props();
const proPlanName = getBasePlanFromGroup(BillingPlanGroup.Pro).name;
</script>
<Card>
<slot>
{#if children}
{@render children()}
{:else}
<Layout.Stack alignItems="center">
<Typography.Text variant="m-600">Upgrade to add {service}</Typography.Text>
<Typography.Text>
Upgrade to a {tierToPlan(BillingPlan.PRO).name} plan to add {service} to your organization
Upgrade to a {proPlanName} plan to add {service} to your organization
</Typography.Text>
<Button
secondary
fullWidthMobile
href={$upgradeURL}
href={getChangePlanUrl(organizationId)}
on:click={() => {
trackEvent(Click.OrganizationClickUpgrade, {
from: 'button',
@@ -31,5 +45,5 @@
Upgrade
</Button>
</Layout.Stack>
</slot>
{/if}
</Card>
@@ -1,24 +1,22 @@
<script lang="ts">
import { InputChoice, InputNumber } from '$lib/elements/forms';
import { formatCurrency } from '$lib/helpers/numbers';
import type { Coupon, Estimation } from '$lib/sdk/billing';
import { type Tier } from '$lib/stores/billing';
import { Card, Divider, Layout, Typography } from '@appwrite.io/pink-svelte';
import { CreditsApplied } from '.';
import { sdk } from '$lib/stores/sdk';
import { AppwriteException } from '@appwrite.io/console';
import { AppwriteException, type Models } from '@appwrite.io/console';
import DiscountsApplied from './discountsApplied.svelte';
export let billingPlan: Tier;
export let billingPlan: Models.BillingPlan;
export let collaborators: string[];
export let couponData: Partial<Coupon>;
export let couponData: Partial<Models.Coupon>;
export let billingBudget: number;
export let fixedCoupon = false; // If true, the coupon cannot be removed
export let isDowngrade = false;
export let organizationId: string | undefined = undefined;
let budgetEnabled = false;
let estimation: Estimation;
let estimation: Models.Estimation;
async function getEstimate(
billingPlan: string,
@@ -26,11 +24,11 @@
couponId: string | undefined
) {
try {
estimation = await sdk.forConsole.billing.estimationCreateOrganization(
estimation = await sdk.forConsole.organizations.estimationCreateOrganization({
billingPlan,
couponId === '' ? null : couponId,
collaborators ?? []
);
invites: collaborators ?? [],
couponId: couponId === '' ? null : couponId
});
} catch (e) {
if (e instanceof AppwriteException) {
if (
@@ -56,12 +54,12 @@
couponId: string | undefined
) {
try {
estimation = await sdk.forConsole.billing.estimationUpdatePlan(
estimation = await sdk.forConsole.organizations.estimationUpdatePlan({
organizationId,
billingPlan,
couponId && couponId.length > 0 ? couponId : null,
collaborators ?? []
);
billingPlan: billingPlan,
invites: collaborators ?? [],
couponId: couponId && couponId.length > 0 ? couponId : null
});
} catch (e) {
if (e instanceof AppwriteException) {
if (
@@ -81,8 +79,8 @@
}
$: organizationId
? getUpdatePlanEstimate(organizationId, billingPlan, collaborators, couponData?.code)
: getEstimate(billingPlan, collaborators, couponData?.code);
? getUpdatePlanEstimate(organizationId, billingPlan.$id, collaborators, couponData?.code)
: getEstimate(billingPlan.$id, collaborators, couponData?.code);
</script>
{#if estimation}
@@ -105,6 +103,7 @@
{#if couponData?.status === 'active'}
<CreditsApplied bind:couponData {fixedCoupon} />
{/if}
<Divider />
<Layout.Stack direction="row" justifyContent="space-between">
<Typography.Text>Total due</Typography.Text>
-1
View File
@@ -6,4 +6,3 @@ export { default as PlanComparisonBox } from './planComparisonBox.svelte';
export { default as EmptyCardCloud } from './emptyCardCloud.svelte';
export { default as CreditsApplied } from './creditsApplied.svelte';
export { default as PlanSelection } from './planSelection.svelte';
export { default as SelectPlan } from './selectPlan.svelte';
@@ -4,11 +4,11 @@
import { CreditCardBrandImage } from '..';
import { initializeStripe, unmountPaymentElement } from '$lib/stores/stripe';
import { Badge, Card, Layout } from '@appwrite.io/pink-svelte';
import type { PaymentMethodData } from '$lib/sdk/billing';
import type { PaymentMethod } from '@stripe/stripe-js';
import StatePicker from './statePicker.svelte';
import type { Models } from '@appwrite.io/console';
export let methods: PaymentMethodData[];
export let methods: Array<Models.PaymentMethod>;
export let group: string;
export let name: string;
export let defaultMethod: string = null;
@@ -13,19 +13,19 @@
import { addNotification } from '$lib/stores/notifications';
import { page } from '$app/state';
import { Spinner } from '@appwrite.io/pink-svelte';
import type { PaymentMethod } from '@stripe/stripe-js';
import type { PaymentMethod as StripePaymentMethod } from '@stripe/stripe-js';
import StatePicker from './statePicker.svelte';
import type { PaymentMethodData } from '$lib/sdk/billing';
import type { Models } from '@appwrite.io/console';
export let show = false;
export let onCardSubmit: ((card: PaymentMethodData) => void) | null = null;
export let onCardSubmit: ((card: Models.PaymentMethod) => void) | null = null;
let modal: FakeModal;
let name: string;
let state: string = '';
let error: string = null;
let showState: boolean = false;
let paymentMethod: PaymentMethod | null = null;
let paymentMethod: StripePaymentMethod | null = null;
async function handleSubmit() {
try {
@@ -47,15 +47,15 @@
const card = await submitStripeCard(name, page?.params?.organization ?? null);
if (card && Object.hasOwn(card, 'id')) {
if ((card as PaymentMethod).card?.country === 'US') {
paymentMethod = card as PaymentMethod;
if ((card as StripePaymentMethod).card?.country === 'US') {
paymentMethod = card as StripePaymentMethod;
showState = true;
return;
}
}
modal.closeModal();
await invalidate(Dependencies.PAYMENT_METHODS);
onCardSubmit?.(card as PaymentMethodData);
onCardSubmit?.(card as Models.PaymentMethod);
addNotification({
type: 'success',
message: 'A new payment method has been added to your account'
@@ -1,103 +1,130 @@
<script lang="ts">
import { BillingPlan } from '$lib/constants';
import { page } from '$app/state';
import { formatNum } from '$lib/helpers/string';
import { plansInfo, tierToPlan, type Tier } from '$lib/stores/billing';
import { BillingPlanGroup, type Models } from '@appwrite.io/console';
import { Card, Layout, Tabs, Typography } from '@appwrite.io/pink-svelte';
import { getBasePlanFromGroup, planHasGroup, plansInfo } from '$lib/stores/billing';
export let downgrade = false;
let {
downgrade = false
}: {
downgrade?: boolean;
} = $props();
let selectedTab: Tier = BillingPlan.FREE;
let selectedTab: string = $state(getBasePlanFromGroup(BillingPlanGroup.Starter).$id);
$: plan = $plansInfo.get(selectedTab);
const currentPlan: Models.BillingPlan = $derived($plansInfo.get(selectedTab));
const visiblePlans: Array<Models.BillingPlan> = $derived.by(() => {
return page.data.plans.plans.filter(
(plan: Models.BillingPlan) => plan.group !== BillingPlanGroup.Scale
);
});
const allTiers: Tier[] = [BillingPlan.FREE, BillingPlan.PRO, BillingPlan.SCALE];
$: visibleTiers = allTiers.filter((tier) => tier !== BillingPlan.SCALE);
const uniquePlans: Array<Models.BillingPlan> = $derived.by(() => {
const map = new Map(visiblePlans.map((p) => [p.group ?? p.$id, p]));
return [...map.values()];
});
function pluralize(count: number, singular: string, plural?: string) {
if (count === 1) return singular;
return plural ?? `${singular}s`;
}
</script>
<Card.Base>
<Layout.Stack>
<Tabs.Root stretch let:root>
{#each visibleTiers as tier}
{#each uniquePlans as plan}
<Tabs.Item.Button
{root}
active={selectedTab === tier}
on:click={() => (selectedTab = tier)}>
{tierToPlan(tier).name}
active={selectedTab === plan.$id}
on:click={() => (selectedTab = plan.$id)}>
{plan.name}
</Tabs.Item.Button>
{/each}
</Tabs.Root>
<Typography.Text variant="m-600">{plan.name} plan</Typography.Text>
{#if selectedTab === BillingPlan.FREE}
{#if downgrade}
<ul class="u-margin-block-start-8 list u-gap-4 u-small">
<li class="list-item u-gap-4 u-cross-center">
<span class="icon-arrow-down u-color-text-danger" aria-hidden="true"></span>
<span class="text">
Limited to {plan.databases} Database, {plan.buckets} Buckets, {plan.functions}
Functions per project
</span>
</li>
<li class="list-item u-gap-4 u-cross-center">
<span class="icon-arrow-down u-color-text-danger" aria-hidden="true"></span>
<span class="text"> Limited to 1 organization member </span>
</li>
<li class="list-item u-gap-4 u-cross-center">
<span class="icon-arrow-down u-color-text-danger" aria-hidden="true"></span>
<span class="text">
{plan.bandwidth}GB bandwidth
</span>
</li>
<li class="list-item u-gap-4 u-cross-center">
<span class="icon-arrow-down u-color-text-danger" aria-hidden="true"></span>
<span class="text">
{plan.storage}GB storage
</span>
</li>
<li class="list-item u-gap-4 u-cross-center">
<span class="icon-arrow-down u-color-text-danger" aria-hidden="true"></span>
<span class="text">
{formatNum(plan.executions)} executions
</span>
</li>
</ul>
{:else}
<ul class="un-order-list">
<li>
Limited to {plan.databases} Database, {plan.buckets} Buckets, {plan.functions}
Functions per project
</li>
<li>Limited to 1 organization member</li>
<li>
Limited to {plan.bandwidth}GB bandwidth
</li>
<li>
Limited to {plan.storage}GB storage
</li>
<li>
Limited to {formatNum(plan.executions)} executions
</li>
</ul>
{/if}
{:else if selectedTab === BillingPlan.PRO}
<Typography.Text>Everything in the Free plan, plus:</Typography.Text>
<ul class="un-order-list">
<li>Unlimited databases, buckets, functions</li>
<li>Unlimited seats</li>
<li>{plan.bandwidth}GB bandwidth</li>
<li>{plan.storage}GB storage</li>
<li>{formatNum(plan.executions)} executions</li>
<li>Email support</li>
</ul>
{:else if selectedTab === BillingPlan.SCALE}
<Typography.Text>Everything in the Pro plan, plus:</Typography.Text>
<ul class="un-order-list">
<li>Unlimited seats</li>
<li>Organization roles</li>
<li>SOC-2, HIPAA compliance</li>
<li>SSO <span class="inline-tag">Coming soon</span></li>
<li>Priority support</li>
</ul>
{/if}
<Typography.Text variant="m-600">{currentPlan.name} plan</Typography.Text>
{@render appwritePlanView()}
</Layout.Stack>
</Card.Base>
{#snippet appwritePlanView()}
{#if planHasGroup(selectedTab, BillingPlanGroup.Starter)}
{#if downgrade}
<ul class="u-margin-block-start-8 list u-gap-4 u-small">
<li class="list-item u-gap-4 u-cross-center">
<span class="icon-arrow-down u-color-text-danger" aria-hidden="true"></span>
<span class="text">
Limited to {currentPlan.databases}
{pluralize(currentPlan.databases, 'Database')}, {currentPlan.buckets}
{pluralize(currentPlan.buckets, 'Bucket')}, {currentPlan.functions}
{pluralize(currentPlan.functions, 'Function')} per project
</span>
</li>
<li class="list-item u-gap-4 u-cross-center">
<span class="icon-arrow-down u-color-text-danger" aria-hidden="true"></span>
<span class="text"> Limited to 1 organization member </span>
</li>
<li class="list-item u-gap-4 u-cross-center">
<span class="icon-arrow-down u-color-text-danger" aria-hidden="true"></span>
<span class="text">
{currentPlan.bandwidth}GB bandwidth
</span>
</li>
<li class="list-item u-gap-4 u-cross-center">
<span class="icon-arrow-down u-color-text-danger" aria-hidden="true"></span>
<span class="text">
{currentPlan.storage}GB storage
</span>
</li>
<li class="list-item u-gap-4 u-cross-center">
<span class="icon-arrow-down u-color-text-danger" aria-hidden="true"></span>
<span class="text">
{formatNum(currentPlan.executions)} executions
</span>
</li>
</ul>
{:else}
<ul class="un-order-list">
<li>
Limited to {currentPlan.databases}
{pluralize(currentPlan.databases, 'Database')}, {currentPlan.buckets}
{pluralize(currentPlan.buckets, 'Bucket')}, {currentPlan.functions}
{pluralize(currentPlan.functions, 'Function')} per project
</li>
<li>Limited to 1 organization member</li>
<li>
Limited to {currentPlan.bandwidth}GB bandwidth
</li>
<li>
Limited to {currentPlan.storage}GB storage
</li>
<li>
Limited to {formatNum(currentPlan.executions)} executions
</li>
</ul>
{/if}
{:else if planHasGroup(selectedTab, BillingPlanGroup.Pro)}
<Typography.Text>Everything in the Free plan, plus:</Typography.Text>
<ul class="un-order-list">
<li>Unlimited databases, buckets, functions</li>
<li>Unlimited seats</li>
<li>{currentPlan.bandwidth}GB bandwidth</li>
<li>{currentPlan.storage}GB storage</li>
<li>{formatNum(currentPlan.executions)} executions</li>
<li>Email support</li>
</ul>
{:else if planHasGroup(selectedTab, BillingPlanGroup.Scale)}
<Typography.Text>Everything in the Pro plan, plus:</Typography.Text>
<ul class="un-order-list">
<li>Unlimited seats</li>
<li>Organization roles</li>
<li>SOC-2, HIPAA compliance</li>
<li>SSO <span class="inline-tag">Coming soon</span></li>
<li>Priority support</li>
</ul>
{/if}
{/snippet}
@@ -1,131 +0,0 @@
<script lang="ts">
import {
calculateExcess,
plansInfo,
tierToPlan,
getServiceLimit,
type Tier
} from '$lib/stores/billing';
import { organization } from '$lib/stores/organization';
import { toLocaleDate } from '$lib/helpers/date';
import { humanFileSize } from '$lib/helpers/sizeConvertion';
import { abbreviateNumber } from '$lib/helpers/numbers';
import { formatNum } from '$lib/helpers/string';
import { onMount } from 'svelte';
import { sdk } from '$lib/stores/sdk';
import { BillingPlan } from '$lib/constants';
import { Alert, Icon, Table, Tooltip } from '@appwrite.io/pink-svelte';
import { IconInfo } from '@appwrite.io/pink-icons-svelte';
import type { AggregationTeam } from '$lib/sdk/billing';
export let tier: Tier;
const plan = $plansInfo?.get(tier);
let excess: {
bandwidth?: number;
storage?: number;
users?: number;
executions?: number;
members?: number;
} = null;
let aggregation: AggregationTeam = null;
let showExcess = false;
onMount(async () => {
aggregation = await sdk.forConsole.billing.getAggregation(
$organization.$id,
$organization.billingAggregationId
);
excess = calculateExcess(aggregation, plan);
showExcess = Object.values(excess).some((value) => value > 0);
});
</script>
{#if showExcess}
<Alert.Inline
status="error"
title={` Your organization will switch to ${tierToPlan(BillingPlan.FREE).name} plan on ${toLocaleDate(
$organization.billingNextInvoiceDate
)}`}>
You will retain access to {tierToPlan($organization.billingPlan).name} plan features until your
billing period ends. After that,
{#if excess?.members > 0}<span class="u-bold">
all team members except the owner will be removed,</span>
{:else}
<span class="u-bold">your organization will be limited to Free plan resources,</span>
{/if} and service disruptions may occur if usage exceeds Free plan limits.
</Alert.Inline>
<Table.Root columns={3} let:root>
<svelte:fragment slot="header" let:root>
<Table.Header.Cell {root}>Resource</Table.Header.Cell>
<Table.Header.Cell {root}>Free limit</Table.Header.Cell>
<Table.Header.Cell {root}>
Excess usage <Tooltip maxWidth="fit-content"
><Icon icon={IconInfo} />
<span slot="tooltip">Metrics are estimates updated every 24 hours</span>
</Tooltip>
</Table.Header.Cell>
</svelte:fragment>
{#if excess?.members}
<Table.Row.Base {root}>
<Table.Cell {root}>Organization members</Table.Cell>
<Table.Cell {root}>{getServiceLimit('members', tier)} members</Table.Cell>
<Table.Cell {root}>
<p class="u-color-text-danger u-flex u-cross-center u-gap-4">
<span class="icon-arrow-up"></span>
{excess?.members} members
</p>
</Table.Cell>
</Table.Row.Base>
{/if}
{#if excess?.storage}
<Table.Row.Base {root}>
<Table.Cell {root}>Storage</Table.Cell>
<Table.Cell {root}>{plan.storage} GB</Table.Cell>
<Table.Cell {root}>
<p class="u-color-text-danger">
<span class="icon-arrow-up"></span>
{humanFileSize(excess?.storage).value}
{humanFileSize(excess?.storage).unit}
</p>
</Table.Cell>
</Table.Row.Base>
{/if}
{#if excess?.executions}
<Table.Row.Base {root}>
<Table.Cell {root}>Function executions</Table.Cell>
<Table.Cell {root}>
{abbreviateNumber(plan.executions)} executions
</Table.Cell>
<Table.Cell {root}>
<p class="u-color-text-danger">
<span class="icon-arrow-up"></span>
<span
title={excess?.executions
? excess.executions.toString()
: 'executions'}>
{formatNum(excess?.executions)} executions
</span>
</p>
</Table.Cell>
</Table.Row.Base>
{/if}
{#if excess?.users}
<Table.Row.Base {root}>
<Table.Cell {root}>Users</Table.Cell>
<Table.Cell {root}>
{abbreviateNumber(plan.users)} users
</Table.Cell>
<Table.Cell column="usage" {root}>
<p class="u-color-text-danger">
<span class="icon-arrow-up"></span>
<span title={excess?.users ? excess.users.toString() : 'users'}>
{formatNum(excess?.users)} users
</span>
</p>
</Table.Cell>
</Table.Row.Base>
{/if}
</Table.Root>
{/if}
+36 -22
View File
@@ -1,41 +1,55 @@
<script lang="ts">
import { BillingPlan } from '$lib/constants';
import { formatCurrency } from '$lib/helpers/numbers';
import { currentPlan, organization } from '$lib/stores/organization';
import { Badge, Layout, Tooltip, Typography } from '@appwrite.io/pink-svelte';
import { LabelCard } from '..';
import type { Plan } from '$lib/sdk/billing';
import { page } from '$app/state';
import { formatCurrency } from '$lib/helpers/numbers';
import { billingIdToPlan } from '$lib/stores/billing';
import { currentPlan, organization } from '$lib/stores/organization';
import { BillingPlanGroup, type Models } from '@appwrite.io/console';
import { Badge, Layout, Tooltip, Typography } from '@appwrite.io/pink-svelte';
export let billingPlan: BillingPlan;
export let isNewOrg = false;
export let selfService = true;
export let anyOrgFree = false;
let {
isNewOrg = false,
selfService = true,
anyOrgFree = false,
selectedBillingPlan = $bindable()
}: {
isNewOrg?: boolean;
selfService?: boolean;
anyOrgFree?: boolean;
selectedBillingPlan: Models.BillingPlan;
} = $props();
$: plans = Object.values(page.data.plans.plans) as Plan[];
$: currentPlanInList = plans.some((plan) => plan.$id === $currentPlan?.$id);
let selectedPlan = $state(selectedBillingPlan.$id);
// experiment to remove scale plan temporarily
$: plansWithoutScale = plans.filter((plan) => plan.$id != BillingPlan.SCALE);
const visiblePlans = $derived(Object.values(page.data.plans.plans) as Models.BillingPlan[]);
const currentPlanInList = $derived(visiblePlans.some((plan) => plan.$id === $currentPlan?.$id));
function shouldShowTooltip(plan: Plan) {
if (plan.$id !== BillingPlan.FREE) return true;
function shouldShowTooltip(plan: Models.BillingPlan) {
if (plan.group !== BillingPlanGroup.Starter) return true;
else return !anyOrgFree;
}
function shouldDisable(plan: Models.BillingPlan) {
return plan.group === BillingPlanGroup.Starter && anyOrgFree;
}
$effect(() => {
selectedBillingPlan = billingIdToPlan(selectedPlan);
});
</script>
<Layout.Stack>
{#each plansWithoutScale as plan}
{#each visiblePlans as plan}
<Tooltip disabled={shouldShowTooltip(plan)} maxWidth="fit-content">
<LabelCard
name="plan"
bind:group={billingPlan}
disabled={!selfService || (plan.$id === BillingPlan.FREE && anyOrgFree)}
tooltipShow={plan.$id === BillingPlan.FREE && anyOrgFree}
bind:group={selectedPlan}
disabled={!selfService || shouldDisable(plan)}
tooltipShow={shouldDisable(plan)}
value={plan.$id}
title={plan.name}>
<svelte:fragment slot="action">
{#if $organization?.billingPlan === plan.$id && !isNewOrg}
{#if $organization?.billingPlanId === plan.$id && !isNewOrg}
<Badge variant="secondary" size="xs" content="Current plan" />
{/if}
</svelte:fragment>
@@ -57,11 +71,11 @@
{#if $currentPlan && !currentPlanInList}
<LabelCard
name="plan"
bind:group={billingPlan}
bind:group={selectedPlan}
value={$currentPlan.$id}
title={$currentPlan.name}>
<svelte:fragment slot="action">
{#if $organization?.billingPlan === $currentPlan.$id && !isNewOrg}
{#if $organization?.billingPlanId === $currentPlan.$id && !isNewOrg}
<Badge variant="secondary" size="xs" content="Current plan" />
{/if}
</svelte:fragment>
@@ -1,6 +1,5 @@
<script lang="ts">
import { Button, InputText } from '$lib/elements/forms';
import type { PaymentList, PaymentMethodData } from '$lib/sdk/billing';
import { hasStripePublicKey, isCloud } from '$lib/system';
import { onMount } from 'svelte';
import PaymentModal from './paymentModal.svelte';
@@ -10,15 +9,16 @@
import InputSelect from '$lib/elements/forms/inputSelect.svelte';
import { invalidate } from '$app/navigation';
import { Dependencies } from '$lib/constants';
import type { Models } from '@appwrite.io/console';
export let value: string;
export let taxId = '';
export let methods: PaymentList;
export let value: string;
export let methods: Models.PaymentMethodList;
let showTaxId = false;
let showPaymentModal = false;
async function cardSaved(card: PaymentMethodData) {
async function cardSaved(card: Models.PaymentMethod) {
value = card.$id;
if (value) {
@@ -1,50 +0,0 @@
<script lang="ts">
import { BillingPlan } from '$lib/constants';
import { formatCurrency } from '$lib/helpers/numbers';
import { plansInfo } from '$lib/stores/billing';
import { organization } from '$lib/stores/organization';
import { LabelCard } from '..';
export let billingPlan: string;
export let anyOrgFree = false;
export let isNewOrg = false;
let classes: string = '';
export { classes as class };
</script>
{#if billingPlan}
<ul class="u-flex u-flex-vertical u-gap-16 u-margin-block-start-8 {classes}">
{#each $plansInfo.values() as plan}
<li>
<LabelCard
name="plan"
bind:group={billingPlan}
disabled={(plan.$id === BillingPlan.FREE && anyOrgFree) || !plan.selfService}
value={plan.$id}
tooltipShow={plan.$id === BillingPlan.FREE && anyOrgFree}
tooltipText={plan.$id === BillingPlan.FREE
? 'You are limited to 1 Free organization per account.'
: ''}>
<svelte:fragment slot="custom" let:disabled>
<div
class="u-flex u-flex-vertical u-gap-4 u-width-full-line"
class:u-opacity-50={disabled}>
<h4 class="body-text-2 u-bold">
{plan.name}
{#if $organization?.billingPlan === plan.$id && !isNewOrg}
<span class="inline-tag">Current plan</span>
{/if}
</h4>
<p class="u-color-text-offline u-small">
{plan.desc}
</p>
<p>
{formatCurrency(plan?.price ?? 0)}
</p>
</div>
</svelte:fragment>
</LabelCard>
</li>
{/each}
</ul>
{/if}
@@ -5,18 +5,18 @@
import { Dependencies } from '$lib/constants';
import { addNotification } from '$lib/stores/notifications';
import { sdk } from '$lib/stores/sdk';
import type { PaymentMethodData } from '$lib/sdk/billing';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { states } from './state';
import { Alert, Card, Layout, Typography } from '@appwrite.io/pink-svelte';
import { CreditCardBrandImage } from '../index.js';
import type { Models } from '@appwrite.io/console';
let {
show = $bindable(false),
paymentMethod
}: {
show: boolean;
paymentMethod: PaymentMethodData;
paymentMethod: Models.PaymentMethod;
} = $props();
let selectedState = $state('');
@@ -40,12 +40,12 @@
error = null;
try {
await sdk.forConsole.billing.setPaymentMethod(
paymentMethod.$id,
paymentMethod.providerMethodId,
paymentMethod.name,
selectedState
);
await sdk.forConsole.account.updatePaymentMethod({
paymentMethodId: paymentMethod.$id,
expiryMonth: paymentMethod.expiryMonth,
expiryYear: paymentMethod.expiryYear,
state: selectedState
});
trackEvent(Submit.PaymentMethodUpdate);
await invalidate(Dependencies.PAYMENT_METHODS);
addNotification({
@@ -63,10 +63,10 @@
</script>
<Modal
dismissible={false}
bind:error
onSubmit={handleSubmit}
bind:show
dismissible={false}
onSubmit={handleSubmit}
title="Update payment method state">
<Layout.Stack direction="column" gap="m">
<Typography.Text>
+11 -15
View File
@@ -2,22 +2,18 @@
import { Modal } from '$lib/components';
import { Button } from '$lib/elements/forms';
import { toLocaleDate } from '$lib/helpers/date';
import { type Organization } from '$lib/stores/organization';
import { plansInfo } from '$lib/stores/billing';
import { abbreviateNumber, formatCurrency, isWithinSafeRange } from '$lib/helpers/numbers';
import { BillingPlan } from '$lib/constants';
import { Table, Typography } from '@appwrite.io/pink-svelte';
import { BillingPlanGroup, type Models } from '@appwrite.io/console';
import { abbreviateNumber, formatCurrency, isWithinSafeRange } from '$lib/helpers/numbers';
export let show = false;
export let org: Organization;
$: plan = $plansInfo?.get(org.billingPlan);
export let org: Models.Organization;
$: nextDate = org?.name
? new Date(new Date().getFullYear(), new Date().getMonth() + 1, 1).toString()
: org?.billingNextInvoiceDate;
$: isFree = org.billingPlan === BillingPlan.FREE;
$: isFree = org.billingPlanDetails.group === BillingPlanGroup.Starter;
// equal or above means unlimited!
const getCorrectSeatsCountValue = (count: number): string | number => {
@@ -27,22 +23,22 @@
};
function getPlanLimit(key: string): number | false {
return plan[key] || false;
return org.billingPlanDetails[key] || false;
}
</script>
<Modal bind:show title="Usage rates">
{#if isFree}
<Typography.Text>
Usage on the {$plansInfo?.get(BillingPlan.FREE).name} plan is limited for the following resources.
Next billing period: {toLocaleDate(nextDate)}.
Usage on the {org.billingPlanDetails.name} plan is limited for the following resources. Next
billing period: {toLocaleDate(nextDate)}.
</Typography.Text>
{:else if org.billingPlan === BillingPlan.PRO}
{:else if org.billingPlanDetails.group === BillingPlanGroup.Pro}
<Typography.Text>
Usage on the Pro plan will be charged at the end of each billing period at the following
rates. Next billing period: {toLocaleDate(nextDate)}.
</Typography.Text>
{:else if org.billingPlan === BillingPlan.SCALE}
{:else if org.billingPlanDetails.group === BillingPlanGroup.Scale}
<Typography.Text>
Usage on the Scale plan will be charged at the end of each billing period at the
following rates. Next billing period: {toLocaleDate(nextDate)}.
@@ -56,7 +52,7 @@
<Table.Header.Cell column="limit" {root}>Limit</Table.Header.Cell>
<Table.Header.Cell column="rate" {root}>Rate</Table.Header.Cell>
</svelte:fragment>
{#each Object.values(plan.addons) as addon}
{#each Object.values(org.billingPlanDetails.addons) as addon}
<Table.Row.Base {root}>
<Table.Cell column="resource" {root}>{addon.invoiceDesc}</Table.Cell>
<Table.Cell column="limit" {root}>
@@ -69,7 +65,7 @@
{/if}
</Table.Row.Base>
{/each}
{#each Object.entries(plan.usage) as [key, usage]}
{#each Object.entries(org.billingPlanDetails.usage) as [key, usage]}
{@const limit = getPlanLimit(key)}
{@const show = limit !== false}
{#if show}
@@ -1,26 +1,28 @@
<script lang="ts">
import { Modal } from '$lib/components';
import { Button, InputText } from '$lib/elements/forms';
import type { Coupon } from '$lib/sdk/billing';
import { addNotification } from '$lib/stores/notifications';
import { sdk } from '$lib/stores/sdk';
import { createEventDispatcher } from 'svelte';
import type { Models } from '@appwrite.io/console';
export let show = false;
export let isNewOrg = false;
export let couponData: Partial<Coupon> = {
export let couponData: Partial<Models.Coupon> = {
code: null,
status: null,
credits: null
};
let error: string = null;
let coupon: string = '';
const dispatch = createEventDispatcher();
async function addCoupon() {
try {
// const response = await sdk.forConsole.billing.getCoupon(coupon);
const response = await sdk.forConsole.billing.getCouponAccount(coupon); //TODO: double check that this is the correct method
const response = await sdk.forConsole.account.getCoupon({
couponId: coupon
});
if (response.onlyNewOrgs && !isNewOrg) {
show = false;
+5 -20
View File
@@ -10,8 +10,7 @@
} from '$lib/stores/bottom-alerts';
import { onMount } from 'svelte';
import { organization } from '$lib/stores/organization';
import { BillingPlan } from '$lib/constants';
import { upgradeURL } from '$lib/stores/billing';
import { canUpgrade, getChangePlanUrl } from '$lib/stores/billing';
import { addBottomModalAlerts } from '$routes/(console)/bottomAlerts';
import { project } from '$routes/(console)/project-[region]-[project]/store';
import { page } from '$app/state';
@@ -142,13 +141,14 @@
// the button component cannot have both href and on:click!
function triggerWindowLink(alert: BottomModalAlertItem, event?: string) {
const alertAction = alert.cta;
const shouldShowUpgrade = showUpgrade();
const shouldShowUpgrade = canUpgrade($organization?.billingPlanDetails);
// for correct event tracking after removal
const currentModalId = currentModalAlert.id;
const organizationId = $project.teamId ?? $organization.$id;
const url = shouldShowUpgrade
? $upgradeURL
? getChangePlanUrl(organizationId)
: alertAction.link({
organization: $organization,
project: $project
@@ -170,26 +170,11 @@
});
}
function showUpgrade() {
const plan = currentModalAlert.plan;
const organizationPlan = $organization?.billingPlan;
switch (plan) {
case 'free':
return false;
case 'pro':
return organizationPlan === BillingPlan.FREE;
case 'scale':
return (
organizationPlan === BillingPlan.FREE || organizationPlan === BillingPlan.PRO
);
}
}
onMount(addBottomModalAlerts);
</script>
{#if !isOnOnboarding && filteredModalAlerts.length > 0 && currentModalAlert}
{@const shouldShowUpgrade = showUpgrade()}
{@const shouldShowUpgrade = canUpgrade($organization?.billingPlanDetails)}
<div class="main-alert-wrapper is-not-mobile">
<div class="alert-container">
<article class="card">
+4 -3
View File
@@ -22,10 +22,9 @@
import { base } from '$app/paths';
import { currentPlan, newOrgModal, organization } from '$lib/stores/organization';
import { Click, trackEvent } from '$lib/actions/analytics';
import { ID, type Models, Query } from '@appwrite.io/console';
import { BillingPlanGroup, ID, type Models, Query } from '@appwrite.io/console';
import { sdk } from '$lib/stores/sdk';
import { page } from '$app/state';
import { BillingPlan } from '$lib/constants';
import { onDestroy } from 'svelte';
type Organization = {
@@ -254,7 +253,9 @@
let badgeType: 'success' | undefined;
$: badgeType =
$organization && $organization.billingPlan !== BillingPlan.FREE ? 'success' : undefined;
$organization && $organization?.billingPlanDetails?.group !== BillingPlanGroup.Starter
? 'success'
: undefined;
</script>
<svelte:window on:resize={onResize} />
+3 -3
View File
@@ -8,11 +8,11 @@
import { isSmallViewport } from '$lib/stores/viewport';
import { getServiceLimit, type PlanServices } from '$lib/stores/billing';
export let disableEmpty = true;
export let offset = 0;
export let total = 0;
export let event: string = null;
export let offset = 0;
export let service = '';
export let disableEmpty = true;
export let event: string = null;
export let serviceId: PlanServices = service as PlanServices;
$: planLimit = getServiceLimit(serviceId) || Infinity;
+13 -3
View File
@@ -1,9 +1,19 @@
<script lang="ts">
import { Button } from '$lib/elements/forms';
import { upgradeURL } from '$lib/stores/billing';
import { getChangePlanUrl } from '$lib/stores/billing';
import { Click, trackEvent } from '$lib/actions/analytics';
import { organization } from '$lib/stores/organization';
import { project } from '$routes/(console)/project-[region]-[project]/store';
export let service: string;
const {
service
}: {
service: string;
} = $props();
const organizationId: string | null = $derived.by(() => {
return $project.teamId ?? $organization.$id;
});
</script>
<article class="card u-grid u-cross-center u-width-full-line">
@@ -11,7 +21,7 @@
<p class="text u-text-center">Upgrade your plan to add more {service}</p>
<Button
secondary
href={$upgradeURL}
href={getChangePlanUrl(organizationId)}
on:click={() => {
trackEvent(Click.OrganizationClickUpgrade, { source: 'card_plan_limit' });
}}>Change plan</Button>
+4 -4
View File
@@ -1,12 +1,12 @@
<script lang="ts">
import type { PaymentMethodData } from '$lib/sdk/billing';
import { Badge, Layout, Link, Popover, Table } from '@appwrite.io/pink-svelte';
import CreditCardBrandImage from './creditCardBrandImage.svelte';
import type { Models } from '@appwrite.io/console';
import type { TableRootProp } from '$lib/helpers/types';
import CreditCardBrandImage from './creditCardBrandImage.svelte';
import { Badge, Layout, Link, Popover, Table } from '@appwrite.io/pink-svelte';
export let root: TableRootProp;
export let paymentMethod: PaymentMethodData;
export let isBackup: boolean = false;
export let paymentMethod: Models.PaymentMethod;
</script>
<Table.Cell column="cc" {root}>
@@ -1,8 +1,8 @@
<script lang="ts">
import { isSmallViewport } from '$lib/stores/viewport';
import { organization } from '$lib/stores/organization';
import { getNextTier, tierToPlan } from '$lib/stores/billing';
import { Card, Layout, Typography } from '@appwrite.io/pink-svelte';
import { getNextTierBillingPlan } from '$lib/stores/billing';
export let source = 'empty_state_card';
export let responsive = false;
@@ -11,6 +11,8 @@
let direction: 'column' | 'row' | 'row-reverse' | 'column-reverse' = 'row';
$: direction = responsive ? ($isSmallViewport ? 'column' : 'row') : 'row';
$: nextTierBillingPlan = getNextTierBillingPlan($organization.billingPlanId);
</script>
<Card.Base variant="secondary" padding="s" radius="s">
@@ -24,7 +26,7 @@
<Layout.Stack gap="xxs">
<Typography.Text variant="m-600"><slot name="title" /></Typography.Text>
<Typography.Text>
<slot nextTier={tierToPlan(getNextTier($organization.billingPlan)).name} />
<slot nextTier={nextTierBillingPlan?.name} />
</Typography.Text>
</Layout.Stack>
<slot name="cta" {source} />
+1 -1
View File
@@ -25,7 +25,7 @@
page.url.href,
$user.name,
$user.email,
$organization?.billingPlan,
$organization?.billingPlanId,
$feedbackData.value,
$organization?.$id,
$project?.$id,
@@ -1,7 +1,6 @@
<script lang="ts">
import { Button } from '$lib/elements/forms';
import { getServiceLimit, plansInfo } from '$lib/stores/billing';
import { BillingPlan } from '$lib/constants';
import { getBasePlanFromGroup, getServiceLimit } from '$lib/stores/billing';
import { Click, trackEvent } from '$lib/actions/analytics';
import { Badge, Icon, Layout, Table, Typography, Tooltip } from '@appwrite.io/pink-svelte';
import { IconArrowUp, IconInfo } from '@appwrite.io/pink-icons-svelte';
@@ -11,15 +10,15 @@
import { Alert } from '@appwrite.io/pink-svelte';
import { addNotification } from '$lib/stores/notifications';
import { toLocaleDate, toLocaleDateTime } from '$lib/helpers/date';
import { organization, type Organization } from '$lib/stores/organization';
import type { Models } from '@appwrite.io/console';
import { organization } from '$lib/stores/organization';
import { BillingPlanGroup, type Models } from '@appwrite.io/console';
// Props
type Props = {
organization: Organization;
storageUsage?: number;
projects?: Models.Project[];
members?: Models.Membership[];
storageUsage?: number;
organization: Models.Organization;
};
const { projects = [], members = [], storageUsage = 0 }: Props = $props();
@@ -29,11 +28,13 @@
let error = $state<string | null>(null);
let showSelectionReminder = $state(false);
const baseFreePlan = getBasePlanFromGroup(BillingPlanGroup.Starter);
// Derived state using runes
let freePlanLimits = $derived({
projects: $plansInfo?.get(BillingPlan.FREE)?.projects,
members: getServiceLimit('members', BillingPlan.FREE),
storage: getServiceLimit('storage', BillingPlan.FREE)
projects: baseFreePlan?.projects,
members: getServiceLimit('members', null, baseFreePlan),
storage: getServiceLimit('storage', null, baseFreePlan)
});
// When preparing to downgrade to Free, enforce Free plan limit locally (2)
+6 -5
View File
@@ -1,9 +1,8 @@
<script>
import Base from './base.svelte';
import { upgradeURL } from '$lib/stores/billing';
import { isCloud } from '$lib/system';
import { getChangePlanUrl } from '$lib/stores/billing';
import { organization } from '$lib/stores/organization';
import { BillingPlan } from '$lib/constants';
import Button from '$lib/elements/forms/button.svelte';
import { Badge, Layout, Link, Typography } from '@appwrite.io/pink-svelte';
</script>
@@ -11,7 +10,7 @@
<Base>
<Layout.Stack gap="s">
{#if isCloud}
{#if $organization?.billingPlan !== BillingPlan.FREE}
{#if $organization?.billingPlanDetails.supportsOrganizationRoles}
<Typography.Text variant="m-600">Roles</Typography.Text>
<Typography.Text>Owner, Developer, Editor, Analyst and Billing.</Typography.Text>
<Typography.Text>
@@ -39,7 +38,8 @@
text
external
href="https://appwrite.io/docs/advanced/platform/roles">Learn more</Button>
<Button size="s" secondary external href={$upgradeURL}>Upgrade plan</Button>
<Button size="s" secondary external href={getChangePlanUrl($organization?.$id)}
>Upgrade plan</Button>
</p>
{/if}
{:else}
@@ -59,7 +59,8 @@
text
external
href="https://appwrite.io/docs/advanced/platform/roles">Learn more</Button>
<Button size="s" secondary external href={$upgradeURL}>Upgrade to Cloud</Button>
<Button size="s" secondary external href={getChangePlanUrl($organization?.$id)}
>Upgrade to Cloud</Button>
</p>
{/if}
</Layout.Stack>
+4 -4
View File
@@ -5,13 +5,13 @@
import { isSupportOnline, showSupportModal } from '$routes/(console)/wizard/support/store';
import { Click, trackEvent } from '$lib/actions/analytics';
import { localeShortTimezoneName, utcHourToLocaleHour } from '$lib/helpers/date';
import { plansInfo } from '$lib/stores/billing';
import { Card } from '$lib/components/index';
import { app } from '$lib/stores/app';
import { currentPlan, type Organization, organizationList } from '$lib/stores/organization';
import { currentPlan, organizationList } from '$lib/stores/organization';
import { isCloud } from '$lib/system';
import { Typography } from '@appwrite.io/pink-svelte';
import { base } from '$app/paths';
import type { Models } from '@appwrite.io/console';
export let show = false;
@@ -20,12 +20,12 @@
$: hasPremiumSupport = $currentPlan?.premiumSupport ?? allOrgsHavePremiumSupport ?? false;
$: allOrgsHavePremiumSupport = $organizationList.teams.every(
(team) => $plansInfo.get((team as Organization).billingPlan)?.premiumSupport
(team) => (team as Models.Organization).billingPlanDetails.premiumSupport
);
// there can only be one free organization
$: freeOrganization = $organizationList.teams.find(
(team) => !$plansInfo.get((team as Organization).billingPlan)?.premiumSupport
(team) => !(team as Models.Organization).billingPlanDetails.premiumSupport
);
$: upgradeURL = `${base}/organization-${freeOrganization?.$id}/change-plan`;
-11
View File
@@ -609,17 +609,6 @@ export const eventServices: Array<EventService> = [
}
];
export enum BillingPlan {
FREE = 'tier-0',
PRO = 'tier-1',
SCALE = 'tier-2',
GITHUB_EDUCATION = 'auto-1',
CUSTOM = 'cont-1',
ENTERPRISE = 'ent-1'
}
export const BASE_BILLING_PLANS: string[] = [BillingPlan.FREE, BillingPlan.PRO, BillingPlan.SCALE];
export const feedbackDowngradeOptions = [
{
value: 'availableFeatures',
-52
View File
@@ -1,52 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { Click, trackEvent } from '$lib/actions/analytics';
import { getServiceLimit, upgradeURL, type PlanServices } from '$lib/stores/billing';
import { isCloud } from '$lib/system';
import { Button } from '../forms';
let tableBody: HTMLDivElement;
export let service: PlanServices = null;
export let name = service;
export let total: number = null;
export let event: string = null;
let columns = 0;
const limit = getServiceLimit(service) || Infinity;
// TODO: refactor this to be a string
const upgradeMethod = () => {
goto($upgradeURL);
};
$: limitReached = limit !== 0 && limit < Infinity && total >= limit;
$: if (tableBody) {
columns = tableBody?.parentNode?.querySelectorAll('.table-thead-col')?.length;
}
</script>
<div class="table-tbody" role="rowgroup" bind:this={tableBody}>
<slot />
</div>
{#if isCloud && limitReached && service}
<tr class="table-row">
<td class="table-col" width="100%" colspan={columns}>
<span class="u-flex u-gap-24 u-main-center u-cross-center">
<slot name="limit" {upgradeMethod} {limit}>
<span class="text">Upgrade your plan to add {name} to your organization</span>
<Button
secondary
href={$upgradeURL}
on:click={() =>
trackEvent(Click.OrganizationClickUpgrade, {
from: 'button',
source: event ?? 'table_row_limit_reached'
})}>Upgrade plan</Button>
</slot>
</span>
</td>
</tr>
{/if}
-24
View File
@@ -1,24 +0,0 @@
<script lang="ts">
export let title = '';
export let onlyDesktop = false;
export let width: number = null;
export let showOverflow = false;
let className = '';
export { className as class };
export let style = '';
export let right = false;
</script>
<div
{style}
style:--p-col-width={width?.toString() ?? ''}
class="table-col {className}"
class:u-overflow-visible={showOverflow}
class:is-only-desktop={onlyDesktop}
class:u-flex={right}
class:u-main-end={right}
data-title={title}
role="cell"
data-private>
<slot />
</div>
-13
View File
@@ -1,13 +0,0 @@
<script lang="ts">
import Cell from './cell.svelte';
export let src: string;
export let alt: string;
export let onlyDesktop = false;
export let width = 30;
</script>
<Cell {onlyDesktop} {width}>
<div class="image">
<img class="avatar" {width} height={width} {src} {alt} />
</div>
</Cell>
-26
View File
@@ -1,26 +0,0 @@
<script lang="ts">
export let title = '';
export let onlyDesktop = false;
export let width: number = null;
export let showOverflow = false;
let className = '';
export { className as class };
export let style = '';
export let right = false;
</script>
<button
{style}
style:--p-col-width={width?.toString() ?? ''}
class="table-col {className}"
class:u-overflow-visible={showOverflow}
class:is-only-desktop={onlyDesktop}
class:u-flex={right}
class:u-main-end={right}
data-title={title}
role="cell"
on:click
on:keypress
data-private>
<slot />
</button>
-35
View File
@@ -1,35 +0,0 @@
<script lang="ts">
import { toggle } from '$lib/helpers/array';
import { isHTMLInputElement } from '$lib/helpers/types';
import { TableCellButton } from '.';
import { InputCheckbox } from '../forms';
export let id: string;
export let selectedIds: string[] = [];
export let disabled: boolean = false;
let el: HTMLInputElement;
const handleClick = (e: Event) => {
// Prevent the link from being followed
e.preventDefault();
e.stopPropagation();
if (!isHTMLInputElement(el)) return;
selectedIds = toggle(selectedIds, id);
// Hack to make sure the checkbox is checked, independent of the
// preventDefault() call above
window.setTimeout(() => {
el.checked = selectedIds.includes(id);
});
};
</script>
<TableCellButton on:click={handleClick}>
<InputCheckbox
bind:element={el}
id="select-{id}"
checked={selectedIds.includes(id)}
{disabled}
on:click={handleClick} />
</TableCellButton>
-17
View File
@@ -1,17 +0,0 @@
<script lang="ts">
export let width: number = null;
export let onlyDesktop = false;
export let eyebrow = true;
export let style = '';
</script>
<div
style:--p-col-width={width?.toString()}
class:is-only-desktop={onlyDesktop}
{style}
class="table-thead-col"
role="columnheader">
<span class={eyebrow ? 'eyebrow-heading-3' : 'body-text-2 u-bold'}>
<slot />
</span>
</div>
@@ -1,32 +0,0 @@
<script lang="ts">
import { isHTMLInputElement } from '$lib/helpers/types';
import { TableCellHead } from '.';
import { InputCheckbox } from '../forms';
export let selected: string[] = [];
export let pageItemsIds: string[] = [];
function handleClick(e: CustomEvent) {
if (!isHTMLInputElement(e.target)) return;
if (e.target.checked) {
const set = new Set(selected);
pageItemsIds.forEach((id) => set.add(id));
selected = Array.from(set);
} else {
selected = selected.filter((id) => {
return !pageItemsIds.includes(id);
});
}
}
$: someSelected = pageItemsIds.some((id) => selected.includes(id));
$: allSelected = pageItemsIds.every((id) => selected.includes(id));
</script>
<TableCellHead width={10}>
<InputCheckbox
id="select-all"
indeterminate={someSelected && !allSelected}
checked={allSelected}
on:click={handleClick} />
</TableCellHead>
-17
View File
@@ -1,17 +0,0 @@
<script lang="ts">
export let href: string;
export let title: string;
export let external = false;
export let noStyle = false;
</script>
<div class="table-col" data-title={title} role="cell" data-private>
<a
role="button"
tabindex="0"
class:link={!noStyle}
{href}
target={external ? '_blank' : ''}
rel={external ? 'noopener noreferrer' : ''}><slot /></a>
</div>
-16
View File
@@ -1,16 +0,0 @@
<script lang="ts">
import { Trim } from '$lib/components';
import Cell from './cell.svelte';
export let title = '';
export let showOverflow = false;
export let onlyDesktop = false;
export let width: number = null;
export let right = false;
let className = '';
export { className as class };
export let style = '';
</script>
<Cell {title} {showOverflow} {onlyDesktop} {width} {right} class={className} {style}>
<Trim><slot /></Trim>
</Cell>
-5
View File
@@ -1,5 +0,0 @@
<tfoot>
<tr>
<slot />
</tr>
</tfoot>
-5
View File
@@ -1,5 +0,0 @@
<div class="table-thead" role="rowheader">
<div class="table-row" role="row">
<slot />
</div>
</div>
-16
View File
@@ -1,17 +1 @@
export { default as Table } from './table.svelte';
export { default as TableScroll } from './tableScroll.svelte';
export { default as TableList } from './tableList.svelte';
export { default as TableBody } from './body.svelte';
export { default as TableHeader } from './header.svelte';
export { default as TableFooter } from './footer.svelte';
export { default as TableRow } from './row.svelte';
export { default as TableRowLink } from './rowLink.svelte';
export { default as TableRowButton } from './rowButton.svelte';
export { default as TableCell } from './cell.svelte';
export { default as TableCellButton } from './cellButton.svelte';
export { default as TableCellHead } from './cellHead.svelte';
export { default as TableCellHeadCheck } from './cellHeadCheck.svelte';
export { default as TableCellLink } from './cellLink.svelte';
export { default as TableCellAvatar } from './cellAvatar.svelte';
export { default as TableCellText } from './cellText.svelte';
export { default as TableCellCheck } from './cellCheck.svelte';
-3
View File
@@ -1,3 +0,0 @@
<div class="table-row" role="row">
<slot />
</div>
-7
View File
@@ -1,7 +0,0 @@
<script lang="ts">
import { clickOnEnter } from '$lib/helpers/a11y';
</script>
<tr class="table-row" role="button" tabindex="0" on:keyup={clickOnEnter} on:click|preventDefault>
<slot />
</tr>
-7
View File
@@ -1,7 +0,0 @@
<script lang="ts">
export let href: string;
</script>
<a class="table-row" role="row" {href}>
<slot />
</a>
-29
View File
@@ -1,29 +0,0 @@
<script lang="ts">
export let noMargin = false;
export let noStyles = false;
export let style = '';
export let transparent = false;
export let isAutoLayout = false;
export let tag: 'div' | 'table' = 'div';
export let dense = false;
</script>
<svelte:element
this={tag}
class="table is-selected-columns-mobile"
class:is-table-layout-auto={isAutoLayout}
class:u-margin-block-start-32={!noMargin}
class:is-remove-outer-styles={noStyles}
class:is-table-row-medium-size={dense}
{style}
style:--p-table-bg-color={transparent ? 'var(--transparent)' : ''}
role="table"
data-private>
<slot />
</svelte:element>
<style>
:global(.table .button.is-text) {
--p-text-color-default: initial !important;
}
</style>
-9
View File
@@ -1,9 +0,0 @@
<div class="table-with-scroll" data-private>
<div class="table-wrapper">
<div class="table is-remove-outer-styles">
<ul class="table-thead">
<slot />
</ul>
</div>
</div>
</div>
+1 -1
View File
@@ -12,7 +12,7 @@
let isOverflowing = false;
const hasOverflow: Action<HTMLDivElement, unknown> = (node) => {
const hasOverflow: Action<HTMLDivElement, unknown> = (node: Element) => {
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
let overflowing = false;
+2 -2
View File
@@ -1,6 +1,6 @@
import { env } from '$env/dynamic/public';
import type { Account } from './stores/user';
import type { Organization } from './stores/organization';
import type { Models } from '@appwrite.io/console';
// Parse feature flags from env as a string array (exact match only)
const flagsRaw = (env.PUBLIC_CONSOLE_FEATURE_FLAGS ?? '').split(',');
@@ -8,7 +8,7 @@ const flagsRaw = (env.PUBLIC_CONSOLE_FEATURE_FLAGS ?? '').split(',');
// @ts-expect-error: unused method!
function isFlagEnabled(name: string) {
// loose generic to allow safe access while retaining type safety
return <T extends { account?: Account; organization?: Organization }>(data: T) => {
return <T extends { account?: Account; organization?: Models.Organization }>(data: T) => {
const { account, organization } = data;
return !!(
+16
View File
@@ -0,0 +1,16 @@
import type { Models } from '@appwrite.io/console';
export function makePlansMap(
plansArray: Models.BillingPlanList | null
): Map<string, Models.BillingPlan> {
const plansMap = new Map<string, Models.BillingPlan>();
if (!plansArray?.plans.length) return plansMap;
const plans = plansArray.plans;
for (let index = 0; index < plans.length; index++) {
const plan = plans[index];
plansMap.set(plan.$id, plan);
}
return plansMap;
}
+5 -2
View File
@@ -6,12 +6,15 @@ import type { DatabaseType, Field } from '$database/(entity)';
import { coerceToNumber, isWithinSafeRange } from '$lib/helpers/numbers';
export async function generateFields(
project: Models.Project,
project: {
id: string;
region: string;
},
databaseId: string,
tableId: string,
databaseType: DatabaseType
): Promise<Field[]> {
const client = sdk.forProject(project.region, project.$id);
const client = sdk.forProject(project.region, project.id);
switch (databaseType) {
case 'legacy':
+12
View File
@@ -0,0 +1,12 @@
import { IconGithub } from '@appwrite.io/pink-icons-svelte';
/**
* Models.BillingPlan > program
* |_ Models.Program > icon
* |_ icon > string
*
* So we need to map them as needed from Pink icons library
*/
export const IconsMap = {
github: IconGithub
};
+4 -4
View File
@@ -1,17 +1,17 @@
<script lang="ts">
import { BillingPlan } from '$lib/constants';
import { Button } from '$lib/elements/forms';
import { tierToPlan } from '$lib/stores/billing';
import { organization } from '$lib/stores/organization';
import { Tooltip } from '@appwrite.io/pink-svelte';
import { BillingPlanGroup } from '@appwrite.io/console';
export let title: string;
export let tooltipContent =
$organization?.billingPlan === BillingPlan.FREE
$organization?.billingPlanDetails.group === BillingPlanGroup.Starter
? `Upgrade to add more ${title.toLocaleLowerCase()}`
: `You've reached the ${title.toLocaleLowerCase()} limit for the ${
tierToPlan($organization?.billingPlan)?.name
$organization?.billingPlanDetails.name
} plan`;
export let disabled: boolean;
export let buttonText: string;
export let buttonMethod: () => void | Promise<void> = () => {};
+42 -30
View File
@@ -1,6 +1,5 @@
<script lang="ts">
import { DropList } from '$lib/components';
import { BillingPlan } from '$lib/constants';
import { Link } from '$lib/elements';
import { Badge, Icon } from '@appwrite.io/pink-svelte';
import { IconInfo } from '@appwrite.io/pink-icons-svelte';
@@ -11,11 +10,11 @@
getServiceLimit,
readOnly,
showUsageRatesModal,
tierToPlan,
upgradeURL,
type PlanServices
getChangePlanUrl,
type PlanServices,
canUpgrade
} from '$lib/stores/billing';
import { organization } from '$lib/stores/organization';
import { currentPlan, organization } from '$lib/stores/organization';
import { GRACE_PERIOD_OVERRIDE, isCloud } from '$lib/system';
import { createEventDispatcher, onMount } from 'svelte';
import { ContainerButton } from '.';
@@ -38,18 +37,15 @@
let showDropdown = false;
// TODO: remove the default billing limits when backend is updated with billing code
const { bandwidth, documents, storage, users, executions } = $organization?.billingLimits ?? {
const { bandwidth, storage, users, executions } = $organization?.billingLimits ?? {
bandwidth: 1,
documents: 1,
storage: 1,
users: 1,
executions: 1
};
// TODO: @itznotabug - check with @abnegate, what do we do here? this is billing!
const limitedServices = [
{ name: 'bandwidth', value: bandwidth },
{ name: 'documents', value: documents },
{ name: 'storage', value: storage },
{ name: 'users', value: users },
{ name: 'executions', value: executions }
@@ -60,18 +56,20 @@
//TODO: refactor this to be a string
const upgradeMethod = () => {
showDropdown = false;
goto($upgradeURL);
goto(getChangePlanUrl($organization?.$id));
};
const dispatch = createEventDispatcher();
$: tier = tierToPlan($organization?.billingPlan)?.name;
$: planName = $organization?.billingPlanDetails.name;
// these can be organization level limitations as well.
// we need to migrate this sometime later, but soon!
$: hasProjectLimitation =
checkForProjectLimitation(serviceId) && $organization?.billingPlan === BillingPlan.FREE;
$: hasProjectLimitation = checkForProjectLimitation($organization?.billingPlanId, serviceId);
$: hasUsageFees = hasProjectLimitation
? checkForUsageFees($organization?.billingPlan, serviceId)
? checkForUsageFees($organization?.billingPlanId, serviceId)
: false;
$: isLimited = limit !== 0 && limit < Infinity;
$: overflowingServices = limitedServices.filter((service) => service.value > 0);
$: isButtonDisabled =
@@ -80,12 +78,12 @@
(isLimited && total >= limit && !hasUsageFees);
onMount(() => {
dispatch('data', { isButtonDisabled, limit, tier });
dispatch('data', { isButtonDisabled, limit, tier: planName });
});
// on free plan, if the only db is deleted,
// `create database` button needs to be enabled again.
$: if (isLimited) dispatch('data', { isButtonDisabled, limit, tier });
$: if (isLimited) dispatch('data', { isButtonDisabled, limit, tier: planName });
</script>
<!-- Show only if on Cloud, alerts are enabled, and it isn't a project limited service -->
@@ -98,11 +96,19 @@
.join(', ')}
{#if services.length}
<slot name="alert" {limit} {tier} {title} {upgradeMethod} {hasUsageFees} {services}>
{#if $organization?.billingPlan !== BillingPlan.FREE && hasUsageFees}
{@const supportsUsage = Object.keys($currentPlan.usage).length > 0}
<slot
name="alert"
{limit}
tier={planName}
{title}
{upgradeMethod}
{hasUsageFees}
{services}>
{#if !supportsUsage && hasUsageFees}
<Alert.Inline status="info">
<span class="text">
You've reached the {services} limit for the {tier} plan.
You've reached the {services} limit for the {planName} plan.
<Link on:mousedown={() => ($showUsageRatesModal = true)}
>Excess usage fees will apply</Link
>.
@@ -111,8 +117,8 @@
{:else}
<Alert.Inline status={alertType}>
<span class="text">
You've reached the {services} limit for the {tier} plan. <Link
href={$upgradeURL}
You've reached the {services} limit for the {planName} plan. <Link
href={getChangePlanUrl($organization?.$id)}
event="organization_upgrade"
eventData={{ from: 'event', source: 'inline_alert' }}>Upgrade</Link> your
organization for additional resources.
@@ -135,7 +141,7 @@
on:click={() => (showDropdown = !showDropdown)}>
<Icon icon={IconInfo} size="s" slot="start" />
</Badge>
{:else if $organization?.billingPlan !== BillingPlan.SCALE}
{:else}
<Badge
variant="secondary"
content="Limits applied"
@@ -144,13 +150,19 @@
</Badge>
{/if}
<svelte:fragment slot="list">
<slot name="tooltip" {limit} {tier} {title} {upgradeMethod} {hasUsageFees}>
<slot
name="tooltip"
{limit}
tier={planName}
{title}
{upgradeMethod}
{hasUsageFees}>
{#if hasProjectLimitation}
<p class="text">
You are limited to {limit}
{title.toLocaleLowerCase()} per project on the {tier} plan.
{#if $organization?.billingPlan === BillingPlan.FREE}<Link
href={$upgradeURL}
{title.toLocaleLowerCase()} per project on the {planName} plan.
{#if canUpgrade($organization.billingPlanId)}<Link
href={getChangePlanUrl($organization?.$id)}
event="organization_upgrade"
eventData={{ from: 'button', source: 'resource_limit_tag' }}
>Upgrade</Link>
@@ -160,7 +172,7 @@
{:else if hasUsageFees}
<p class="text">
You are limited to {limit}
{title.toLocaleLowerCase()} per organization on the {tier} plan.
{title.toLocaleLowerCase()} per organization on the {planName} plan.
<Link on:mousedown={() => ($showUsageRatesModal = true)}
>Excess usage fees will apply</Link
>.
@@ -168,9 +180,9 @@
{:else}
<p class="text">
You are limited to {limit}
{title.toLocaleLowerCase()} per organization on the {tier} plan.
{#if $organization?.billingPlan === BillingPlan.FREE}
<Link href={$upgradeURL}>Upgrade</Link>
{title.toLocaleLowerCase()} per organization on the {planName} plan.
{#if canUpgrade($organization.billingPlanId)}
<Link href={getChangePlanUrl($organization?.$id)}>Upgrade</Link>
for additional {title.toLocaleLowerCase()}.
{/if}
</p>
+23 -18
View File
@@ -4,15 +4,13 @@
import { CustomId } from '$lib/components/index.js';
import { getFlagUrl } from '$lib/helpers/flag';
import { isCloud } from '$lib/system.js';
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';
import { resolve } from '$app/paths';
let {
projectName = $bindable(),
@@ -20,7 +18,7 @@
regions = [],
region = $bindable(),
showTitle = true,
billingPlan = undefined,
currentPlan = undefined,
projects = undefined,
submit
}: {
@@ -29,21 +27,24 @@
regions: Array<Models.ConsoleRegion>;
region: string;
showTitle: boolean;
billingPlan?: BillingPlan;
currentPlan?: Models.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
);
let isAddonProject = $derived(
$currentPlan?.addons?.projects?.supported &&
const projectsLimited = $derived.by(() => {
return currentPlan?.projects > 0 && projects && projects >= currentPlan?.projects;
});
const isAddonProject = $derived.by(() => {
return (
currentPlan?.addons?.projects?.supported &&
projects &&
projects >= $currentPlan?.addons?.projects?.planIncluded
);
projects >= currentPlan?.addons?.projects?.planIncluded
);
});
</script>
<svelte:head>
@@ -61,7 +62,7 @@
<Layout.Stack direction="column" gap="xxl">
<Layout.Stack direction="column" gap="s">
<Input.Text
disabled={!isProPlan && projectsLimited}
disabled={projectsLimited}
label="Name"
placeholder="Project name"
required
@@ -82,7 +83,7 @@
{#if isCloud && regions.length > 0}
<Layout.Stack gap="xs">
<Input.Select
disabled={!isProPlan && projectsLimited}
disabled={projectsLimited}
required
bind:value={region}
placeholder="Select a region"
@@ -91,25 +92,29 @@
<Typography.Text>Region cannot be changed after creation</Typography.Text>
</Layout.Stack>
{/if}
{#if isAddonProject}
<Alert.Inline
status="info"
title="Expand for {formatCurrency(
$currentPlan?.addons?.projects?.price || 15
currentPlan?.addons?.projects?.price || 15
)}/project per month">
Each added project comes with its own dedicated pool of resources.
</Alert.Inline>
{/if}
{#if projectsLimited}
<Alert.Inline
status="warning"
title={`You've reached your limit of ${$currentPlan?.projects} projects`}>
title={`You've reached your limit of ${currentPlan?.projects} projects`}>
Extra projects are available on paid plans for an additional fee
<svelte:fragment slot="actions">
<Button
compact
size="s"
href={`${base}/organization-${page.params.organization}/billing`}
href={resolve('/(console)/organization-[organization]/billing', {
organization: page.params.organization
})}
external>Upgrade</Button>
</svelte:fragment>
</Alert.Inline>
+13 -10
View File
@@ -9,14 +9,12 @@
import { organization, organizationList } from '$lib/stores/organization';
import { sdk } from '$lib/stores/sdk';
import { user } from '$lib/stores/user';
import { tierToPlan } from '$lib/stores/billing';
import { isCloud } from '$lib/system';
import SideNavigation from '$lib/layout/navigation.svelte';
import { hasOnboardingDismissed } from '$lib/helpers/onboarding';
import { isSidebarOpen, noWidthTransition } from '$lib/stores/sidebar';
import { BillingPlan } from '$lib/constants';
import { page } from '$app/stores';
import type { Models } from '@appwrite.io/console';
import { BillingPlanGroup, type Models } from '@appwrite.io/console';
import { getSidebarState, isInDatabasesRoute, updateSidebarState } from '$lib/helpers/sidebar';
import { isTabletViewport } from '$lib/stores/viewport';
@@ -155,14 +153,19 @@
})
.toString(),
organizations: $organizationList.teams.map((org) => {
const billingPlan = org['billingPlan'];
organizations: $organizationList.teams.map((team) => {
let billingPlan: Models.BillingPlan | null = null;
if (isCloud) {
billingPlan = (team as Models.Organization).billingPlanDetails;
}
return {
name: org.name,
$id: org.$id,
showUpgrade: billingPlan === BillingPlan.FREE,
tierName: isCloud ? tierToPlan(billingPlan).name : null,
isSelected: $organization?.$id === org.$id
name: team.name,
$id: team.$id,
isSelected: $organization?.$id === team.$id,
tierName: isCloud ? billingPlan.name : null,
showUpgrade: isCloud ? billingPlan.group === BillingPlanGroup.Starter : false
};
}),
+4 -5
View File
@@ -5,23 +5,22 @@
import AppwriteLogoLight from '$lib/images/appwrite-logo-light.svg';
import LoginDark from '$lib/images/login/login-dark-mode.png';
import LoginLight from '$lib/images/login/login-light-mode.png';
import type { Coupon } from '$lib/sdk/billing';
import { app } from '$lib/stores/app';
import type { Campaign } from '$lib/stores/campaigns';
import { Typography, Layout, Avatar } from '@appwrite.io/pink-svelte';
import { getCampaignImageUrl } from '$routes/(public)/card/helpers';
import { isSmallViewport } from '$lib/stores/viewport';
import type { Models } from '@appwrite.io/console';
export const imgLight = LoginLight;
export const imgDark = LoginDark;
export let campaign: Campaign = null;
export let coupon: Coupon = null;
export let campaign: Models.Campaign = null;
export let coupon: Models.Coupon = null;
export let align: 'start' | 'center' | 'end' = 'start';
$: variation = ((coupon?.campaign ?? campaign) ? campaign?.template : 'default') as
| 'default'
| Campaign['template'];
| Models.Campaign['template'];
let currentReviewNumber = 0;
$: currentReview = campaign?.reviews?.[currentReviewNumber];
+2 -1427
View File
File diff suppressed because it is too large Load Diff
+248 -179
View File
@@ -1,48 +1,39 @@
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { base } from '$app/paths';
import { Click, trackEvent } from '$lib/actions/analytics';
import { resolve } from '$app/paths';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { browser } from '$app/environment';
import { Click, trackEvent } from '$lib/actions/analytics';
import LimitReached from '$lib/components/billing/alerts/limitReached.svelte';
import MarkedForDeletion from '$lib/components/billing/alerts/markedForDeletion.svelte';
import MissingPaymentMethod from '$lib/components/billing/alerts/missingPaymentMethod.svelte';
import newDevUpgradePro from '$lib/components/billing/alerts/newDevUpgradePro.svelte';
import PaymentAuthRequired from '$lib/components/billing/alerts/paymentAuthRequired.svelte';
import { BillingPlan, NEW_DEV_PRO_UPGRADE_COUPON } from '$lib/constants';
import { NEW_DEV_PRO_UPGRADE_COUPON } from '$lib/constants';
import { cachedStore } from '$lib/helpers/cache';
import { type Size, sizeToBytes } from '$lib/helpers/sizeConvertion';
import type {
AddressesList,
AggregationTeam,
Invoice,
InvoiceList,
PaymentList,
Plan,
PlansMap
} from '$lib/sdk/billing';
import type { BillingPlansMap } from '$lib/sdk/billing';
import { isCloud } from '$lib/system';
import { activeHeaderAlert, orgMissingPaymentMethod } from '$routes/(console)/store';
import { AppwriteException, Query, Platform } from '@appwrite.io/console';
import {
AppwriteException,
BillingPlanGroup,
type Models,
Platform,
Query
} from '@appwrite.io/console';
import { derived, get, writable } from 'svelte/store';
import { headerAlert } from './headerAlert';
import { addNotification, notifications } from './notifications';
import {
currentPlan,
organization,
type Organization,
type OrganizationError
} from './organization';
import { currentPlan } from './organization';
import { canSeeBilling } from './roles';
import { sdk } from './sdk';
import { user } from './user';
import BudgetLimitAlert from '$routes/(console)/organization-[organization]/budgetLimitAlert.svelte';
import TeamReadonlyAlert from '$routes/(console)/organization-[organization]/teamReadonlyAlert.svelte';
import ProjectsLimit from '$lib/components/billing/alerts/projectsLimit.svelte';
import EnterpriseTrial from '$routes/(console)/organization-[organization]/enterpriseTrial.svelte';
export type Tier = 'tier-0' | 'tier-1' | 'tier-2' | 'auto-1' | 'cont-1' | 'ent-1';
export const roles = [
{
label: 'Owner',
@@ -69,60 +60,120 @@ export const roles = [
export const teamStatusReadonly = 'readonly';
export const billingLimitOutstandingInvoice = 'outstanding_invoice';
export const paymentMethods = derived(page, ($page) => $page.data.paymentMethods as PaymentList);
export const addressList = derived(page, ($page) => $page.data.addressList as AddressesList);
export const plansInfo = derived(page, ($page) => $page.data.plansInfo as PlansMap);
export const daysLeftInTrial = writable<number>(0);
export const paymentMethods = derived(
page,
($page) => $page.data.paymentMethods as Models.PaymentMethodList
);
export const addressList = derived(
page,
($page) => $page.data.addressList as Models.BillingAddressList
);
export const readOnly = writable<boolean>(false);
export const daysLeftInTrial = writable<number>(0);
export const plansInfo = writable<BillingPlansMap>(new Map());
export const showBudgetAlert = derived(
page,
($page) => ($page.data.organization?.billingLimits.budgetLimit ?? 0) >= 100
);
function getPlansInfoStore(): BillingPlansMap | null {
return get(plansInfo) ?? get(page).data?.plansInfo ?? null;
}
function makeBillingPlan(billingPlanOrId: string | Models.BillingPlan): Models.BillingPlan {
return typeof billingPlanOrId === 'string' ? billingIdToPlan(billingPlanOrId) : billingPlanOrId;
}
export function getRoleLabel(role: string) {
return roles.find((r) => r.value === role)?.label ?? role;
}
export function tierToPlan(tier: Tier) {
switch (tier) {
case BillingPlan.FREE:
return tierFree;
case BillingPlan.PRO:
return tierPro;
case BillingPlan.SCALE:
return tierScale;
case BillingPlan.GITHUB_EDUCATION:
return tierGitHubEducation;
case BillingPlan.CUSTOM:
return tierCustom;
case BillingPlan.ENTERPRISE:
return tierEnterprise;
default:
return tierCustom;
export function isStarterPlan(billingPlanOrId: string | Models.BillingPlan): boolean {
const billingPlan = makeBillingPlan(billingPlanOrId);
return planHasGroup(billingPlan, BillingPlanGroup.Starter);
}
export function canUpgrade(billingPlanOrId: string | Models.BillingPlan): boolean {
const billingPlan = makeBillingPlan(billingPlanOrId);
const nextTier = getNextTierBillingPlan(billingPlan.$id);
// defaults back to PRO, so adjust the check!
return billingPlan.$id !== nextTier.$id;
}
export function canDowngrade(billingPlanOrId: string | Models.BillingPlan): boolean {
const billingPlan = makeBillingPlan(billingPlanOrId);
const nextTier = getPreviousTierBillingPlan(billingPlan.$id);
// defaults back to Starter, so adjust the check!
return billingPlan.$id !== nextTier.$id;
}
export function planHasGroup(
billingPlanOrId: string | Models.BillingPlan,
group: BillingPlanGroup
): boolean {
const billingPlan = makeBillingPlan(billingPlanOrId);
return billingPlan?.group === group;
}
export function getBasePlanFromGroup(billingPlanGroup: BillingPlanGroup): Models.BillingPlan {
const plansInfoStore = getPlansInfoStore();
const proPlans = Array.from(plansInfoStore.values()).filter(
(plan) => plan.group === billingPlanGroup
);
return proPlans.sort((a, b) => a.order - b.order)[0];
}
export function billingIdToPlan(billingId: string): Models.BillingPlan | null {
const plansInfoStore = getPlansInfoStore();
if (plansInfoStore.has(billingId)) {
return plansInfoStore.get(billingId);
} else {
return null;
}
}
export function getNextTier(tier: Tier) {
switch (tier) {
case BillingPlan.FREE:
return BillingPlan.PRO;
case BillingPlan.PRO:
return BillingPlan.SCALE;
default:
return BillingPlan.PRO;
export function getNextTierBillingPlan(billingPlanId: string): Models.BillingPlan {
const currentPlanData = billingIdToPlan(billingPlanId);
if (!currentPlanData) {
/* should never happen but safety! */
return getBasePlanFromGroup(BillingPlanGroup.Pro);
}
const currentOrder = currentPlanData.order;
const plans = get(plansInfo);
for (const [, plan] of plans) {
if (plan.order === currentOrder + 1) {
return plan;
}
}
return getBasePlanFromGroup(BillingPlanGroup.Pro);
}
export function getPreviousTier(tier: Tier) {
switch (tier) {
case BillingPlan.PRO:
return BillingPlan.FREE;
case BillingPlan.SCALE:
return BillingPlan.PRO;
default:
return BillingPlan.FREE;
export function getPreviousTierBillingPlan(billingPlanId: string): Models.BillingPlan {
const currentPlanData = billingIdToPlan(billingPlanId);
if (!currentPlanData) {
/* should never happen but safety! */
return getBasePlanFromGroup(BillingPlanGroup.Starter);
}
const currentOrder = currentPlanData.order;
const plans = get(plansInfo);
for (const [, plan] of plans) {
if (plan.order === currentOrder - 1) {
return plan;
}
}
return getBasePlanFromGroup(BillingPlanGroup.Starter);
}
export type PlanServices =
@@ -151,7 +202,11 @@ export type PlanServices =
| 'authPhone'
| 'imageTransformations';
export function getServiceLimit(serviceId: PlanServices, tier: Tier = null, plan?: Plan): number {
export function getServiceLimit(
serviceId: PlanServices,
tier: string = null,
plan?: Models.BillingPlan
): number {
if (!isCloud) return 0;
if (!serviceId) return 0;
@@ -177,7 +232,7 @@ export function getServiceLimit(serviceId: PlanServices, tier: Tier = null, plan
}
export const failedInvoice = cachedStore<
Invoice,
Models.Invoice,
{
load: (orgId: string) => Promise<void>;
}
@@ -186,9 +241,10 @@ export const failedInvoice = cachedStore<
load: async (orgId) => {
if (!isCloud) set(null);
if (!get(canSeeBilling)) set(null);
const failedInvoices = await sdk.forConsole.billing.listInvoices(orgId, [
Query.equal('status', 'failed')
]);
const failedInvoices = await sdk.forConsole.organizations.listInvoices({
organizationId: orgId,
queries: [Query.equal('status', 'failed')]
});
// const failedInvoices = invoices.invoices;
if (failedInvoices?.invoices?.length > 0) {
const firstFailed = failedInvoices.invoices[0];
@@ -203,49 +259,16 @@ export const failedInvoice = cachedStore<
};
});
export const actionRequiredInvoices = writable<InvoiceList>(null);
export type TierData = {
name: string;
description: string;
};
export const tierFree: TierData = {
name: 'Free',
description: 'A great fit for passion projects and small applications.'
};
export const tierGitHubEducation: TierData = {
name: 'GitHub Education',
description: 'For members of GitHub student developers program.'
};
export const tierPro: TierData = {
name: 'Pro',
description:
'For production applications that need powerful functionality and resources to scale.'
};
export const tierScale: TierData = {
name: 'Scale',
description:
'For teams that handle more complex and large projects and need more control and support.'
};
export const tierCustom: TierData = {
name: 'Custom',
description: 'Team on a custom contract'
};
export const tierEnterprise: TierData = {
name: 'Enterprise',
description: 'For enterprises that need more power and premium support.'
};
export const actionRequiredInvoices = writable<Models.InvoiceList>(null);
export const showUsageRatesModal = writable<boolean>(false);
export const useNewPricingModal = derived(currentPlan, ($plan) => $plan?.usagePerProject === true);
export function checkForUsageFees(plan: Tier, id: PlanServices) {
if (plan === BillingPlan.PRO || plan === BillingPlan.SCALE) {
export function checkForUsageFees(plan: string, id: PlanServices) {
const billingPlan = billingIdToPlan(plan);
const supportsUsage = Object.keys(billingPlan.usage).length > 0;
if (supportsUsage) {
switch (id) {
case 'bandwidth':
case 'storage':
@@ -260,11 +283,12 @@ export function checkForUsageFees(plan: Tier, id: PlanServices) {
} else return false;
}
export function checkForProjectLimitation(id: PlanServices) {
// Members are no longer limited on Pro and Scale plans (unlimited seats)
export function checkForProjectLimitation(plan: string, id: PlanServices) {
if (id === 'members') {
const currentTier = get(organization)?.billingPlan;
if (currentTier === BillingPlan.PRO || currentTier === BillingPlan.SCALE) {
const billingPlan = billingIdToPlan(plan);
const hasUnlimitedProjects = billingPlan.projects === 0;
if (hasUnlimitedProjects) {
return false; // No project limitation for members on Pro/Scale plans
}
}
@@ -284,15 +308,25 @@ export function checkForProjectLimitation(id: PlanServices) {
}
}
export function isServiceLimited(serviceId: PlanServices, plan: Tier, total: number) {
export function isServiceLimited(
serviceId: PlanServices,
organization: Models.Organization,
total: number
) {
// total validity
if (!total) return false;
// org and plan validity!
if (!organization) return false;
if (!('billingPlanId' in organization)) return false;
const limit = getServiceLimit(serviceId) || Infinity;
const isLimited = limit !== 0 && limit < Infinity;
const hasUsageFees = checkForUsageFees(plan, serviceId);
const hasUsageFees = checkForUsageFees(organization.billingPlanId, serviceId);
return isLimited && total >= limit && !hasUsageFees;
}
export function checkForEnterpriseTrial(org: Organization) {
export function checkForEnterpriseTrial(org: Models.Organization) {
if (!org || !org.billingNextInvoiceDate) return;
if (calculateEnterpriseTrial(org) > 0) {
headerAlert.add({
@@ -304,7 +338,7 @@ export function checkForEnterpriseTrial(org: Organization) {
}
}
export function calculateEnterpriseTrial(org: Organization) {
export function calculateEnterpriseTrial(org: Models.Organization) {
if (!org || !org.billingNextInvoiceDate) return 0;
const endDate = new Date(org.billingNextInvoiceDate);
const startDate = new Date(org.billingCurrentInvoiceDate);
@@ -319,8 +353,9 @@ export function calculateEnterpriseTrial(org: Organization) {
return 0;
}
export function calculateTrialDay(org: Organization) {
if (org?.billingPlan === BillingPlan.FREE) return false;
export function calculateTrialDay(org: Models.Organization) {
if (!org.billingPlanDetails.trial) return false;
const endDate = new Date(org?.billingStartDate);
const today = new Date();
@@ -333,20 +368,22 @@ export function calculateTrialDay(org: Organization) {
return days;
}
export async function checkForProjectsLimit(org: Organization, orgProjectCount?: number) {
export async function checkForProjectsLimit(org: Models.Organization, orgProjectCount?: number) {
if (!isCloud) return;
if (!org) return;
const plan = await sdk.forConsole.billing.getOrganizationPlan(org.$id);
const plan = await sdk.forConsole.organizations.getPlan({
organizationId: org.$id
});
if (!plan) return;
if (plan.$id !== BillingPlan.FREE) return;
if (!org.projects) return;
if (org.projects.length > 0) return;
const projectCount = orgProjectCount;
if (projectCount === undefined) return;
// not unlimited and current exceeds plan limits!
if (plan.projects > 0 && projectCount > plan.projects) {
headerAlert.add({
id: 'projectsLimitReached',
@@ -357,8 +394,11 @@ export async function checkForProjectsLimit(org: Organization, orgProjectCount?:
}
}
export async function checkForUsageLimit(org: Organization) {
if (org?.status === teamStatusReadonly && org?.remarks === billingLimitOutstandingInvoice) {
export async function checkForUsageLimit(organization: Models.Organization) {
if (
organization?.status === teamStatusReadonly &&
organization?.remarks === billingLimitOutstandingInvoice
) {
headerAlert.add({
id: 'teamReadOnlyFailedInvoices',
component: TeamReadonlyAlert,
@@ -368,12 +408,14 @@ export async function checkForUsageLimit(org: Organization) {
readOnly.set(true);
return;
}
if (!org?.billingLimits && org?.status !== teamStatusReadonly) {
if (!organization?.billingLimits && organization?.status !== teamStatusReadonly) {
readOnly.set(false);
return;
}
if (org?.billingPlan !== BillingPlan.FREE) {
const { budgetLimit } = org?.billingLimits ?? {};
if (organization.billingPlanDetails.budgeting) {
const { budgetLimit } = organization?.billingLimits ?? {};
if (budgetLimit && budgetLimit >= 100) {
readOnly.set(false);
@@ -389,17 +431,15 @@ export async function checkForUsageLimit(org: Organization) {
}
}
// TODO: @itznotabug - check with @abnegate, what do we do here? this is billing!
const { bandwidth, documents, executions, storage, users } = org?.billingLimits ?? {};
const { bandwidth, executions, storage, users } = organization?.billingLimits ?? {};
const resources = [
{ value: bandwidth, name: 'bandwidth' },
{ value: documents, name: 'documents' },
{ value: executions, name: 'executions' },
{ value: storage, name: 'storage' },
{ value: users, name: 'users' }
];
const members = org.total;
const members = organization.total;
const memberLimit = getServiceLimit('members');
const membersOverflow = memberLimit === Infinity ? 0 : Math.max(0, members - memberLimit);
@@ -418,10 +458,16 @@ export async function checkForUsageLimit(org: Organization) {
if (now - lastNotification < 1000 * 60 * 60 * 24) return;
localStorage.setItem('limitReachedNotification', now.toString());
let message = `<b>${org.name}</b> has reached <b>75%</b> of the ${tierToPlan(BillingPlan.FREE).name} plan's ${resources.find((r) => r.value >= 75).name} limit. Upgrade to ensure there are no service disruptions.`;
if (resources.filter((r) => r.value >= 75)?.length > 1) {
message = `Usage for <b>${org.name}</b> has reached 75% of the ${tierToPlan(BillingPlan.FREE).name} plan limit. Upgrade to ensure there are no service disruptions.`;
const threshold = 75;
const exceededResources = resources.filter((r) => r.value >= threshold);
let message = `<b>${organization.name}</b> has reached <b>${threshold}%</b> of its ${exceededResources[0].name} limit. Upgrade to ensure there are no service disruptions.`;
if (exceededResources.length > 1) {
message = `Usage for <b>${organization.name}</b> has reached <b>${threshold}%</b> of its plan limits. Upgrade to ensure there are no service disruptions.`;
}
addNotification({
type: 'warning',
isHtml: true,
@@ -431,13 +477,21 @@ export async function checkForUsageLimit(org: Organization) {
{
name: 'View usage',
method: () => {
goto(`${base}/organization-${org.$id}/usage`);
goto(
resolve('/(console)/organization-[organization]/usage', {
organization: organization.$id
})
);
}
},
{
name: 'Upgrade plan',
method: () => {
goto(`${base}/organization-${org.$id}/change-plan`);
goto(
resolve('/(console)/organization-[organization]/change-plan', {
organization: organization.$id
})
);
trackEvent(Click.OrganizationClickUpgrade, {
from: 'button',
source: 'limit_reached_notification'
@@ -451,12 +505,13 @@ export async function checkForUsageLimit(org: Organization) {
}
}
export async function checkPaymentAuthorizationRequired(org: Organization) {
if (org.billingPlan === BillingPlan.FREE) return;
export async function checkPaymentAuthorizationRequired(org: Models.Organization) {
if (!org.billingPlanDetails.requiresPaymentMethod) return;
const invoices = await sdk.forConsole.billing.listInvoices(org.$id, [
Query.equal('status', 'requires_authentication')
]);
const invoices = await sdk.forConsole.organizations.listInvoices({
organizationId: org.$id,
queries: [Query.equal('status', 'requires_authentication')]
});
if (invoices?.invoices?.length > 0) {
headerAlert.add({
@@ -471,12 +526,13 @@ export async function checkPaymentAuthorizationRequired(org: Organization) {
actionRequiredInvoices.set(invoices);
}
export async function paymentExpired(org: Organization) {
export async function paymentExpired(org: Models.Organization) {
if (!org?.paymentMethodId) return;
const payment = await sdk.forConsole.billing.getOrganizationPaymentMethod(
org.$id,
org.paymentMethodId
);
const payment = await sdk.forConsole.organizations.getPaymentMethod({
organizationId: org.$id,
paymentMethodId: org.paymentMethodId
});
if (!payment?.expiryYear) return;
const sessionStorageNotification = sessionStorage.getItem('expiredPaymentNotification');
if (sessionStorageNotification === 'true') return;
@@ -500,7 +556,7 @@ export async function paymentExpired(org: Organization) {
{
name: 'Update payment details',
method: () => {
goto(`${base}/account/payments`);
goto(resolve('/account/payments'));
}
}
]
@@ -514,7 +570,7 @@ export async function paymentExpired(org: Organization) {
{
name: 'Update payment details',
method: () => {
goto(`${base}/account/payments`);
goto(resolve('/account/payments'));
}
}
]
@@ -523,7 +579,7 @@ export async function paymentExpired(org: Organization) {
sessionStorage.setItem('expiredPaymentNotification', 'true');
}
export function checkForMarkedForDeletion(org: Organization) {
export function checkForMarkedForDeletion(org: Models.Organization) {
if (org?.markedForDeletion) {
headerAlert.add({
id: 'markedForDeletion',
@@ -535,12 +591,17 @@ export function checkForMarkedForDeletion(org: Organization) {
}
export async function checkForMissingPaymentMethod() {
const orgs = await sdk.forConsole.billing.listOrganization([
Query.notEqual('billingPlan', BillingPlan.FREE),
Query.isNull('paymentMethodId'),
Query.isNull('backupPaymentMethodId'),
Query.equal('platform', Platform.Appwrite)
]);
const starterPlan = getBasePlanFromGroup(BillingPlanGroup.Starter);
const orgs = await sdk.forConsole.organizations.list({
queries: [
Query.isNull('paymentMethodId'),
Query.isNull('backupPaymentMethodId'),
Query.equal('platform', Platform.Appwrite),
Query.notEqual('billingPlan', starterPlan.$id)
]
});
if (orgs?.total) {
orgMissingPaymentMethod.set(orgs.teams[0]);
headerAlert.add({
@@ -553,9 +614,9 @@ export async function checkForMissingPaymentMethod() {
}
// Display upgrade banner for new users after 1 week for 30 days
export async function checkForNewDevUpgradePro(org: Organization) {
export async function checkForNewDevUpgradePro(org: Models.Organization) {
// browser or plan check.
if (!browser || org?.billingPlan !== BillingPlan.FREE) return;
if (!browser || !org.billingPlanDetails.supportsCredits) return;
// already dismissed by user!
if (localStorage.getItem('newDevUpgradePro')) return;
@@ -569,14 +630,16 @@ export async function checkForNewDevUpgradePro(org: Organization) {
const accountCreated = new Date(account.$createdAt).getTime();
if (now - accountCreated < 1000 * 60 * 60 * 24 * 7) return;
const organizations = await sdk.forConsole.billing.listOrganization([
Query.notEqual('billingPlan', BillingPlan.FREE)
]);
const organizations = await sdk.forConsole.organizations.list({
queries: [Query.notEqual('billingPlan', getBasePlanFromGroup(BillingPlanGroup.Starter).$id)]
});
if (organizations?.total) return;
try {
await sdk.forConsole.billing.getCouponAccount(NEW_DEV_PRO_UPGRADE_COUPON);
await sdk.forConsole.console.getCoupon({
couponId: NEW_DEV_PRO_UPGRADE_COUPON
});
} catch (error) {
if (
// already utilized if error is 409
@@ -596,32 +659,38 @@ export async function checkForNewDevUpgradePro(org: Organization) {
importance: 1
});
}
export const upgradeURL = derived(
page,
($page) => `${base}/organization-${$page.data?.organization?.$id}/change-plan`
);
export const billingURL = derived(
page,
($page) => `${base}/organization-${$page.data?.organization?.$id}/billing`
);
export const hideBillingHeaderRoutes = [base + '/create-organization', base + '/account'];
export function getChangePlanUrl(organizationId?: string | null | undefined): string {
let orgId = organizationId || null;
export function calculateExcess(addon: AggregationTeam, plan: Plan) {
return {
bandwidth: calculateResourceSurplus(addon.usageBandwidth, plan.bandwidth),
storage: calculateResourceSurplus(addon.usageStorage, plan.storage, 'GB'),
executions: calculateResourceSurplus(addon.usageExecutions, plan.executions, 'GB'),
members: addon.additionalMembers
};
if (!orgId) {
try {
const pageState = get(page);
const fromUrl = pageState?.params?.organization?.trim?.() || null;
const fromOrgData = pageState?.data?.organization?.$id?.trim?.() || null;
const fromProjectData = pageState?.data?.project?.teamId?.trim?.() || null;
orgId = fromUrl ?? fromProjectData ?? fromOrgData;
} catch {
/* ignore */
}
}
if (orgId) {
return resolve('/(console)/organization-[organization]/change-plan', {
organization: orgId
});
} else {
/* fallback to not crash anything */
return resolve('/');
}
}
export function calculateResourceSurplus(total: number, limit: number, limitUnit: Size = null) {
if (total === undefined || limit === undefined) return 0;
const realLimit = (limitUnit ? sizeToBytes(limit, limitUnit) : limit) || Infinity;
return total > realLimit ? total - realLimit : 0;
}
export const hideBillingHeaderRoutes = [resolve('/account'), resolve('/create-organization')];
export function isOrganization(org: Organization | OrganizationError): org is Organization {
return (org as Organization).$id !== undefined;
export function isPaymentAuthenticationRequired(
org: Models.Organization | Models.PaymentAuthentication
): org is Models.PaymentAuthentication {
return 'clientSecret' in org;
}
+1 -2
View File
@@ -1,7 +1,6 @@
import { writable } from 'svelte/store';
import type { Component } from 'svelte';
import type { Models } from '@appwrite.io/console';
import type { Organization } from '$lib/stores/organization';
import type { NotificationCoolOffOptions } from '$lib/helpers/notifications';
export type BottomModalAlertAction = {
@@ -10,7 +9,7 @@ export type BottomModalAlertAction = {
background?: Record<'light' | 'dark', string> | string;
backgroundHover?: Record<'light' | 'dark', string> | string;
hideOnClick?: boolean;
link: (ctx: { organization: Organization; project: Models.Project }) => string;
link: (ctx: { organization: Models.Organization; project: Models.Project }) => string;
external?: boolean;
};
-26
View File
@@ -1,26 +0,0 @@
import type { BillingPlan } from '$lib/constants';
export type Campaign = {
$id: string;
template: string;
title: string;
description: string;
cta?: string;
claimed?: string;
unclaimed?: string;
reviews?: Review[];
image?: {
dark: string;
light: string;
};
onlyNewOrgs?: boolean;
footer?: boolean;
plan?: BillingPlan;
};
export type Review = {
name: string;
image: string;
description: string;
review: string;
};
+24 -56
View File
@@ -1,59 +1,8 @@
import { page } from '$app/stores';
import type { Tier } from './billing';
import type { Plan } from '$lib/sdk/billing';
import { sdk } from '$lib/stores/sdk';
import { isCloud } from '$lib/system';
import { derived, writable } from 'svelte/store';
import { type Models, Platform } from '@appwrite.io/console';
export type OrganizationError = {
status: number;
message: string;
teamId: string;
invoiceId: string;
clientSecret: string;
type: string;
};
export type Organization = Models.Team<Record<string, unknown>> & {
billingBudget: number;
billingPlan: Tier;
billingPlanId: Tier /* unused for now! */;
billingPlanDetails: Plan /* unused for now! */;
budgetAlerts: number[];
paymentMethodId: string;
backupPaymentMethodId: string;
markedForDeletion: boolean;
billingLimits: BillingLimits;
billingCurrentInvoiceDate: string;
billingNextInvoiceDate: string;
billingTrialStartDate?: string;
billingStartDate?: string;
billingTrialDays?: number;
billingAddressId?: string;
amount: number;
billingTaxId?: string;
billingPlanDowngrade?: Tier;
billingAggregationId: string;
billingInvoiceId: string;
status: string;
remarks: string;
projects: string[];
platform: Platform;
};
export type OrganizationList = {
teams: Organization[];
total: number;
};
// TODO: @itznotabug - check with @abnegate, what do we do here? this is billing!
export type BillingLimits = {
bandwidth: number;
documents: number;
executions: number;
storage: number;
users: number;
budgetLimit: number;
};
import { type Models, Platform, Query } from '@appwrite.io/console';
export const newOrgModal = writable<boolean>(false);
export const newMemberModal = writable<boolean>(false);
@@ -62,7 +11,26 @@ export const organizationList = derived(
($page) => $page.data.organizations as Models.TeamList<Record<string, unknown>>
);
export const organization = derived(page, ($page) => $page.data?.organization as Organization);
export const currentPlan = derived(page, ($page) => $page.data?.currentPlan as Plan);
export const organization = derived(
page,
($page) => $page.data?.organization as Models.Organization
);
export const currentPlan = derived(page, ($page) => $page.data?.currentPlan as Models.BillingPlan);
export const members = derived(page, ($page) => $page.data.members as Models.MembershipList);
export const regions = writable<Models.ConsoleRegionList>({ total: 0, regions: [] });
export async function getTeamOrOrganizationList(
queries: string[] = []
): Promise<Models.TeamList | Models.OrganizationList> {
let organizations: Models.TeamList | Models.OrganizationList;
if (isCloud) {
organizations = await sdk.forConsole.organizations.list({
queries: [...queries, Query.equal('platform', Platform.Appwrite)]
});
} else {
organizations = await sdk.forConsole.teams.list({ queries });
}
return organizations;
}
-2
View File
@@ -27,7 +27,6 @@ import {
Realtime,
Organizations
} from '@appwrite.io/console';
import { Billing } from '../sdk/billing';
import { Sources } from '$lib/sdk/sources';
import {
REGION_FRA,
@@ -92,7 +91,6 @@ function createConsoleSdk(client: Client) {
migrations: new Migrations(client),
console: new Console(client),
assistant: new Assistant(client),
billing: new Billing(client),
sources: new Sources(client),
sites: new Sites(client),
domains: new Domains(client),
+37 -30
View File
@@ -1,6 +1,6 @@
import type {
Appearance,
PaymentMethod,
PaymentMethod as StripePaymentMethod,
Stripe,
StripeElement,
StripeElements
@@ -8,21 +8,21 @@ import type {
import { sdk } from './sdk';
import { app } from './app';
import { get, writable } from 'svelte/store';
import type { PaymentMethodData } from '$lib/sdk/billing';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { addNotification } from './notifications';
import { organization } from './organization';
import { base } from '$app/paths';
import { resolve } from '$app/paths';
import { ThemeDarkCloud, ThemeLightCloud } from '$themes';
import Color from 'color';
import type { Models } from '@appwrite.io/console';
export const stripe = writable<Stripe>();
let paymentMethod: PaymentMethodData;
export const isStripeInitialized = writable(false);
let clientSecret: string;
let elements: StripeElements;
let paymentElement: StripeElement;
export const isStripeInitialized = writable(false);
let paymentMethod: Models.PaymentMethod;
export async function initializeStripe(node: HTMLElement) {
if (!get(stripe)) return;
@@ -32,7 +32,7 @@ export async function initializeStripe(node: HTMLElement) {
isStripeInitialized.set(true);
const methods = await sdk.forConsole.billing.listPaymentMethods();
const methods = await sdk.forConsole.account.listPaymentMethods();
// Get the client secret from empty payment method if available
clientSecret = methods.paymentMethods?.filter(
@@ -41,7 +41,7 @@ export async function initializeStripe(node: HTMLElement) {
// If there is no payment method, create an empty one and get the client secret
if (!clientSecret) {
paymentMethod = await sdk.forConsole.billing.createPaymentMethod();
paymentMethod = await sdk.forConsole.account.createPaymentMethod();
clientSecret = paymentMethod.clientSecret;
}
@@ -78,7 +78,7 @@ export async function submitStripeCard(name: string, organizationId?: string) {
try {
// If a payment method was created during initialization, use it, otherwise create a new one
if (!paymentMethod) {
paymentMethod = await sdk.forConsole.billing.createPaymentMethod();
paymentMethod = await sdk.forConsole.account.createPaymentMethod();
clientSecret = paymentMethod.clientSecret;
}
@@ -116,12 +116,12 @@ export async function submitStripeCard(name: string, organizationId?: string) {
}
if (setupIntent && setupIntent.status === 'succeeded') {
const pm = setupIntent.payment_method as PaymentMethod | string | undefined;
const pm = setupIntent.payment_method as StripePaymentMethod | string | undefined;
// If Stripe returned an expanded PaymentMethod object, check the card country.
// If it returned a string id (common), `typeof pm === 'string'` and we skip this.
if (typeof pm !== 'string' && pm?.card?.country === 'US') {
// need to get state
return pm as PaymentMethod;
return pm as StripePaymentMethod;
}
// The backend expects a provider method ID (string). Extract the id
@@ -130,7 +130,7 @@ export async function submitStripeCard(name: string, organizationId?: string) {
if (typeof pm === 'string') {
providerId = pm;
} else {
providerId = (pm as PaymentMethod)?.id;
providerId = (pm as StripePaymentMethod)?.id;
}
if (!providerId) {
@@ -139,11 +139,12 @@ export async function submitStripeCard(name: string, organizationId?: string) {
throw e;
}
const method = await sdk.forConsole.billing.setPaymentMethod(
paymentMethod.$id,
providerId,
const method = await sdk.forConsole.account.updatePaymentMethodProvider({
paymentMethodId: paymentMethod.$id,
providerMethodId: providerId,
name
);
});
paymentElement.destroy();
isStripeInitialized.set(false);
trackEvent(Submit.PaymentMethodCreate);
@@ -169,12 +170,12 @@ export async function setPaymentMethod(providerMethodId: string, name: string, s
return;
}
try {
const method = await sdk.forConsole.billing.setPaymentMethod(
paymentMethod.$id,
providerMethodId,
const method = await sdk.forConsole.account.updatePaymentMethodProvider({
paymentMethodId: paymentMethod.$id,
providerMethodId: providerMethodId,
name,
state
);
});
paymentElement.destroy();
isStripeInitialized.set(false);
trackEvent(Submit.PaymentMethodCreate);
@@ -185,17 +186,22 @@ export async function setPaymentMethod(providerMethodId: string, name: string, s
}
}
export async function confirmPayment(
orgId: string,
clientSecret: string,
paymentMethodId: string,
route?: string
) {
try {
const url =
window.location.origin + (route ? route : `${base}/organization-${orgId}/billing`);
export async function confirmPayment(config: {
clientSecret: string;
paymentMethodId: string;
orgId?: string;
route?: string;
}) {
const { clientSecret, paymentMethodId, orgId, route } = config;
const paymentMethod = await sdk.forConsole.billing.getPaymentMethod(paymentMethodId);
try {
const resolvedUrl = resolve('/(console)/organization-[organization]/billing', {
organization: orgId
});
const url = window.location.origin + (route ? route : resolvedUrl);
const paymentMethod = await sdk.forConsole.account.getPaymentMethod({ paymentMethodId });
const { error } = await get(stripe).confirmPayment({
clientSecret: clientSecret,
@@ -204,6 +210,7 @@ export async function confirmPayment(
payment_method: paymentMethod.providerMethodId
}
});
if (error) {
throw error.message;
}
+6 -6
View File
@@ -1,12 +1,12 @@
<script lang="ts">
import { page } from '$app/state';
import { BillingPlan, INTERVAL } from '$lib/constants';
import { INTERVAL } from '$lib/constants';
import Footer from '$lib/layout/footer.svelte';
import Shell from '$lib/layout/shell.svelte';
import { app } from '$lib/stores/app';
import { database, checkForDatabaseBackupPolicies } from '$lib/stores/database';
import { newOrgModal, organization, type Organization } from '$lib/stores/organization';
import { newOrgModal, organization } from '$lib/stores/organization';
import { wizard } from '$lib/stores/wizard';
import { afterUpdate, onMount } from 'svelte';
import { loading } from '$routes/store';
@@ -22,7 +22,6 @@
checkForUsageLimit,
checkPaymentAuthorizationRequired,
paymentExpired,
plansInfo,
showUsageRatesModal
} from '$lib/stores/billing';
import { goto } from '$app/navigation';
@@ -56,6 +55,7 @@
IconSwitchHorizontal
} from '@appwrite.io/pink-icons-svelte';
import type { LayoutData } from './$types';
import type { Models } from '@appwrite.io/console';
export let data: LayoutData;
@@ -291,7 +291,7 @@
});
let currentOrganizationId = null;
async function checkForUsageLimits(org: Organization) {
async function checkForUsageLimits(org: Models.Organization) {
if (!org) return;
if (currentOrganizationId === org.$id) return;
if (isCloud) {
@@ -304,11 +304,11 @@
checkForMarkedForDeletion(org);
await checkForNewDevUpgradePro(org);
if (org?.billingPlan !== BillingPlan.FREE) {
if (org?.billingPlanDetails.requiresPaymentMethod) {
await paymentExpired(org);
await checkPaymentAuthorizationRequired(org);
if ($plansInfo.get(org.billingPlan)?.trialDays) {
if (org?.billingTrialDays) {
calculateTrialDay(org);
}
}
+24 -23
View File
@@ -1,29 +1,40 @@
import { Dependencies } from '$lib/constants';
import { sdk } from '$lib/stores/sdk';
import { isCloud } from '$lib/system';
import type { LayoutLoad } from './$types';
import type { Tier } from '$lib/stores/billing';
import type { Plan, PlanList } from '$lib/sdk/billing';
import { Query } from '@appwrite.io/console';
import { Dependencies } from '$lib/constants';
import { Platform, Query } from '@appwrite.io/console';
import { makePlansMap } from '$lib/helpers/billing';
import { plansInfo as plansInfoStore } from '$lib/stores/billing';
export const load: LayoutLoad = async ({ depends, parent }) => {
const { organizations } = await parent();
const { organizations, plansInfo } = await parent();
depends(Dependencies.RUNTIMES);
depends(Dependencies.CONSOLE_VARIABLES);
depends(Dependencies.ORGANIZATION);
const { endpoint, project } = sdk.forConsole.client.config;
const plansArrayPromise =
plansInfo || !isCloud
? null
: sdk.forConsole.console.getPlans({
platform: Platform.Appwrite
});
const [preferences, plansArray, versionData, consoleVariables] = await Promise.all([
sdk.forConsole.account.getPrefs(),
isCloud ? sdk.forConsole.billing.getPlansInfo() : null,
plansArrayPromise,
fetch(`${endpoint}/health/version`, {
headers: { 'X-Appwrite-Project': project as string }
}).then((response) => response.json() as { version?: string }),
sdk.forConsole.console.variables()
]);
const plansInfo = toPlanMap(plansArray);
let fallbackPlansInfoArray = plansInfo;
if (!fallbackPlansInfoArray) {
fallbackPlansInfoArray = makePlansMap(plansArray);
}
const currentOrgId =
preferences.organization ??
@@ -47,28 +58,18 @@ export const load: LayoutLoad = async ({ depends, parent }) => {
}
}
// just in case!
plansInfoStore.set(fallbackPlansInfoArray);
return {
plansInfo,
roles: [],
scopes: [],
preferences,
currentOrgId,
organizations,
consoleVariables,
version: versionData?.version ?? null,
allProjectsCount: projectsCount
allProjectsCount: projectsCount,
plansInfo: fallbackPlansInfoArray,
version: versionData?.version ?? null
};
};
function toPlanMap(plansArray: PlanList | null): Map<Tier, Plan> {
const map = new Map<Tier, Plan>();
if (!plansArray?.plans.length) return map;
const plans = plansArray.plans;
for (let i = 0; i < plans.length; i++) {
const plan = plans[i];
map.set(plan.$id as Tier, plan);
}
return map;
}
@@ -15,10 +15,8 @@
import { isCloud } from '$lib/system';
import { Badge, Skeleton } from '@appwrite.io/pink-svelte';
import type { Models } from '@appwrite.io/console';
import type { Organization } from '$lib/stores/organization';
import { daysLeftInTrial, plansInfo, tierToPlan, type Tier } from '$lib/stores/billing';
import { daysLeftInTrial, billingIdToPlan } from '$lib/stores/billing';
import { toLocaleDate } from '$lib/helpers/date';
import { BillingPlan } from '$lib/constants';
import { goto } from '$app/navigation';
import { Icon, Tooltip, Typography } from '@appwrite.io/pink-svelte';
import { IconPlus } from '@appwrite.io/pink-icons-svelte';
@@ -40,16 +38,18 @@
if (!billingPlan) return 'Unknown';
// For known plans, use tierToPlan
const tierData = tierToPlan(billingPlan as Tier);
const tierData = billingIdToPlan(billingPlan);
// If it's not a custom plan or we got a non-custom result, return the name
// If it's not a custom plan, or we got a non-custom result, return the name
if (tierData.name !== 'Custom') {
return tierData.name;
}
// For custom plans, fetch from API
try {
const plan = await sdk.forConsole.billing.getPlan(billingPlan);
const plan = await sdk.forConsole.console.getPlan({
planId: billingPlan
});
return plan.name;
} catch (error) {
// Fallback to 'Custom' if fetch fails
@@ -57,33 +57,33 @@
}
}
function isOrganizationOnTrial(organization: Organization): boolean {
function isOrganizationOnTrial(organization: Models.Organization): boolean {
if (!organization?.billingTrialStartDate) return false;
if ($daysLeftInTrial <= 0) return false;
if (organization.billingPlan === BillingPlan.FREE) return false;
if (!organization.billingPlanDetails.trial) return false;
return !!$plansInfo.get(organization.billingPlan)?.trialDays;
return !!organization?.billingTrialDays;
}
function isNonPayingOrganization(organization: Organization): boolean {
return (
organization?.billingPlan === BillingPlan.FREE ||
organization?.billingPlan === BillingPlan.GITHUB_EDUCATION
);
function isNonPayingOrganization(organization: Models.Organization): boolean {
// plan doesn't require payments, it is a non-paying org!
return !organization?.billingPlanDetails.requiresPaymentMethod;
}
function isPayingOrganization(team: Models.Preferences | Organization): Organization | null {
function isPayingOrganization(
team: Models.Preferences | Models.Organization
): Models.Organization | null {
const isPayingOrganization =
isCloudOrg(team) && !isOrganizationOnTrial(team) && !isNonPayingOrganization(team);
if (isPayingOrganization) return team as Organization;
if (isPayingOrganization) return team as Models.Organization;
else return null;
}
function isCloudOrg(
data: Partial<Models.TeamList<Models.Preferences>> | Organization
): data is Organization {
return isCloud && 'billingPlan' in data;
data: Partial<Models.TeamList<Models.Preferences>> | Models.Organization
): data is Models.Organization {
return isCloud && 'billingPlanId' in data;
}
function createOrg() {
@@ -114,7 +114,7 @@
{@const avatarList = getMemberships(organization.$id)}
{@const payingOrg = isPayingOrganization(organization)}
{@const planName = isCloudOrg(organization)
? getPlanName(organization.billingPlan)
? getPlanName(organization.billingPlanId)
: null}
<GridItem1 href={`${base}/organization-${organization.$id}`}>
@@ -1,25 +1,17 @@
import { Query, Platform } from '@appwrite.io/console';
import { sdk } from '$lib/stores/sdk';
import { getLimit, getPage, pageToOffset } from '$lib/helpers/load';
import { CARD_LIMIT } from '$lib/constants';
import type { PageLoad } from './$types';
import { isCloud } from '$lib/system';
import { CARD_LIMIT } from '$lib/constants';
import { Query } from '@appwrite.io/console';
import { getLimit, getPage, pageToOffset } from '$lib/helpers/load';
import { getTeamOrOrganizationList } from '$lib/stores/organization';
export const load: PageLoad = async ({ url, route }) => {
const page = getPage(url);
const limit = getLimit(url, route, CARD_LIMIT);
const offset = pageToOffset(page, limit);
const queries = [
Query.offset(offset),
Query.limit(limit),
Query.orderDesc(''),
...(isCloud ? [Query.equal('platform', Platform.Appwrite)] : [])
];
const queries = [Query.offset(offset), Query.limit(limit), Query.orderDesc('')];
const organizations = !isCloud
? await sdk.forConsole.teams.list({ queries })
: await sdk.forConsole.billing.listOrganization(queries);
const organizations = await getTeamOrOrganizationList(queries);
return {
offset,
@@ -16,7 +16,9 @@
if (page.url.searchParams.has('clientSecret')) {
const clientSecret = page.url.searchParams.get('clientSecret');
const paymentMethodId = page.url.searchParams.get('paymentMethodId');
await confirmPayment('', clientSecret, paymentMethodId);
if (clientSecret && paymentMethodId) {
await confirmPayment({ clientSecret, paymentMethodId });
}
}
});
</script>
@@ -7,8 +7,8 @@ export const load: PageLoad = async ({ depends }) => {
depends(Dependencies.ADDRESS);
const [paymentMethods, addressList, countryList, locale] = await Promise.all([
sdk.forConsole.billing.listPaymentMethods(),
sdk.forConsole.billing.listAddresses(),
sdk.forConsole.account.listPaymentMethods(),
sdk.forConsole.account.listBillingAddresses(),
sdk.forConsole.locale.listCountries(),
sdk.forConsole.locale.get()
]);
@@ -5,7 +5,6 @@
import { Dependencies } from '$lib/constants';
import { Button, InputSelect, InputText } from '$lib/elements/forms';
import { addNotification } from '$lib/stores/notifications';
import type { Organization } from '$lib/stores/organization';
import { sdk } from '$lib/stores/sdk';
import { onMount } from 'svelte';
import type { Models } from '@appwrite.io/console';
@@ -43,18 +42,22 @@
async function handleSubmit() {
try {
const response = await sdk.forConsole.billing.createAddress(
const response = await sdk.forConsole.account.createBillingAddress({
country,
address,
streetAddress: address,
city,
state,
zip ? zip : undefined,
address2 ? address2 : undefined
);
postalCode: zip ? zip : undefined,
addressLine2: address2 ? address2 : undefined
});
trackEvent(Submit.BillingAddressCreate);
let org: Organization = null;
let org: Models.Organization = null;
if (organization) {
org = await sdk.forConsole.billing.setBillingAddress(organization, response.$id);
org = await sdk.forConsole.organizations.setBillingAddress({
organizationId: organization,
billingAddressId: response.$id
});
trackEvent(Submit.OrganizationBillingAddressUpdate);
await invalidate(Dependencies.ORGANIZATIONS);
}
@@ -6,8 +6,7 @@
import type { Models } from '@appwrite.io/console';
import DeleteAddress from './deleteAddressModal.svelte';
import EditAddressModal from './editAddressModal.svelte';
import type { Address } from '$lib/sdk/billing';
import { organizationList, type Organization } from '$lib/stores/organization';
import { organizationList } from '$lib/stores/organization';
import { base } from '$app/paths';
import {
IconDotsHorizontal,
@@ -35,11 +34,11 @@
let show = false;
let showEdit = false;
let selectedAddress: Address;
let selectedLinkedOrgs: Organization[] = [];
let selectedAddress: Models.BillingAddress;
let selectedLinkedOrgs: Array<Models.Organization> = [];
let showDelete = false;
$: orgList = $organizationList.teams as unknown as Organization[];
$: orgList = $organizationList.teams as unknown as Array<Models.Organization>;
</script>
<CardGrid>
@@ -4,20 +4,23 @@
import { Submit, trackEvent, trackError } from '$lib/actions/analytics';
import Confirm from '$lib/components/confirm.svelte';
import { Dependencies } from '$lib/constants';
import type { Address } from '$lib/sdk/billing';
import { addNotification } from '$lib/stores/notifications';
import type { Organization } from '$lib/stores/organization';
import { sdk } from '$lib/stores/sdk';
import { Layout, Link } from '@appwrite.io/pink-svelte';
import type { Models } from '@appwrite.io/console';
export let showDelete = false;
export let selectedAddress: Address;
export let linkedOrgs: Organization[] = [];
export let linkedOrgs: Array<Models.Organization> = [];
export let selectedAddress: Models.BillingAddress;
let error: string = null;
async function handleDelete() {
try {
await sdk.forConsole.billing.deleteAddress(selectedAddress.$id);
await sdk.forConsole.account.deleteBillingAddress({
billingAddressId: selectedAddress.$id
});
await invalidate(Dependencies.PAYMENT_METHODS);
showDelete = false;
addNotification({
@@ -5,19 +5,21 @@
import Confirm from '$lib/components/confirm.svelte';
import { Dependencies } from '$lib/constants';
import { addNotification } from '$lib/stores/notifications';
import type { Organization } from '$lib/stores/organization';
import { sdk } from '$lib/stores/sdk';
import { Layout, Link, Typography } from '@appwrite.io/pink-svelte';
import type { Models } from '@appwrite.io/console';
export let linkedOrgs: Organization[] = [];
export let showDelete = false;
export let method: string;
export let showDelete = false;
export let linkedOrgs: Array<Models.Organization> = [];
let error: string;
async function handleDelete() {
try {
await sdk.forConsole.billing.deletePaymentMethod(method);
await sdk.forConsole.account.deletePaymentMethod({
paymentMethodId: method
});
await invalidate(Dependencies.PAYMENT_METHODS);
showDelete = false;
addNotification({
@@ -4,16 +4,15 @@
import { Modal } from '$lib/components';
import { Dependencies } from '$lib/constants';
import { Button, InputSelect, InputText } from '$lib/elements/forms';
import type { Address } from '$lib/sdk/billing';
import { addNotification } from '$lib/stores/notifications';
import { sdk } from '$lib/stores/sdk';
import { onMount } from 'svelte';
import type { Models } from '@appwrite.io/console';
export let show = false;
export let selectedAddress: Address;
export let locale: Models.Locale;
export let countryList: Models.CountryList;
export let selectedAddress: Models.BillingAddress;
let error: string = null;
let options = [
@@ -34,20 +33,22 @@
async function handleSubmit() {
try {
await sdk.forConsole.billing.updateAddress(
selectedAddress.$id,
selectedAddress.country,
selectedAddress.streetAddress,
selectedAddress.city,
selectedAddress.state,
selectedAddress.postalCode ? selectedAddress.postalCode : undefined,
selectedAddress.addressLine2 ? selectedAddress.addressLine2 : undefined
);
await sdk.forConsole.account.updateBillingAddress({
billingAddressId: selectedAddress.$id,
country: selectedAddress.country,
streetAddress: selectedAddress.streetAddress,
city: selectedAddress.city,
state: selectedAddress.state,
postalCode: selectedAddress.postalCode ? selectedAddress.postalCode : undefined,
addressLine2: selectedAddress.addressLine2
? selectedAddress.addressLine2
: undefined
});
await invalidate(Dependencies.ADDRESS);
show = false;
addNotification({
type: 'success',
message: `Address has been added`
message: 'Address has been updated'
});
trackEvent(Submit.BillingAddressUpdate);
} catch (e) {
@@ -5,9 +5,9 @@
import { Dependencies } from '$lib/constants';
import { addNotification } from '$lib/stores/notifications';
import { sdk } from '$lib/stores/sdk';
import type { PaymentMethodData } from '$lib/sdk/billing';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { Alert } from '@appwrite.io/pink-svelte';
import type { Models } from '@appwrite.io/console';
let {
show = $bindable(false),
@@ -16,7 +16,7 @@
}: {
show: boolean;
isLinked?: boolean;
selectedPaymentMethod: PaymentMethodData;
selectedPaymentMethod: Models.PaymentMethod;
} = $props();
let year: number | null = $state(null);
@@ -24,6 +24,7 @@
let error: string | null = $state(null);
const currentYear = new Date().getFullYear();
const months = Array.from({ length: 12 }, (_, i) => {
const value = String(i + 1).padStart(2, '0');
return { value, label: value };
@@ -33,11 +34,14 @@
async function handleSubmit() {
try {
await sdk.forConsole.billing.updatePaymentMethod(
selectedPaymentMethod.$id,
month,
year?.toString()
);
await sdk.forConsole.account.updatePaymentMethod({
paymentMethodId: selectedPaymentMethod.$id,
expiryMonth: parseInt(month),
expiryYear: year
});
trackEvent(Submit.PaymentMethodUpdate);
invalidate(Dependencies.PAYMENT_METHODS);
show = false;
trackEvent(Submit.PaymentMethodUpdate);
await invalidate(Dependencies.PAYMENT_METHODS);
@@ -2,8 +2,7 @@
import { CardGrid, CreditCardInfo, Empty } from '$lib/components';
import { Button } from '$lib/elements/forms';
import { paymentMethods } from '$lib/stores/billing';
import type { PaymentMethodData } from '$lib/sdk/billing';
import { organizationList, type Organization } from '$lib/stores/organization';
import { organizationList } from '$lib/stores/organization';
import { base } from '$app/paths';
import EditPaymentModal from './editPaymentModal.svelte';
import DeletePaymentModal from './deletePaymentModal.svelte';
@@ -27,24 +26,23 @@
Tag,
Typography
} from '@appwrite.io/pink-svelte';
import type { Models } from '@appwrite.io/console';
export let showPayment = false;
let showDropdown = [];
let selectedMethod: PaymentMethodData;
let selectedLinkedOrgs: Organization[] = [];
let selectedMethod: Models.PaymentMethod;
let selectedLinkedOrgs: Array<Models.Organization> = [];
let showDelete = false;
let showEdit = false;
let showUpdateState = false;
let paymentMethodNeedingState: PaymentMethodData | null = null;
let paymentMethodNeedingState: Models.PaymentMethod | null = null;
let isLinked = false;
$: orgList = $organizationList.teams as unknown as Organization[];
$: orgList = $organizationList.teams as unknown as Array<Models.Organization>;
$: filteredMethods = $paymentMethods?.paymentMethods.filter(
(method: PaymentMethodData) => !!method?.last4
);
$: filteredMethods = $paymentMethods?.paymentMethods.filter((method) => !!method?.last4);
const isMethodLinkedToOrg = (methodId: string, org: Organization) =>
const isMethodLinkedToOrg = (methodId: string, org: Models.Organization) =>
methodId === org.paymentMethodId || methodId === org.backupPaymentMethodId;
$: linkedMethodIds = new Set(
@@ -57,7 +55,7 @@
$: {
if ($paymentMethods?.paymentMethods && !showUpdateState && !paymentMethodNeedingState) {
const usMethodWithoutState = $paymentMethods.paymentMethods.find(
(method: PaymentMethodData) =>
(method: Models.PaymentMethod) =>
method?.country?.toLowerCase() === 'us' &&
(!method.state || method.state.trim() === '') &&
!!method.last4
+97 -62
View File
@@ -1,26 +1,26 @@
<script lang="ts">
import { afterNavigate, goto, invalidate } from '$app/navigation';
import { base } from '$app/paths';
import { base, resolve } from '$app/paths';
import { page } from '$app/state';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { CreditsApplied, SelectPaymentMethod } from '$lib/components/billing';
import { BillingPlan, Dependencies } from '$lib/constants';
import { Dependencies } from '$lib/constants';
import { Button, Form, InputSelect, InputTags, InputText } from '$lib/elements/forms';
import { toLocaleDate } from '$lib/helpers/date';
import { Wizard } from '$lib/layout';
import type { PaymentList, Plan } from '$lib/sdk/billing';
import { addNotification } from '$lib/stores/notifications';
import {
organizationList,
type Organization,
type OrganizationError
} from '$lib/stores/organization';
import { organizationList } from '$lib/stores/organization';
import { sdk } from '$lib/stores/sdk';
import { confirmPayment } from '$lib/stores/stripe.js';
import { ID } from '@appwrite.io/console';
import { BillingPlanGroup, ID, type Models } from '@appwrite.io/console';
import { onMount } from 'svelte';
import { writable } from 'svelte/store';
import { isOrganization, plansInfo, type Tier } from '$lib/stores/billing';
import {
billingIdToPlan,
getBasePlanFromGroup,
isPaymentAuthenticationRequired,
plansInfo
} from '$lib/stores/billing';
import { Fieldset, Icon, Layout, Tooltip } from '@appwrite.io/pink-svelte';
import { IconInfo } from '@appwrite.io/pink-icons-svelte';
import EstimatedTotalBox from '$lib/components/billing/estimatedTotalBox.svelte';
@@ -48,7 +48,7 @@
let formComponent: Form;
let couponForm: Form;
let isSubmitting = writable(false);
let methods: PaymentList;
let methods: Models.PaymentMethodList;
let paymentMethodId: string;
let collaborators: string[];
let taxId: string;
@@ -68,16 +68,18 @@
let coupon: string;
let couponData = data?.couponData;
let campaign = data?.campaign;
let billingPlan: Tier = BillingPlan.PRO;
let tempOrgId = null;
let currentPlan: Plan;
let billingPlan = getBasePlanFromGroup(BillingPlanGroup.Pro);
let currentPlan: Models.BillingPlan;
$: onlyNewOrgs = campaign?.onlyNewOrgs || couponData?.onlyNewOrgs;
$: selectedOrgId = tempOrgId;
function isUpgrade() {
const newPlan = $plansInfo.get(billingPlan);
const newPlan = $plansInfo.get(billingPlan.$id);
return currentPlan && newPlan && currentPlan.order < newPlan.order;
}
@@ -86,12 +88,15 @@
if (!$organizationList?.total || campaign?.onlyNewOrgs) {
selectedOrgId = newOrgId;
}
if (page.url.searchParams.has('org')) {
selectedOrgId = page.url.searchParams.get('org');
tempOrgId = selectedOrgId;
canSelectOrg = false;
}
if (campaign?.plan) {
billingPlan = campaign.plan;
billingPlan = billingIdToPlan(campaign.plan);
}
if ($organizationList.total > 0 && tempOrgId === null) {
@@ -100,10 +105,17 @@
if (page.url.searchParams.has('type')) {
const type = page.url.searchParams.get('type');
if (type === 'payment_confirmed') {
const organizationId = page.url.searchParams.get('id');
collaborators = page.url.searchParams.get('invites').split(',');
const organizationId =
page.url.searchParams.get('org') || page.url.searchParams.get('id');
const invites = page.url.searchParams.get('invites');
if (invites) {
collaborators = invites.split(',');
}
await sdk.forConsole.billing.validateOrganization(organizationId, collaborators);
await sdk.forConsole.organizations.validatePayment({
organizationId,
invites: collaborators
});
}
}
@@ -113,7 +125,7 @@
});
async function loadPaymentMethods() {
const methodList = await sdk.forConsole.billing.listPaymentMethods();
const methodList = await sdk.forConsole.account.listPaymentMethods();
const filteredMethods = methodList.paymentMethods.filter((method) => !!method?.last4);
methods = { paymentMethods: filteredMethods, total: filteredMethods.length };
paymentMethodId =
@@ -126,60 +138,66 @@
if (couponForm && !couponForm.checkValidity()) return;
isSubmitting.set(true);
try {
let org: Organization | OrganizationError;
let org: Models.Organization | Models.PaymentAuthentication;
// Create new org
if (selectedOrgId === newOrgId) {
org = await sdk.forConsole.billing.createOrganization(
newOrgId,
org = await sdk.forConsole.organizations.create({
organizationId: newOrgId,
name,
billingPlan,
billingPlan: billingPlan.$id,
paymentMethodId,
undefined,
couponData.code ? couponData.code : null,
collaborators,
billingBudget,
taxId
);
invites: collaborators,
couponId: couponData.code ? couponData.code : null,
taxId,
budget: billingBudget
});
}
// Upgrade existing org
else if (selectedOrg?.billingPlan !== billingPlan && isUpgrade()) {
org = await sdk.forConsole.billing.updatePlan(
selectedOrg.$id,
billingPlan,
else if (selectedOrg?.billingPlanId !== billingPlan.$id && isUpgrade()) {
org = await sdk.forConsole.organizations.updatePlan({
organizationId: selectedOrg.$id,
billingPlan: billingPlan.$id,
paymentMethodId,
undefined,
couponData.code ? couponData.code : null,
collaborators
);
invites: collaborators,
couponId: couponData.code ? couponData.code : null
});
}
// Existing pro org, apply credits
else {
org = selectedOrg;
await sdk.forConsole.billing.addCredit(org.$id, couponData.code);
await sdk.forConsole.organizations.addCredit({
organizationId: org.$id,
couponId: couponData.code
});
}
if (!isOrganization(org) && org.status === 402) {
if (isPaymentAuthenticationRequired(org)) {
let clientSecret = org.clientSecret;
let params = new URLSearchParams();
params.append('type', 'payment_confirmed');
params.append('org', org.teamId);
params.append('org', org.organizationId);
for (const [key, value] of page.url.searchParams.entries()) {
if (key !== 'type' && key !== 'id') {
params.append(key, value);
}
}
params.append('invites', collaborators.join(','));
await confirmPayment(
'',
const resolvedUrl = resolve('/(console)/apply-credit');
await confirmPayment({
clientSecret,
paymentMethodId,
`${base}/apply-credit?${params}`
);
org = await sdk.forConsole.billing.validateOrganization(org.teamId, collaborators);
route: `${resolvedUrl}?${params}`
});
org = await sdk.forConsole.organizations.validatePayment({
organizationId: org.organizationId,
invites: collaborators
});
}
if (isOrganization(org)) {
if (!isPaymentAuthenticationRequired(org)) {
trackEvent(Submit.CreditRedeem, {
coupon: couponData.code,
campaign: couponData?.campaign
@@ -205,8 +223,10 @@
async function addCoupon() {
try {
const response = await sdk.forConsole.billing.getCouponAccount(coupon);
couponData = response;
couponData = await sdk.forConsole.console.getCoupon({
couponId: coupon
});
coupon = null;
addNotification({
type: 'success',
@@ -222,21 +242,21 @@
$: selectedOrg = $organizationList?.teams?.find(
(team) => team.$id === selectedOrgId
) as Organization;
) as Models.Organization;
function getBillingPlan(): Tier | undefined {
const campaignPlan =
campaign?.plan && $plansInfo.get(campaign.plan) ? $plansInfo.get(campaign.plan) : null;
const newPlan = $plansInfo.get(billingPlan);
function getBillingPlan(): Models.BillingPlan | undefined {
const newPlan = billingIdToPlan(billingPlan.$id);
const planInCache = billingIdToPlan(campaign.plan);
const campaignPlan = campaign?.plan && planInCache ? planInCache : null;
// if campaign has a plan and it's higher than the selected new plan
// if campaign has a plan, and it's higher than the selected new plan
if (campaignPlan?.order > newPlan?.order) {
return campaignPlan.$id as Tier;
return campaignPlan;
}
// if current plan's order is higher than the selected new plan
if (currentPlan?.order > newPlan?.order) {
return currentPlan.$id as Tier;
return currentPlan;
}
return billingPlan;
@@ -250,7 +270,9 @@
$: if (!isNewOrg) {
(async () => {
currentPlan = await sdk.forConsole.billing.getOrganizationPlan(selectedOrgId);
currentPlan = await sdk.forConsole.organizations.getPlan({
organizationId: selectedOrgId
});
})();
loadPaymentMethods();
}
@@ -262,6 +284,15 @@
) {
loadPaymentMethods();
}
/* check if payment method selection is needed */
$: needsPaymentMethod = selectedOrgId && selectedOrg?.billingPlanDetails.requiresPaymentMethod;
/* check if coupon code input should be shown */
$: needsCouponInput = !data?.couponData?.code && selectedOrgId;
/* show payment section if either payment method or coupon input is needed */
$: showPaymentSection = needsPaymentMethod || needsCouponInput;
</script>
<svelte:head>
@@ -283,7 +314,9 @@
placeholder="Select organization"
id="organization" />
{/if}
{#if selectedOrgId && (selectedOrg?.billingPlan !== BillingPlan.PRO || !selectedOrg?.paymentMethodId)}
<!-- show invite members -->
{#if selectedOrgId && !selectedOrg?.billingPlanDetails.addons.seats.supported}
{#if selectedOrgId === newOrgId}
<InputText
label="Organization name"
@@ -292,6 +325,7 @@
required
bind:value={name} />
{/if}
<InputTags
bind:tags={collaborators}
label="Invite members by email"
@@ -309,17 +343,18 @@
{/if}
</Layout.Stack>
</Fieldset>
{#if (selectedOrgId && (selectedOrg?.billingPlan !== BillingPlan.PRO || !selectedOrg?.paymentMethodId)) || (!data?.couponData?.code && selectedOrgId)}
{#if showPaymentSection}
<Fieldset legend="Payment">
<Layout.Stack gap="xl">
{#if selectedOrgId && (selectedOrg?.billingPlan !== BillingPlan.PRO || !selectedOrg?.paymentMethodId)}
{#if needsPaymentMethod}
<SelectPaymentMethod
bind:methods
bind:value={paymentMethodId}
bind:taxId />
{/if}
<Form bind:this={couponForm} onSubmit={addCoupon}>
{#if !data?.couponData?.code && selectedOrgId}
{#if needsCouponInput}
<Layout.Stack gap="s" direction="row" alignItems="flex-end">
<InputText
required
@@ -347,7 +382,7 @@
</Form>
</Layout.Stack>
<svelte:fragment slot="aside">
{#if selectedOrg?.$id && selectedOrg?.billingPlan === billingPlan}
{#if selectedOrg?.$id && selectedOrg?.billingPlanId === billingPlan.$id}
<section
class="card"
style:--p-card-padding="1.5rem"
+15 -12
View File
@@ -1,38 +1,41 @@
import { base } from '$app/paths';
import type { Coupon } from '$lib/sdk/billing.js';
import type { Campaign } from '$lib/stores/campaigns.js';
import { resolve } from '$app/paths';
import { sdk } from '$lib/stores/sdk.js';
import { redirect } from '@sveltejs/kit';
import type { Models } from '@appwrite.io/console';
export const load = async ({ url }) => {
// Has promo code
if (url.searchParams.has('code')) {
let couponData: Coupon;
let campaign: Campaign;
let couponData: Models.Coupon;
let campaign: Models.Campaign;
const code = url.searchParams.get('code');
try {
couponData = await sdk.forConsole.billing.getCouponAccount(code);
couponData = await sdk.forConsole.console.getCoupon({
couponId: code
});
if (couponData.campaign) {
campaign = await sdk.forConsole.billing.getCampaign(couponData.campaign);
campaign = await sdk.forConsole.console.getCampaign({
campaignId: couponData.campaign
});
}
return { couponData, campaign };
} catch (e) {
redirect(303, base);
redirect(303, resolve('/'));
}
}
// Has campaign
else if (url.searchParams.has('campaign')) {
const campaignId = url.searchParams.get('campaign');
let campaign: Campaign;
let campaign: Models.Campaign;
try {
campaign = await sdk.forConsole.billing.getCampaign(campaignId);
campaign = await sdk.forConsole.console.getCampaign({ campaignId });
return { campaign };
} catch (e) {
redirect(303, base);
redirect(303, resolve('/'));
}
}
// No campaign or promo code
else {
redirect(303, base);
redirect(303, resolve('/'));
}
};
@@ -1,20 +1,22 @@
<script lang="ts">
import { afterNavigate, goto, invalidate, preloadData } from '$app/navigation';
import { base, resolve } from '$app/paths';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { PlanComparisonBox, PlanSelection, SelectPaymentMethod } from '$lib/components/billing';
import ValidateCreditModal from '$lib/components/billing/validateCreditModal.svelte';
import { BillingPlan, Dependencies } from '$lib/constants';
import { Dependencies } from '$lib/constants';
import { Button, Form, InputTags, InputText } from '$lib/elements/forms';
import { Wizard } from '$lib/layout';
import type { Coupon } from '$lib/sdk/billing';
import { isOrganization, tierToPlan } from '$lib/stores/billing';
import {
billingIdToPlan,
getBasePlanFromGroup,
isPaymentAuthenticationRequired
} from '$lib/stores/billing';
import { addNotification } from '$lib/stores/notifications';
import type { OrganizationError, Organization } from '$lib/stores/organization';
import { sdk } from '$lib/stores/sdk';
import { confirmPayment } from '$lib/stores/stripe';
import { ID } from '@appwrite.io/console';
import { BillingPlanGroup, ID, type Models } from '@appwrite.io/console';
import { IconPlus } from '@appwrite.io/pink-icons-svelte';
import { Divider, Fieldset, Icon, Layout, Link, Typography } from '@appwrite.io/pink-svelte';
import { writable } from 'svelte/store';
@@ -25,9 +27,9 @@
const { data }: PageProps = $props();
let showExitModal = $state(false);
let selectedPlan = $state(data.plan);
let previousPage: string = $state(resolve('/(console)'));
let selectedPlan: BillingPlan = $state(data.plan as BillingPlan);
let selectedCoupon: Partial<Coupon> | null = $state(data.coupon);
let selectedCoupon: Partial<Models.Coupon> | null = $state(data.coupon);
let isSubmitting = $state(writable(false));
let formComponent: Form | null = $state(null);
@@ -36,7 +38,6 @@
let taxId: string | null = $state(null);
let collaborators: string[] = $state([]);
let paymentMethodId: string | null = $state(null);
let billingPlan: BillingPlan = $state(BillingPlan.FREE);
let showCreditModal = $state(false);
let billingBudget: number | undefined = $state(undefined);
@@ -49,7 +50,9 @@
if (page.url.searchParams.has('coupon')) {
const coupon = page.url.searchParams.get('coupon');
try {
selectedCoupon = await sdk.forConsole.billing.getCouponAccount(coupon);
selectedCoupon = await sdk.forConsole.console.getCoupon({
couponId: coupon
});
} catch (e) {
selectedCoupon = {
code: null,
@@ -61,18 +64,21 @@
if (page.url.searchParams.has('name')) {
name = page.url.searchParams.get('name');
}
if (page.url.searchParams.has('plan')) {
const plan = page.url.searchParams.get('plan');
if (plan && Object.values(BillingPlan).includes(plan as BillingPlan)) {
billingPlan = plan as BillingPlan;
if (plan) {
selectedPlan = billingIdToPlan(plan);
}
}
if (
data?.hasFreeOrganizations ||
(page.url.searchParams.has('type') && page.url.searchParams.get('type') === 'createPro')
) {
billingPlan = BillingPlan.PRO;
selectedPlan = getBasePlanFromGroup(BillingPlanGroup.Pro);
}
if (page.url.searchParams.has('type')) {
const type = page.url.searchParams.get('type');
if (type === 'payment_confirmed') {
@@ -83,12 +89,24 @@
}
});
async function preloadAndNavigate(organizationId: string) {
const resolvedUrl = resolve('/(console)/organization-[organization]', {
organization: organizationId
});
await preloadData(resolvedUrl);
await goto(resolvedUrl);
}
async function validate(organizationId: string, invites: string[]) {
try {
const org = await sdk.forConsole.billing.validateOrganization(organizationId, invites);
if (isOrganization(org)) {
await preloadData(`${base}/console/organization-${org.$id}`);
await goto(`${base}/console/organization-${org.$id}`);
const org = await sdk.forConsole.organizations.validatePayment({
organizationId,
invites
});
if (!isPaymentAuthenticationRequired(org)) {
await preloadAndNavigate(org.$id);
addNotification({
type: 'success',
message: `${org.name ?? 'Organization'} has been created`
@@ -105,59 +123,58 @@
async function create() {
try {
let org: Organization | OrganizationError;
let org: Models.Organization | Models.PaymentAuthentication;
if (selectedPlan === BillingPlan.FREE) {
org = await sdk.forConsole.billing.createOrganization(
ID.unique(),
name,
BillingPlan.FREE,
null
);
if (selectedPlan.group === BillingPlanGroup.Starter) {
org = await sdk.forConsole.organizations.create({
organizationId: ID.unique(),
name: name,
billingPlan: getBasePlanFromGroup(BillingPlanGroup.Starter).$id
});
} else {
org = await sdk.forConsole.billing.createOrganization(
ID.unique(),
org = await sdk.forConsole.organizations.create({
organizationId: ID.unique(),
name,
selectedPlan,
billingPlan: selectedPlan.$id,
paymentMethodId,
undefined,
selectedCoupon?.code,
collaborators,
billingBudget,
couponId: selectedCoupon?.code,
invites: collaborators,
budget: billingBudget,
taxId
);
});
if (!isOrganization(org) && org.status === 402) {
if (isPaymentAuthenticationRequired(org)) {
let clientSecret = org.clientSecret;
let params = new URLSearchParams();
params.append('type', 'payment_confirmed');
params.append('id', org.teamId);
params.append('id', org.organizationId);
for (const [key, value] of page.url.searchParams.entries()) {
if (key !== 'type' && key !== 'id') {
params.append(key, value);
}
}
params.append('invites', collaborators.join(','));
await confirmPayment(
'',
const resolvedUrl = resolve('/(console)/create-organization');
await confirmPayment({
clientSecret,
paymentMethodId,
`${base}/create-organization?${params}`
);
await validate(org.teamId, collaborators);
route: `${resolvedUrl}?${params}`
});
await validate(org.organizationId, collaborators);
}
}
trackEvent(Submit.OrganizationCreate, {
plan: tierToPlan(billingPlan)?.name,
plan: selectedPlan.name,
budget_cap_enabled: billingBudget !== null,
members_invited: collaborators?.length
});
if (isOrganization(org)) {
if (!isPaymentAuthenticationRequired(org)) {
await invalidate(Dependencies.ACCOUNT);
await preloadData(`${base}/organization-${org.$id}`);
await goto(`${base}/organization-${org.$id}`);
await preloadAndNavigate(org.$id);
addNotification({
type: 'success',
message: `${org.name ?? 'Organization'} has been created`
@@ -202,11 +219,12 @@
<PlanSelection
isNewOrg
bind:billingPlan={selectedPlan}
bind:selectedBillingPlan={selectedPlan}
anyOrgFree={data.hasFreeOrganizations} />
</Layout.Stack>
</Fieldset>
{#if selectedPlan !== BillingPlan.FREE}
{#if selectedPlan.supportsCredits}
<Fieldset legend="Payment">
<Layout.Stack gap="s" alignItems="flex-start">
<SelectPaymentMethod
@@ -243,7 +261,7 @@
</Layout.Stack>
</Form>
<svelte:fragment slot="aside">
{#if selectedPlan !== BillingPlan.FREE}
{#if selectedPlan.supportsCredits}
<EstimatedTotalBox
billingPlan={selectedPlan}
{collaborators}
@@ -1,24 +1,29 @@
import { BillingPlan, Dependencies } from '$lib/constants';
import { sdk } from '$lib/stores/sdk';
import type { PageLoad } from './$types';
import type { Coupon } from '$lib/sdk/billing';
import type { Organization } from '$lib/stores/organization';
import { BillingPlanGroup, type Models, Platform } from '@appwrite.io/console';
import { Dependencies } from '$lib/constants';
import { billingIdToPlan, getBasePlanFromGroup, getNextTierBillingPlan } from '$lib/stores/billing';
export const load: PageLoad = async ({ url, parent, depends }) => {
const { organizations } = await parent();
depends(Dependencies.ORGANIZATIONS);
const [coupon, paymentMethods, plans] = await Promise.all([
getCoupon(url),
sdk.forConsole.billing.listPaymentMethods(),
sdk.forConsole.billing.listPlans()
sdk.forConsole.account.listPaymentMethods(),
sdk.forConsole.console.getPlans({
platform: Platform.Appwrite
})
]);
let plan = getPlanFromUrl(url);
const hasFreeOrganizations = organizations.teams?.some(
(org) => (org as Organization)?.billingPlan === BillingPlan.FREE
);
if (plan === BillingPlan.FREE && hasFreeOrganizations) {
plan = BillingPlan.PRO;
let plan = await getPlanFromUrl(url);
const hasFreeOrganizations = organizations.teams?.some((org) => {
const organization = org as Models.Organization;
return organization.billingPlanDetails.group === BillingPlanGroup.Starter;
});
if (plan?.group === BillingPlanGroup.Starter && hasFreeOrganizations) {
plan = getNextTierBillingPlan(plan?.$id);
}
return {
@@ -31,21 +36,33 @@ export const load: PageLoad = async ({ url, parent, depends }) => {
};
};
function getPlanFromUrl(url: URL): BillingPlan | null {
async function getPlanFromUrl(url: URL): Promise<Models.BillingPlan | null> {
if (url.searchParams.has('plan')) {
const plan = url.searchParams.get('plan');
if (plan && plan in BillingPlan) {
return plan as BillingPlan;
}
const planId = url.searchParams.get('plan');
// check if available in cache, if not, fetch from API.
return getPlanFromCache(planId) ?? (await sdk.forConsole.console.getPlan({ planId }));
}
return BillingPlan.FREE;
// fallback
const baseStarter = getBasePlanFromGroup(BillingPlanGroup.Starter);
return await sdk.forConsole.console.getPlan({ planId: baseStarter.$id });
}
async function getCoupon(url: URL): Promise<Coupon | null> {
function getPlanFromCache(plan: string): Models.BillingPlan | null {
try {
return billingIdToPlan(plan);
} catch (error) {
return null;
}
}
async function getCoupon(url: URL): Promise<Models.Coupon | null> {
if (url.searchParams.has('code')) {
const coupon = url.searchParams.get('code');
try {
return sdk.forConsole.billing.getCouponAccount(coupon);
return sdk.forConsole.account.getCoupon({
couponId: coupon
});
} catch (e) {
return null;
}
@@ -1,19 +1,19 @@
<script lang="ts">
import { isCloud } from '$lib/system';
import { sdk } from '$lib/stores/sdk';
import { ID } from '@appwrite.io/console';
import { BillingPlan, Dependencies } from '$lib/constants';
import { tierToPlan } from '$lib/stores/billing';
import { BillingPlanGroup, ID } from '@appwrite.io/console';
import { Dependencies } from '$lib/constants';
import { addNotification } from '$lib/stores/notifications';
import { loadAvailableRegions } from '$routes/(console)/regions';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { Button, Card, Layout, Input, Typography, Spinner } from '@appwrite.io/pink-svelte';
import { Form } from '$lib/elements/forms/index.js';
import { goto, invalidate } from '$app/navigation';
import { base } from '$app/paths';
import { resolve } from '$app/paths';
import { getBasePlanFromGroup } from '$lib/stores/billing';
let isLoading = false;
let organizationName = 'Personal Projects';
let isLoading = $state(false);
let organizationName = $state('Personal Projects');
async function createOrganization() {
isLoading = true;
@@ -21,15 +21,15 @@
try {
if (isCloud) {
organization = await sdk.forConsole.billing.createOrganization(
ID.unique(),
organizationName,
BillingPlan.FREE,
null
);
const starter = getBasePlanFromGroup(BillingPlanGroup.Starter);
organization = await sdk.forConsole.organizations.create({
organizationId: ID.unique(),
name: organizationName,
billingPlan: starter.$id
});
trackEvent(Submit.OrganizationCreate, {
plan: tierToPlan(BillingPlan.FREE)?.name,
plan: starter?.name,
budget_cap_enabled: false,
members_invited: 0
});
@@ -48,7 +48,11 @@
} finally {
if (organization) {
loadAvailableRegions(organization?.$id).then();
await goto(`${base}/organization-${organization.$id}`);
await goto(
resolve('/(console)/organization-[organization]', {
organization: organization.$id
})
);
// fixes an edge case where
// the org is not available for some reason!
@@ -2,9 +2,8 @@ import type { PageLoad } from './$types';
import { isCloud } from '$lib/system';
import { sdk } from '$lib/stores/sdk';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { isOrganization, tierToPlan } from '$lib/stores/billing';
import { ID, Query, type Models } from '@appwrite.io/console';
import { BillingPlan } from '$lib/constants';
import { getBasePlanFromGroup, isPaymentAuthenticationRequired } from '$lib/stores/billing';
import { BillingPlanGroup, ID, type Models, Query } from '@appwrite.io/console';
import { redirect } from '@sveltejs/kit';
import { base } from '$app/paths';
@@ -25,28 +24,28 @@ export const load: PageLoad = async ({ parent }) => {
if (!organizations?.total) {
try {
if (isCloud) {
const org = await sdk.forConsole.billing.createOrganization(
ID.unique(),
'Personal projects',
BillingPlan.FREE,
null
);
const starterPlan = getBasePlanFromGroup(BillingPlanGroup.Starter);
const org = await sdk.forConsole.organizations.create({
organizationId: ID.unique(),
name: 'Personal projects',
billingPlan: starterPlan.$id
});
trackEvent(Submit.OrganizationCreate, {
plan: tierToPlan(BillingPlan.FREE)?.name,
plan: starterPlan?.name,
budget_cap_enabled: false,
members_invited: 0
});
if (isOrganization(org)) {
if (!isPaymentAuthenticationRequired(org)) {
return {
accountPrefs,
organization: org
};
} else {
const e = new Error(org.message, {
const error = new Error(org.message, {
cause: org
});
trackError(e, Submit.OrganizationCreate);
trackError(error, Submit.OrganizationCreate);
}
} else {
return {
@@ -11,10 +11,8 @@ import ProjectsAtRisk from '$lib/components/billing/alerts/projectsAtRisk.svelte
import { get } from 'svelte/store';
import { preferences } from '$lib/stores/preferences';
import { defaultRoles, defaultScopes } from '$lib/constants';
import type { Plan } from '$lib/sdk/billing';
import { loadAvailableRegions } from '$routes/(console)/regions';
import type { Organization } from '$lib/stores/organization';
import { Platform } from '@appwrite.io/console';
import { type Models, Platform } from '@appwrite.io/console';
import { resolve } from '$app/paths';
export const load: LayoutLoad = async ({ params, depends, parent }) => {
@@ -28,26 +26,37 @@ export const load: LayoutLoad = async ({ params, depends, parent }) => {
let roles = isCloud ? [] : defaultRoles;
let scopes = isCloud ? [] : defaultScopes;
let currentPlan: Plan = null;
let currentPlan: Models.BillingPlan | null = null;
try {
if (isCloud) {
[{ roles, scopes }, currentPlan] = await Promise.all([
sdk.forConsole.billing.getRoles(params.organization),
sdk.forConsole.billing.getOrganizationPlan(params.organization)
sdk.forConsole.organizations.getScopes({
organizationId: params.organization
}),
sdk.forConsole.organizations.getPlan({
organizationId: params.organization
})
]);
if (scopes.includes('billing.read')) {
loadFailedInvoices(params.organization);
}
}
if (prefs.organization !== params.organization) {
const newPrefs = { ...prefs, organization: params.organization };
sdk.forConsole.account.updatePrefs({ prefs: newPrefs });
}
const program: Models.Program | null =
currentPlan && currentPlan?.program ? currentPlan.program : null;
const [org, members, countryList, locale] = await Promise.all([
sdk.forConsole.teams.get({ teamId: params.organization }) as Promise<Organization>,
sdk.forConsole.teams.get({
teamId: params.organization
}) as Promise<Models.Organization>,
sdk.forConsole.teams.listMemberships({ teamId: params.organization }),
sdk.forConsole.locale.listCountries(),
sdk.forConsole.locale.get(),
@@ -64,7 +73,8 @@ export const load: LayoutLoad = async ({ params, depends, parent }) => {
roles,
scopes,
countryList,
locale
locale,
program
};
} catch (e) {
const newPrefs = { ...prefs, organization: null };
@@ -94,7 +104,7 @@ async function checkPlatformAndRedirect(
) {
// check if preloaded
let requestedOrg = organizations.teams.find((team) => team.$id === params.organization) as
| Organization
| Models.Organization
| undefined;
// not found, load!
@@ -102,7 +112,7 @@ async function checkPlatformAndRedirect(
try {
requestedOrg = (await sdk.forConsole.teams.get({
teamId: params.organization
})) as Organization;
})) as Models.Organization;
} catch (e) {
return null;
}
@@ -133,7 +143,7 @@ async function checkPlatformAndRedirect(
// check if exists and is valid
const orgFromPrefs = (await sdk.forConsole.teams.get({
teamId: orgIdInPrefs
})) as Organization;
})) as Models.Organization;
// exists and is valid, redirect
redirect(
@@ -18,8 +18,7 @@
} from '$lib/components';
import { trackEvent, Click } from '$lib/actions/analytics';
import { type Models } from '@appwrite.io/console';
import { getServiceLimit, readOnly, upgradeURL } from '$lib/stores/billing';
import { BillingPlan } from '$lib/constants';
import { getServiceLimit, readOnly, getChangePlanUrl } from '$lib/stores/billing';
import { hideNotification, shouldShowNotification } from '$lib/helpers/notifications';
import { onMount, type ComponentType } from 'svelte';
import { canWriteProjects } from '$lib/stores/roles';
@@ -38,7 +37,7 @@
import type { PageProps } from './$types';
import { getPlatformInfo } from '$lib/helpers/platform';
import CreateProjectCloud from './createProjectCloud.svelte';
import { currentPlan, regions as regionsStore } from '$lib/stores/organization';
import { regions as regionsStore } from '$lib/stores/organization';
import SelectProjectCloud from '$lib/components/billing/alerts/selectProjectCloud.svelte';
import ArchiveProject from '$lib/components/archiveProject.svelte';
@@ -197,7 +196,7 @@
{/if}
</Layout.Stack>
{#if isCloud && $currentPlan?.projects && $currentPlan?.projects > 0 && data.organization.projects.length > 0 && $canWriteProjects && (projectsToArchive.length > 0 || data.projects.total > $currentPlan.projects)}
{#if isCloud && data.currentPlan?.projects && data.currentPlan?.projects > 0 && data.organization.projects.length > 0 && $canWriteProjects && (projectsToArchive.length > 0 || data.projects.total > data.currentPlan.projects)}
{@const difference = projectsToArchive.length}
{@const messagePrefix =
difference !== 1 ? `${difference} projects are` : `${difference} project is`}
@@ -207,7 +206,7 @@
<Button
compact
size="s"
href={$upgradeURL}
href={getChangePlanUrl(data.organization.$id)}
on:click={() => {
trackEvent(Click.OrganizationClickUpgrade, {
from: 'button',
@@ -220,7 +219,7 @@
</Alert.Inline>
{/if}
{#if isCloud && data.organization.billingPlan === BillingPlan.FREE && projectsToArchive.length === 0 && !freePlanAlertDismissed}
{#if isCloud && data.currentPlan?.projects !== 0 && projectsToArchive.length === 0 && !freePlanAlertDismissed}
<Alert.Inline dismissible on:dismiss={dismissFreePlanAlert}>
<Typography.Text
>Your Free plan includes up to 2 projects and limited resources. Upgrade to unlock
@@ -229,7 +228,7 @@
<Button
compact
size="s"
href={$upgradeURL}
href={getChangePlanUrl(data.organization.$id)}
on:click={() => {
trackEvent(Click.OrganizationClickUpgrade, {
from: 'button',
@@ -336,7 +335,7 @@
<ArchiveProject
{projectsToArchive}
organization={data.organization}
currentPlan={$currentPlan}
currentPlan={data.currentPlan}
archivedTotalOverall={data.archivedTotalOverall}
archivedOffset={data.archivedOffset}
limit={data.limit} />
@@ -344,7 +343,8 @@
<CreateOrganization bind:show={addOrganization} />
<CreateProject bind:show={showCreate} teamId={page.params.organization} />
<CreateProjectCloud
projects={data.projects.total}
bind:showCreateProjectCloud
projects={data.projects.total}
regions={$regionsStore.regions}
teamId={page.params.organization} />
teamId={page.params.organization}
currentPlan={data.currentPlan} />
@@ -8,7 +8,12 @@
import AvailableCredit from './availableCredit.svelte';
import PaymentHistory from './paymentHistory.svelte';
import TaxId from './taxId.svelte';
import { failedInvoice, tierToPlan, upgradeURL, useNewPricingModal } from '$lib/stores/billing';
import {
failedInvoice,
billingIdToPlan,
getChangePlanUrl,
useNewPricingModal
} from '$lib/stores/billing';
import { onMount } from 'svelte';
import { page } from '$app/state';
import { confirmPayment } from '$lib/stores/stripe';
@@ -27,13 +32,13 @@
$: organization = data.organization;
$: baseUrl = resolve('/(console)/organization-[organization]/billing', {
organization: organization.$id
organization: page.params.organization
});
onMount(async () => {
if (page.url.searchParams.has('type')) {
if (page.url.searchParams.get('type') === 'upgrade') {
goto($upgradeURL);
await goto(getChangePlanUrl(page.params.organization));
}
if (
@@ -41,17 +46,17 @@
page.url.searchParams.get('type') === 'confirmation'
) {
const invoiceId = page.url.searchParams.get('invoice');
const invoice = await sdk.forConsole.billing.getInvoice(
page.params.organization,
const invoice = await sdk.forConsole.organizations.getInvoice({
organizationId: page.params.organization,
invoiceId
);
});
await confirmPayment(
organization.$id,
invoice.clientSecret,
organization.paymentMethodId,
`${baseUrl}?type=validate-invoice&invoice=${invoice.$id}`
);
await confirmPayment({
clientSecret: invoice.clientSecret,
paymentMethodId: organization.paymentMethodId,
orgId: organization.$id,
route: `${baseUrl}?type=validate-invoice&invoice=${invoice.$id}`
});
}
if (
@@ -59,9 +64,14 @@
page.url.searchParams.get('type') === 'validate-invoice'
) {
const invoiceId = page.url.searchParams.get('invoice');
await sdk.forConsole.billing.updateInvoiceStatus(organization.$id, invoiceId);
invalidate(Dependencies.INVOICES);
invalidate(Dependencies.ORGANIZATION);
await sdk.forConsole.organizations.validateInvoice({
organizationId: organization.$id,
invoiceId
});
await Promise.all([
invalidate(Dependencies.INVOICES),
invalidate(Dependencies.ORGANIZATION)
]);
}
if (
@@ -69,17 +79,21 @@
page.url.searchParams.get('type') === 'retry'
) {
const invoiceId = page.url.searchParams.get('invoice');
const invoice = await sdk.forConsole.billing.getInvoice(
page.params.organization,
const invoice = await sdk.forConsole.organizations.getInvoice({
organizationId: page.params.organization,
invoiceId
);
});
selectedInvoice.set(invoice);
showRetryModal.set(true);
}
}
if (page.url.searchParams.has('clientSecret')) {
const clientSecret = page.url.searchParams.get('clientSecret');
await confirmPayment(organization.$id, clientSecret, organization.paymentMethodId);
await confirmPayment({
clientSecret,
paymentMethodId: organization.paymentMethodId,
orgId: organization.$id
});
}
});
</script>
@@ -117,41 +131,43 @@
{/if}
{#if organization?.billingPlanDowngrade}
<Alert.Inline status="info">
Your organization has changed to {tierToPlan(organization?.billingPlanDowngrade).name} plan.
You will continue to have access to {tierToPlan(organization?.billingPlan).name} plan features
until your billing period ends on {toLocaleDate(organization.billingNextInvoiceDate)}.
Your organization has changed to {billingIdToPlan(organization?.billingPlanDowngrade)
.name} plan. You will continue to have access to {organization?.billingPlanDetails
?.name} plan features until your billing period ends on {toLocaleDate(
organization.billingNextInvoiceDate
)}.
</Alert.Inline>
{/if}
{#if $useNewPricingModal}
<PlanSummary
availableCredit={data?.availableCredit}
currentPlan={data?.currentPlan}
nextPlan={data?.nextPlan}
currentAggregation={data?.billingAggregation}
limit={data?.limit}
offset={data?.offset} />
availableCredit={data.availableCredit}
currentPlan={data.currentPlan}
nextPlan={data.nextPlan}
currentAggregation={data.billingAggregation}
limit={data.limit}
offset={data.offset} />
{:else}
<PlanSummaryOld
availableCredit={data?.availableCredit}
currentPlan={data?.currentPlan}
currentAggregation={data?.billingAggregation}
currentInvoice={data?.billingInvoice} />
availableCredit={data.availableCredit}
currentPlan={data.currentPlan}
currentAggregation={data.billingAggregation}
currentInvoice={data.billingInvoice} />
{/if}
<PaymentHistory />
<PaymentMethods
methods={data?.paymentMethods}
organization={data?.organization}
organization={data.organization}
paymentMethods={data.paymentMethods}
backupMethod={data.backupPaymentMethod}
primaryMethod={data.primaryPaymentMethod} />
<BillingAddress
organization={data?.organization}
billingAddress={data?.billingAddress}
locale={data?.locale}
countryList={data?.countryList} />
locale={data.locale}
countryList={data.countryList}
organization={data.organization}
billingAddress={data.billingAddress} />
<TaxId />
<BudgetCap organization={data?.organization} currentPlan={data?.currentPlan} />
<BudgetCap organization={data.organization} currentPlan={data.currentPlan} />
<AvailableCredit areCreditsSupported={data.areCreditsSupported} />
</Container>
@@ -1,20 +1,23 @@
import { BillingPlan, DEFAULT_BILLING_PROJECTS_LIMIT, Dependencies } from '$lib/constants';
import type { Address, PaymentList } from '$lib/sdk/billing';
import { type Organization } from '$lib/stores/organization';
import { resolve } from '$app/paths';
import { isCloud } from '$lib/system';
import { sdk } from '$lib/stores/sdk';
import { redirect } from '@sveltejs/kit';
import type { PageLoad } from './$types';
import { isCloud } from '$lib/system';
import { base } from '$app/paths';
import { type PaymentMethodData } from '$lib/sdk/billing';
import { DEFAULT_BILLING_PROJECTS_LIMIT, Dependencies } from '$lib/constants';
import type { Models } from '@appwrite.io/console';
import { getLimit, getPage, pageToOffset } from '$lib/helpers/load';
export const load: PageLoad = async ({ parent, depends, url, route }) => {
const { organization, scopes, currentPlan, countryList, locale } = await parent();
if (!scopes.includes('billing.read')) {
return redirect(301, `${base}/organization-${organization.$id}`);
return redirect(
302,
resolve('/(console)/organization-[organization]', {
organization: organization.$id
})
);
}
depends(Dependencies.PAYMENT_METHODS);
@@ -25,10 +28,13 @@ export const load: PageLoad = async ({ parent, depends, url, route }) => {
// aggregation reloads on page param changes
depends(Dependencies.BILLING_AGGREGATION);
const billingAddressId = (organization as Organization)?.billingAddressId;
const billingAddressPromise: Promise<Address> = billingAddressId
? sdk.forConsole.billing
.getOrganizationBillingAddress(organization.$id, billingAddressId)
const billingAddressId = (organization as Models.Organization)?.billingAddressId;
const billingAddressPromise: Promise<Models.BillingAddress> = billingAddressId
? sdk.forConsole.organizations
.getBillingAddress({
organizationId: organization.$id,
billingAddressId
})
.catch(() => null)
: null;
@@ -37,47 +43,47 @@ export const load: PageLoad = async ({ parent, depends, url, route }) => {
* initially created, these might return 404
* - can be removed later once that is fixed in back-end
*/
let billingAggregation = null;
let billingAggregation: Models.AggregationTeam | null = null;
try {
const currentPage = getPage(url) || 1;
const limit = getLimit(url, route, DEFAULT_BILLING_PROJECTS_LIMIT);
const offset = pageToOffset(currentPage, limit);
billingAggregation = await sdk.forConsole.billing.getAggregation(
organization.$id,
(organization as Organization)?.billingAggregationId,
billingAggregation = await sdk.forConsole.organizations.getAggregation({
organizationId: organization.$id,
aggregationId: (organization as Models.Organization)?.billingAggregationId,
limit,
offset
);
});
} catch (e) {
// ignore error
}
let billingInvoice = null;
try {
billingInvoice = await sdk.forConsole.billing.getInvoice(
organization.$id,
(organization as Organization)?.billingInvoiceId
);
billingInvoice = await sdk.forConsole.organizations.getInvoice({
organizationId: organization.$id,
invoiceId: (organization as Models.Organization)?.billingInvoiceId
});
} catch (e) {
// ignore error
}
const areCreditsSupported = isCloud
? (currentPlan?.supportsCredits ??
(organization.billingPlan !== BillingPlan.FREE &&
organization?.billingPlan !== BillingPlan.GITHUB_EDUCATION))
: false;
const areCreditsSupported = isCloud ? currentPlan?.supportsCredits : false;
const [paymentMethods, addressList, billingAddress, availableCredit, billingPlanDowngrade] =
await Promise.all([
sdk.forConsole.billing.listPaymentMethods(),
sdk.forConsole.billing.listAddresses(),
sdk.forConsole.account.listPaymentMethods(),
sdk.forConsole.account.listBillingAddresses(),
billingAddressPromise,
areCreditsSupported
? sdk.forConsole.billing.getAvailableCredit(organization.$id)
? sdk.forConsole.organizations.getAvailableCredits({
organizationId: organization.$id
})
: null,
organization.billingPlanDowngrade
? sdk.forConsole.billing.getPlan(organization.billingPlanDowngrade)
? sdk.forConsole.console.getPlan({
planId: organization.billingPlanDowngrade
})
: null
]);
@@ -108,14 +114,14 @@ export const load: PageLoad = async ({ parent, depends, url, route }) => {
};
function getOrganizationPaymentMethods(
organization: Organization,
paymentMethods: PaymentList
organization: Models.Organization,
paymentMethods: Models.PaymentMethodList
): {
backup: PaymentMethodData | null;
primary: PaymentMethodData | null;
backup: Models.PaymentMethod | null;
primary: Models.PaymentMethod | null;
} {
let backup: PaymentMethodData | null = null;
let primary: PaymentMethodData | null = null;
let backup: Models.PaymentMethod | null = null;
let primary: Models.PaymentMethod | null = null;
for (const paymentMethod of paymentMethods.paymentMethods) {
if (paymentMethod.$id === organization.paymentMethodId) {
@@ -16,7 +16,10 @@
async function redeem() {
try {
await sdk.forConsole.billing.addCredit($organization.$id, coupon);
await sdk.forConsole.organizations.addCredit({
organizationId: $organization.$id,
couponId: coupon
});
show = false;
await invalidate(Dependencies.CREDIT);
await invalidate(Dependencies.ORGANIZATION);
@@ -18,12 +18,15 @@
async function create() {
try {
await sdk.forConsole.billing.setOrganizationPaymentMethod(
$organization.$id,
$addCreditWizardStore.paymentMethodId
);
await sdk.forConsole.organizations.setDefaultPaymentMethod({
organizationId: $organization.$id,
paymentMethodId: $addCreditWizardStore.paymentMethodId
});
await sdk.forConsole.billing.addCredit($organization.$id, $addCreditWizardStore.coupon);
await sdk.forConsole.organizations.addCredit({
organizationId: $organization.$id,
couponId: $addCreditWizardStore.coupon
});
addNotification({
type: 'success',
message: `Credit has been added to ${$organization.name}`
@@ -1,18 +1,16 @@
<script lang="ts">
import { CardGrid, Empty, PaginationInline } from '$lib/components';
import { toLocaleDate } from '$lib/helpers/date';
import type { CreditList } from '$lib/sdk/billing';
import { organization } from '$lib/stores/organization';
import { sdk } from '$lib/stores/sdk';
import { wizard } from '$lib/stores/wizard';
import { Query } from '@appwrite.io/console';
import { type Models, Query } from '@appwrite.io/console';
import { onMount } from 'svelte';
import { Button } from '$lib/elements/forms';
import AddCreditModal from './addCreditModal.svelte';
import { formatCurrency } from '$lib/helpers/numbers';
import { BillingPlan } from '$lib/constants';
import { Click, trackEvent } from '$lib/actions/analytics';
import { upgradeURL } from '$lib/stores/billing';
import { getChangePlanUrl } from '$lib/stores/billing';
import { Alert, Badge, Icon, Link, Table, Tooltip, Typography } from '@appwrite.io/pink-svelte';
import { IconPlus } from '@appwrite.io/pink-icons-svelte';
@@ -20,14 +18,16 @@
export let areCreditsSupported: boolean;
let offset = 0;
let creditList: CreditList = {
available: 0,
credits: [],
total: 0
};
let show = false;
let reloadOnWizardClose = false;
/* default credits until loaded */
let creditList: Models.CreditList = {
total: 0,
credits: [],
available: 0
};
onMount(request);
const limit = 5;
@@ -44,10 +44,10 @@
* Technically, we could reuse that for offsets < 25 (i.e., the first 5 pages with limit = 5)
* to avoid an extra request. But for now, we always fetch fresh data.
*/
creditList = await sdk.forConsole.billing.listCredits($organization.$id, [
Query.limit(limit),
Query.offset(offset)
]);
creditList = await sdk.forConsole.organizations.listCredits({
organizationId: $organization.$id,
queries: [Query.limit(limit), Query.offset(offset)]
});
creditList = {
...creditList,
@@ -81,7 +81,7 @@
}
</script>
<CardGrid hideFooter={$organization?.billingPlan !== BillingPlan.FREE}>
<CardGrid hideFooter={areCreditsSupported}>
<svelte:fragment slot="title">
{!areCreditsSupported ? 'Credits' : 'Available credit'}
</svelte:fragment>
@@ -171,10 +171,10 @@
{/if}
</svelte:fragment>
<svelte:fragment slot="actions">
{#if $organization?.billingPlan === BillingPlan.FREE}
{#if !areCreditsSupported}
<Button
secondary
href={$upgradeURL}
href={getChangePlanUrl($organization.$id)}
on:click={() => {
trackEvent(Click.OrganizationClickUpgrade, {
from: 'button',
@@ -4,10 +4,8 @@
import { CardGrid } from '$lib/components';
import { Dependencies } from '$lib/constants';
import { Button } from '$lib/elements/forms';
import type { Address } from '$lib/sdk/billing';
import { addressList } from '$lib/stores/billing';
import { addNotification } from '$lib/stores/notifications';
import { type Organization } from '$lib/stores/organization';
import { sdk } from '$lib/stores/sdk';
import RemoveAddress from './removeAddress.svelte';
import { user } from '$lib/stores/user';
@@ -24,10 +22,10 @@
} from '@appwrite.io/pink-icons-svelte';
import type { Models } from '@appwrite.io/console';
export let organization: Organization;
export let locale: Models.Locale;
export let countryList: Models.CountryList;
export let billingAddress: Address;
export let organization: Models.Organization;
export let billingAddress: Models.BillingAddress;
let showCreate = false;
let showEdit = false;
@@ -36,7 +34,10 @@
async function addAddress(addressId: string) {
try {
await sdk.forConsole.billing.setBillingAddress(organization.$id, addressId);
await sdk.forConsole.organizations.setBillingAddress({
organizationId: organization.$id,
billingAddressId: addressId
});
addNotification({
type: 'success',
@@ -2,21 +2,20 @@
import { invalidate } from '$app/navigation';
import { Click, Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { CardGrid } from '$lib/components';
import { BillingPlan, Dependencies } from '$lib/constants';
import { tierToPlan, upgradeURL } from '$lib/stores/billing';
import { Dependencies } from '$lib/constants';
import { getChangePlanUrl } from '$lib/stores/billing';
import { Button, Form } from '$lib/elements/forms';
import { symmetricDifference } from '$lib/helpers/array';
import { addNotification } from '$lib/stores/notifications';
import { type Organization } from '$lib/stores/organization';
import { sdk } from '$lib/stores/sdk';
import { Alert, Icon, Table } from '@appwrite.io/pink-svelte';
import { IconTrash } from '@appwrite.io/pink-icons-svelte';
import InputSelect from '$lib/elements/forms/inputSelect.svelte';
import type { Plan } from '$lib/sdk/billing';
import type { Models } from '@appwrite.io/console';
export let organization: Organization;
export let currentPlan: Plan;
export let alertsEnabled = false;
export let currentPlan: Models.BillingPlan;
export let organization: Models.Organization;
let search: string;
let selectedAlert: number;
@@ -47,19 +46,20 @@
async function updateBudget() {
try {
await sdk.forConsole.billing.updateBudget(
organization.$id,
organization.billingBudget,
await sdk.forConsole.organizations.updateBudget({
organizationId: organization.$id,
budget: organization.billingBudget,
alerts
);
});
invalidate(Dependencies.ORGANIZATION);
await invalidate(Dependencies.ORGANIZATION);
addNotification({
type: 'success',
isHtml: true,
message: `<span> ${alerts.length === 0 ? 'Budget alerts removed from' : alerts.length > 1 ? `Budget alerts added to` : 'A budget alert has been added to'} <b>${organization.name}</b> </span>`
});
trackEvent(Submit.BudgetAlertsUpdate, {
alerts
});
@@ -80,8 +80,8 @@
<svelte:fragment slot="title">Billing alerts</svelte:fragment>
{#if !currentPlan.budgeting}
Get notified by email when your organization meets a percentage of your budget cap. <b
>{tierToPlan(organization.billingPlan).name} organizations will receive one notification
at 75% resource usage.</b>
>{organization.billingPlanDetails.name} organizations will receive one notification at 75%
resource usage.</b>
{:else}
Get notified by email when your organization meets or exceeds a percentage of your specified
billing alert(s).
@@ -148,10 +148,10 @@
<svelte:fragment slot="actions">
<Form onSubmit={updateBudget}>
{#if organization?.billingPlan === BillingPlan.FREE || organization?.billingPlan === BillingPlan.GITHUB_EDUCATION}
{#if !currentPlan.budgeting}
<Button
secondary
href={$upgradeURL}
href={getChangePlanUrl(organization.$id)}
on:click={() => {
trackEvent(Click.OrganizationClickUpgrade, {
from: 'button',

Some files were not shown because too many files have changed in this diff Show More