feat(billing): on-session live payment for create-organization and upgrade-plan

Switch the create-organization and change-plan submit flows to confirm
PaymentIntents on-session via stripe.confirmPayment with redirect:
'if_required'. The frontend now treats PaymentAuthentication responses
as the expected default path for paid plans (not just the 3DS edge
case) and surfaces succeeded / processing / requires_action outcomes
to callers via a new ConfirmPaymentOutcome return type. Other call
sites (account/payments, billing, BAA, retry-invoice) remain on the
previous redirect-based flow.

Add a "Payment is processing" header alert that renders when the
team status is 'upgrading' (Stripe still settling). The alert sits
below readonly / budget / mark-for-deletion alerts (importance 5),
so it only shows when nothing more critical is happening, and lets
the user keep using the org while the charge clears.

Update the Indian RBI card-holder warning copy to reflect that the
first charge is now authenticated on-session and the $150 mandate
applies to future renewals — this avoids the 24h "processing"
limbo seen with off-session first charges under the RBI mandate.
This commit is contained in:
Damodar Lohani
2026-05-07 03:43:40 +00:00
parent 8d62e6c989
commit 5bfc3716e2
7 changed files with 136 additions and 25 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) {
+46 -3
View File
@@ -186,13 +186,18 @@ 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', {
@@ -203,8 +208,40 @@ export async function confirmPayment(config: {
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
@@ -221,6 +258,12 @@ export async function confirmPayment(config: {
'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: typeof e === 'string' ? e : (e?.message ?? '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,21 @@
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') {
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,21 @@
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') {
return;
}
if (outcome.status === 'requires_action') {
return;
}
await validate(org.organizationId, collaborators);
}