Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/lib/components/billing/alerts/paymentProcessing.svelte
Original file line number Diff line number Diff line change
@@ -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}
8 changes: 4 additions & 4 deletions src/lib/components/billing/selectPaymentMethod.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions src/lib/stores/billing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -57,6 +58,7 @@ export const roles = [
];

export const teamStatusReadonly = 'readonly';
export const teamStatusUpgrading = 'upgrading';
export const billingLimitOutstandingInvoice = 'outstanding_invoice';

export const paymentMethods = derived(
Expand Down Expand Up @@ -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) {
Expand Down
61 changes: 53 additions & 8 deletions src/lib/stores/stripe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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'
};
}
}
}

Expand Down
2 changes: 2 additions & 0 deletions src/routes/(console)/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
checkForMarkedForDeletion,
checkForMissingPaymentMethod,
checkForNewDevUpgradePro,
checkForUpgradingStatus,
checkForUsageLimit,
checkPaymentAuthorizationRequired,
paymentExpired,
Expand Down Expand Up @@ -295,6 +296,7 @@
checkForEnterpriseTrial(org);
await checkForUsageLimit(org);
checkForMarkedForDeletion(org);
checkForUpgradingStatus(org);
await checkForNewDevUpgradePro(org);

if (org?.billingPlanDetails.requiresPaymentMethod) {
Expand Down
46 changes: 37 additions & 9 deletions src/routes/(console)/create-organization/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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()) {
Expand All @@ -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);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
Loading
Loading