mirror of
https://github.com/appwrite/console.git
synced 2026-06-06 19:27:48 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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')
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user