Merge pull request #3028 from appwrite/feat/live-payment-create-upgrade

Confirm create-org and upgrade-plan payments on-session
This commit is contained in:
Damodar Lohani
2026-05-11 09:04:28 +05:45
committed by GitHub
9 changed files with 236 additions and 34 deletions
@@ -0,0 +1,13 @@
<script lang="ts">
import { page } from '$app/state';
import { HeaderAlert } from '$lib/layout';
import { hideBillingHeaderRoutes, teamStatusUpgrading } from '$lib/stores/billing';
import { organization } from '$lib/stores/organization';
</script>
{#if $organization?.$id && $organization?.status === teamStatusUpgrading && !hideBillingHeaderRoutes.includes(page.url.pathname)}
<HeaderAlert title="Payment is processing" type="info">
Your plan will activate within a few minutes. You can keep using {$organization.name} while we
confirm the charge with your bank.
</HeaderAlert>
{/if}
@@ -50,10 +50,10 @@
{#if selectedPaymentMethod?.country?.toLowerCase() === 'in'}
<Alert.Inline status="warning">
<svelte:fragment slot="title">Indian credit or debit card-holders</svelte:fragment>
To comply with RBI regulations in India, Appwrite will ask for verification to charge
up to $150 USD on your payment method. We will never charge more than the cost of your
plan and the resources you use, or your budget cap limit. For higher usage limits, please
contact us.
To comply with RBI regulations in India, you will be asked to authenticate the first charge
during checkout, and Appwrite will set up a mandate to charge up to $150 USD on your payment
method for future renewals. We will never charge more than the cost of your plan and the
resources you use, or your budget cap limit. For higher usage limits, please contact us.
</Alert.Inline>
{/if}
<InputSelect
+13
View File
@@ -8,6 +8,7 @@ import MarkedForDeletion from '$lib/components/billing/alerts/markedForDeletion.
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 PaymentProcessing from '$lib/components/billing/alerts/paymentProcessing.svelte';
import { NEW_DEV_PRO_UPGRADE_COUPON } from '$lib/constants';
import { cachedStore } from '$lib/helpers/cache';
@@ -57,6 +58,7 @@ export const roles = [
];
export const teamStatusReadonly = 'readonly';
export const teamStatusUpgrading = 'upgrading';
export const billingLimitOutstandingInvoice = 'outstanding_invoice';
export const paymentMethods = derived(
@@ -587,6 +589,17 @@ export function checkForMarkedForDeletion(org: Models.Organization) {
}
}
export function checkForUpgradingStatus(org: Models.Organization) {
if (org?.status === teamStatusUpgrading) {
headerAlert.add({
id: 'paymentProcessing',
component: PaymentProcessing,
show: true,
importance: 5
});
}
}
export async function checkForMissingPaymentMethod() {
const starterPlan = getBasePlanFromGroup(BillingPlanGroup.Starter);
if (!starterPlan?.$id) {
+53 -8
View File
@@ -186,25 +186,61 @@ export async function setPaymentMethod(providerMethodId: string, name: string, s
}
}
export type ConfirmPaymentOutcome =
| { status: 'succeeded' | 'processing' | 'requires_action' }
| { status: 'error'; message: string };
export async function confirmPayment(config: {
clientSecret: string;
paymentMethodId: string;
orgId?: string;
route?: string;
}) {
const { clientSecret, paymentMethodId, orgId, route } = config;
redirectIfRequired?: boolean;
}): Promise<ConfirmPaymentOutcome | undefined> {
const { clientSecret, paymentMethodId, orgId, route, redirectIfRequired } = config;
try {
const resolvedUrl = resolve('/(console)/organization-[organization]/billing', {
organization: orgId
});
const url = window.location.origin + (route ? route : resolvedUrl);
const url =
window.location.origin +
(route ??
resolve('/(console)/organization-[organization]/billing', { organization: orgId }));
const paymentMethod = await sdk.forConsole.account.getPaymentMethod({ paymentMethodId });
if (redirectIfRequired) {
const { paymentIntent, error } = await get(stripe).confirmPayment({
clientSecret,
confirmParams: {
return_url: url,
payment_method: paymentMethod.providerMethodId
},
redirect: 'if_required'
});
if (error) {
addNotification({
title: 'Error',
message:
error.message ??
'There was an error processing your payment, try again later. If the problem persists, please contact support.',
type: 'error'
});
return { status: 'error', message: error.message ?? 'Payment confirmation failed' };
}
const status = paymentIntent?.status;
if (status === 'succeeded' || status === 'processing' || status === 'requires_action') {
return { status };
}
return {
status: 'error',
message: `Unexpected payment status: ${status ?? 'unknown'}`
};
}
const { error } = await get(stripe).confirmPayment({
clientSecret: clientSecret,
clientSecret,
confirmParams: {
return_url: url,
payment_method: paymentMethod.providerMethodId
@@ -215,12 +251,21 @@ export async function confirmPayment(config: {
throw error.message;
}
} catch (e) {
const underlying =
typeof e === 'string' ? e : ((e as { message?: string })?.message ?? null);
addNotification({
title: 'Error',
message:
underlying ??
'There was an error processing your payment, try again later. If the problem persists, please contact support.',
type: 'error'
});
if (redirectIfRequired) {
return {
status: 'error',
message: underlying ?? 'Payment confirmation failed'
};
}
}
}
+2
View File
@@ -18,6 +18,7 @@
checkForMarkedForDeletion,
checkForMissingPaymentMethod,
checkForNewDevUpgradePro,
checkForUpgradingStatus,
checkForUsageLimit,
checkPaymentAuthorizationRequired,
paymentExpired,
@@ -295,6 +296,7 @@
checkForEnterpriseTrial(org);
await checkForUsageLimit(org);
checkForMarkedForDeletion(org);
checkForUpgradingStatus(org);
await checkForNewDevUpgradePro(org);
if (org?.billingPlanDetails.requiresPaymentMethod) {
@@ -11,7 +11,8 @@
import {
billingIdToPlan,
getBasePlanFromGroup,
isPaymentAuthenticationRequired
isPaymentAuthenticationRequired,
teamStatusUpgrading
} from '$lib/stores/billing';
import { addNotification } from '$lib/stores/notifications';
import { sdk } from '$lib/stores/sdk';
@@ -106,11 +107,21 @@
});
if (!isPaymentAuthenticationRequired(org)) {
await invalidate(Dependencies.ACCOUNT);
await preloadAndNavigate(org.$id);
addNotification({
type: 'success',
message: `${org.name ?? 'Organization'} has been created`
});
if (org.status === teamStatusUpgrading) {
addNotification({
type: 'info',
message:
'Payment is processing — your plan will activate within a few minutes.'
});
} else {
addNotification({
type: 'success',
message: `${org.name ?? 'Organization'} has been created`
});
}
}
} catch (e) {
addNotification({
@@ -144,8 +155,8 @@
});
if (isPaymentAuthenticationRequired(org)) {
let clientSecret = org.clientSecret;
let params = new URLSearchParams();
const clientSecret = org.clientSecret;
const params = new URLSearchParams();
params.append('type', 'payment_confirmed');
params.append('id', org.organizationId);
for (const [key, value] of page.url.searchParams.entries()) {
@@ -156,12 +167,29 @@
params.append('invites', collaborators.join(','));
const resolvedUrl = resolve('/(console)/create-organization');
await confirmPayment({
const outcome = await confirmPayment({
clientSecret,
paymentMethodId,
route: `${resolvedUrl}?${params}`
route: `${resolvedUrl}?${params}`,
redirectIfRequired: true
});
if (!outcome || outcome.status === 'error') {
try {
await sdk.forConsole.organizations.validatePayment({
organizationId: org.organizationId,
invites: []
});
} catch {
// expected: backend throws BILLING_PAYMENT_FAILED after deleting the draft team
}
return;
}
if (outcome.status === 'requires_action') {
return;
}
await validate(org.organizationId, collaborators);
}
}
@@ -12,7 +12,8 @@
import {
billingIdToPlan,
getBasePlanFromGroup,
isPaymentAuthenticationRequired
isPaymentAuthenticationRequired,
teamStatusUpgrading
} from '$lib/stores/billing';
import { addNotification } from '$lib/stores/notifications';
import { currentPlan, organization } from '$lib/stores/organization';
@@ -220,10 +221,19 @@
await invalidate(Dependencies.ORGANIZATION);
await goto(previousPage);
addNotification({
type: 'success',
message: 'Your organization has been upgraded'
});
if (org.status === teamStatusUpgrading) {
addNotification({
type: 'info',
message:
'Payment is processing — your plan will activate within a few minutes.'
});
} else {
addNotification({
type: 'success',
message: 'Your organization has been upgraded'
});
}
trackEvent(Submit.OrganizationUpgrade, {
plan: selectedPlan?.name
@@ -259,8 +269,8 @@
});
if (isPaymentAuthenticationRequired(org)) {
let clientSecret = org.clientSecret;
let params = new URLSearchParams();
const clientSecret = org.clientSecret;
const params = new URLSearchParams();
for (const [key, value] of page.url.searchParams.entries()) {
if (key !== 'type' && key !== 'id') {
params.append(key, value);
@@ -275,11 +285,29 @@
organization: org.organizationId
});
await confirmPayment({
const outcome = await confirmPayment({
clientSecret,
paymentMethodId,
route: `${resolvedUrl}?${params.toString()}`
route: `${resolvedUrl}?${params.toString()}`,
redirectIfRequired: true
});
if (!outcome || outcome.status === 'error') {
try {
await sdk.forConsole.organizations.validatePayment({
organizationId: org.organizationId,
invites: []
});
} catch {
// expected: backend throws BILLING_PAYMENT_FAILED and rolls back the upgrade
}
return;
}
if (outcome.status === 'requires_action') {
return;
}
await validate(org.organizationId, collaborators);
}
@@ -160,12 +160,48 @@
const settingsUrl = resolve('/(console)/organization-[organization]/settings', {
organization: $organization.$id
});
await confirmPayment({
const outcome = await confirmPayment({
clientSecret: paymentAuth.clientSecret,
paymentMethodId: $organization.paymentMethodId,
orgId: $organization.$id,
route: `${settingsUrl}?type=confirm-addon&addonId=${paymentAuth.addonId}`
route: `${settingsUrl}?type=confirm-addon&addonId=${paymentAuth.addonId}`,
redirectIfRequired: true
});
if (!outcome || outcome.status === 'error') {
trackError(
new Error(outcome?.status === 'error' ? outcome.message : 'Payment failed'),
Submit.BAAAddonEnable
);
return;
}
if (outcome.status === 'requires_action') {
return;
}
await sdk.forConsole.organizations.confirmAddonPayment({
organizationId: $organization.$id,
addonId: paymentAuth.addonId
});
await Promise.all([
invalidate(Dependencies.ADDONS),
invalidate(Dependencies.ORGANIZATION)
]);
if (outcome.status === 'processing') {
addNotification({
message: "BAA addon payment is processing — we'll activate it shortly.",
type: 'info'
});
} else {
addNotification({
message: 'BAA addon has been enabled',
type: 'success'
});
}
trackEvent(Submit.BAAAddonEnable);
return;
}
@@ -39,12 +39,49 @@
const settingsUrl = resolve('/(console)/organization-[organization]/settings', {
organization: $organization.$id
});
await confirmPayment({
const outcome = await confirmPayment({
clientSecret: paymentAuth.clientSecret,
paymentMethodId: $organization.paymentMethodId,
orgId: $organization.$id,
route: `${settingsUrl}?type=confirm-addon&addonId=${paymentAuth.addonId}`
route: `${settingsUrl}?type=confirm-addon&addonId=${paymentAuth.addonId}`,
redirectIfRequired: true
});
if (!outcome || outcome.status === 'error') {
if (outcome?.status === 'error') {
error = outcome.message;
trackError(new Error(outcome.message), Submit.BAAAddonEnable);
}
return;
}
if (outcome.status === 'requires_action') {
return;
}
await sdk.forConsole.organizations.confirmAddonPayment({
organizationId: $organization.$id,
addonId: paymentAuth.addonId
});
await Promise.all([
invalidate(Dependencies.ADDONS),
invalidate(Dependencies.ORGANIZATION)
]);
if (outcome.status === 'processing') {
addNotification({
message: "BAA addon payment is processing — we'll activate it shortly.",
type: 'info'
});
} else {
addNotification({
message: 'BAA addon has been enabled',
type: 'success'
});
}
trackEvent(Submit.BAAAddonEnable);
show = false;
return;
}