From 5bfc3716e23a90f7d13ead8d91a5478801f4fa2d Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Thu, 7 May 2026 03:43:40 +0000 Subject: [PATCH 1/3] feat(billing): on-session live payment for create-organization and upgrade-plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../billing/alerts/paymentProcessing.svelte | 13 +++++ .../billing/selectPaymentMethod.svelte | 8 +-- src/lib/stores/billing.ts | 13 +++++ src/lib/stores/stripe.ts | 49 +++++++++++++++++-- src/routes/(console)/+layout.svelte | 2 + .../create-organization/+page.svelte | 38 ++++++++++---- .../change-plan/+page.svelte | 38 ++++++++++---- 7 files changed, 136 insertions(+), 25 deletions(-) create mode 100644 src/lib/components/billing/alerts/paymentProcessing.svelte diff --git a/src/lib/components/billing/alerts/paymentProcessing.svelte b/src/lib/components/billing/alerts/paymentProcessing.svelte new file mode 100644 index 0000000000..0539ba2fad --- /dev/null +++ b/src/lib/components/billing/alerts/paymentProcessing.svelte @@ -0,0 +1,13 @@ + + +{#if $organization?.$id && $organization?.status === teamStatusUpgrading && !hideBillingHeaderRoutes.includes(page.url.pathname)} + + Your plan will activate within a few minutes. You can keep using {$organization.name} while we + confirm the charge with your bank. + +{/if} diff --git a/src/lib/components/billing/selectPaymentMethod.svelte b/src/lib/components/billing/selectPaymentMethod.svelte index 9682b99953..b6a09fc032 100644 --- a/src/lib/components/billing/selectPaymentMethod.svelte +++ b/src/lib/components/billing/selectPaymentMethod.svelte @@ -50,10 +50,10 @@ {#if selectedPaymentMethod?.country?.toLowerCase() === 'in'} Indian credit or debit card-holders - 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. {/if} { + 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') + }; + } } } diff --git a/src/routes/(console)/+layout.svelte b/src/routes/(console)/+layout.svelte index ea0830ac0c..8663437fb7 100644 --- a/src/routes/(console)/+layout.svelte +++ b/src/routes/(console)/+layout.svelte @@ -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) { diff --git a/src/routes/(console)/create-organization/+page.svelte b/src/routes/(console)/create-organization/+page.svelte index bf5a4a1098..8966e20553 100644 --- a/src/routes/(console)/create-organization/+page.svelte +++ b/src/routes/(console)/create-organization/+page.svelte @@ -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); } } diff --git a/src/routes/(console)/organization-[organization]/change-plan/+page.svelte b/src/routes/(console)/organization-[organization]/change-plan/+page.svelte index b353f5738f..7c9c6923ab 100644 --- a/src/routes/(console)/organization-[organization]/change-plan/+page.svelte +++ b/src/routes/(console)/organization-[organization]/change-plan/+page.svelte @@ -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); } From 9e9c737a1d72d11beb722715eac7c6447958c16e Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Thu, 7 May 2026 04:03:15 +0000 Subject: [PATCH 2/3] fix(billing): on-session live payment for BAA addon enable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch the BAA addon enable flow (both the re-enable button on the settings card and the BAAEnableModal submit) to confirm the addon PaymentIntent on-session via stripe.confirmPayment with redirect: 'if_required'. After a succeeded or processing outcome, finalize the addon by calling organizations.confirmAddonPayment directly and invalidate ADDONS + ORGANIZATION dependencies inline — no more URL round-trip via ?type=confirm-addon&addonId=. A processing outcome surfaces an info notification ("BAA addon payment is processing — we'll activate it shortly.") since the addon stays pending while Stripe settles, and the existing "Payment pending" badge on the BAA card already covers the visual state. The onMount redirect-handler in BAA.svelte that consumes the ?type=confirm-addon query string is preserved as a fallback for the rare case where Stripe still elects to redirect (e.g. some 3DS challenges that can't be inlined) — the route is still passed to confirmPayment so Stripe has a return URL when needed. --- .../settings/BAA.svelte | 40 +++++++++++++++++- .../settings/BAAEnableModal.svelte | 41 ++++++++++++++++++- 2 files changed, 77 insertions(+), 4 deletions(-) diff --git a/src/routes/(console)/organization-[organization]/settings/BAA.svelte b/src/routes/(console)/organization-[organization]/settings/BAA.svelte index 71a32f4d2a..a4ba95ae3d 100644 --- a/src/routes/(console)/organization-[organization]/settings/BAA.svelte +++ b/src/routes/(console)/organization-[organization]/settings/BAA.svelte @@ -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; } diff --git a/src/routes/(console)/organization-[organization]/settings/BAAEnableModal.svelte b/src/routes/(console)/organization-[organization]/settings/BAAEnableModal.svelte index 36881a73cf..33d68f7a9e 100644 --- a/src/routes/(console)/organization-[organization]/settings/BAAEnableModal.svelte +++ b/src/routes/(console)/organization-[organization]/settings/BAAEnableModal.svelte @@ -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; } From eeb28a4ce84b1970371640002fd6b55b7f4a8a56 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Thu, 7 May 2026 10:32:59 +0000 Subject: [PATCH 3/3] fix(billing): surface real confirmPayment error and clean up draft on failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues surfaced during user testing of the live-payment flow on create-organization and change-plan. 1. confirmPayment() in src/lib/stores/stripe.ts always called resolve('/(console)/organization-[organization]/billing', {organization: orgId}) eagerly at the top of the try block. The create-organization callsite passes only `route` (no orgId, since the team doesn't yet exist), so resolve() threw on the missing required `organization` param, the outer catch fired, and the user saw the generic "There was an error processing your payment..." message instead of the actual Stripe error. The URL is now built lazily — resolve() is only invoked when no `route` is supplied. The outer catch now surfaces the underlying error message (Stripe / SDK error) and falls back to the generic copy only when no message is available. 2. When the frontend Stripe confirmation failed, the backend was never notified, leaving draft teams (status='draft') and partial upgrades stranded. handleTeamCreateUpgradeFailure already deletes drafts and rolls back upgrades — but only when the backend recognises failure. The create-organization and change-plan error branches now make a best-effort call to organizations.validatePayment after a failed confirmation. validatePayment inspects the actual Stripe intent and routes to handleTeamCreateUpgradeFailure when it isn't succeeded/processing. validatePayment is expected to throw BILLING_PAYMENT_FAILED in this path; the throw is swallowed because the user has already seen the underlying Stripe error and the backend cleanup is the desired side-effect. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/stores/stripe.ts | 14 ++++++++------ .../(console)/create-organization/+page.svelte | 8 ++++++++ .../change-plan/+page.svelte | 8 ++++++++ 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/lib/stores/stripe.ts b/src/lib/stores/stripe.ts index b181e1c66a..0e237c175a 100644 --- a/src/lib/stores/stripe.ts +++ b/src/lib/stores/stripe.ts @@ -200,11 +200,10 @@ export async function confirmPayment(config: { 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 }); @@ -252,16 +251,19 @@ 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: typeof e === 'string' ? e : (e?.message ?? 'Payment confirmation failed') + message: underlying ?? 'Payment confirmation failed' }; } } diff --git a/src/routes/(console)/create-organization/+page.svelte b/src/routes/(console)/create-organization/+page.svelte index 8966e20553..297b94489d 100644 --- a/src/routes/(console)/create-organization/+page.svelte +++ b/src/routes/(console)/create-organization/+page.svelte @@ -175,6 +175,14 @@ }); 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; } diff --git a/src/routes/(console)/organization-[organization]/change-plan/+page.svelte b/src/routes/(console)/organization-[organization]/change-plan/+page.svelte index 7c9c6923ab..a651561a93 100644 --- a/src/routes/(console)/organization-[organization]/change-plan/+page.svelte +++ b/src/routes/(console)/organization-[organization]/change-plan/+page.svelte @@ -293,6 +293,14 @@ }); 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; }