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', { - 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' + }; + } } } 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..297b94489d 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,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); } } diff --git a/src/routes/(console)/organization-[organization]/change-plan/+page.svelte b/src/routes/(console)/organization-[organization]/change-plan/+page.svelte index b353f5738f..a651561a93 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,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); } 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; }