@antelopejs/dms-saas 0.3.9 → 0.3.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -12
- package/dist/routes/platformOwner/workspaces-admin.js +1 -1
- package/dist/routes/platformOwner/workspaces-admin.js.map +1 -1
- package/dist/routes/tenant/billing-self-refund.js +1 -1
- package/dist/routes/tenant/billing-self-refund.js.map +1 -1
- package/dist/routes/tenant/index.d.ts +1 -0
- package/dist/routes/tenant/index.js +1 -0
- package/dist/routes/tenant/index.js.map +1 -1
- package/dist/routes/tenant/tenant-plan-checkout-recovery.d.ts +21 -0
- package/dist/routes/tenant/tenant-plan-checkout-recovery.js +140 -0
- package/dist/routes/tenant/tenant-plan-checkout-recovery.js.map +1 -0
- package/dist/routes/tenant/tenant-plan-checkout.d.ts +0 -3
- package/dist/routes/tenant/tenant-plan-checkout.js +4 -44
- package/dist/routes/tenant/tenant-plan-checkout.js.map +1 -1
- package/dist/routes/tenant/tenant-plan.d.ts +3 -0
- package/dist/routes/tenant/tenant-plan.js +27 -0
- package/dist/routes/tenant/tenant-plan.js.map +1 -1
- package/dist/stripe/billing-mode.d.ts +5 -0
- package/dist/stripe/billing-mode.js +7 -0
- package/dist/stripe/billing-mode.js.map +1 -0
- package/dist/stripe/client.js +1 -1
- package/dist/stripe/client.js.map +1 -1
- package/dist/stripe/customer-billing.js +1 -1
- package/dist/stripe/customer-billing.js.map +1 -1
- package/dist/stripe/index.d.ts +1 -0
- package/dist/stripe/index.js +1 -0
- package/dist/stripe/index.js.map +1 -1
- package/dist/stripe/payload-shapes.js.map +1 -1
- package/dist/stripe/plan-schedule.js +12 -2
- package/dist/stripe/plan-schedule.js.map +1 -1
- package/dist/stripe/webhook-checkout.js +2 -0
- package/dist/stripe/webhook-checkout.js.map +1 -1
- package/dist/stripe/webhook-credit-notes.js +13 -4
- package/dist/stripe/webhook-credit-notes.js.map +1 -1
- package/dist/stripe/workspace-checkout.d.ts +5 -0
- package/dist/stripe/workspace-checkout.js +27 -1
- package/dist/stripe/workspace-checkout.js.map +1 -1
- package/dist/workers/plan-migration.js +1 -1
- package/dist/workers/plan-migration.js.map +1 -1
- package/dist/workspaces/deletion.js +1 -1
- package/dist/workspaces/deletion.js.map +1 -1
- package/dist/workspaces/provisioning.js +2 -12
- package/dist/workspaces/provisioning.js.map +1 -1
- package/dist/workspaces/subscription-create-params.d.ts +3 -0
- package/dist/workspaces/subscription-create-params.js +17 -0
- package/dist/workspaces/subscription-create-params.js.map +1 -0
- package/frontend-vue/app/components/PendingCheckoutModal.vue +78 -0
- package/frontend-vue/app/components/PlanComparisonModal.vue +5 -0
- package/frontend-vue/app/components/PlanUpgradeModal.vue +5 -0
- package/frontend-vue/app/components/TenantPlanCard.vue +27 -1
- package/frontend-vue/app/components/WorkspaceCreateModal.vue +2 -0
- package/frontend-vue/app/composables/useApiErrorMessage.ts +9 -8
- package/frontend-vue/app/composables/usePendingCheckout.ts +88 -0
- package/frontend-vue/app/composables/useSaasRegistration.ts +1 -0
- package/frontend-vue/app/composables/useStripePaymentElement.ts +90 -13
- package/frontend-vue/app/composables/useTenantPlan.ts +41 -1
- package/frontend-vue/app/custom-pages/auth/no-workspace.vue +1 -0
- package/frontend-vue/i18n/locales/saas-en-GB.json +12 -0
- package/frontend-vue/i18n/locales/saas-fr-FR.json +12 -0
- package/frontend-vue/package.json +3 -0
- package/package.json +2 -2
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, ref } from "vue";
|
|
3
|
+
|
|
4
|
+
const KEY_PREFIX = "saas.workspace.plan.checkout_pending";
|
|
5
|
+
|
|
6
|
+
const toast = useToast();
|
|
7
|
+
const { resolveApiError } = useApiErrorMessage();
|
|
8
|
+
const { prompt, resume, startOver } = usePendingCheckout();
|
|
9
|
+
|
|
10
|
+
const isRestarting = ref(false);
|
|
11
|
+
|
|
12
|
+
const open = computed({
|
|
13
|
+
get: () => prompt.value !== null,
|
|
14
|
+
set: (isOpen: boolean) => {
|
|
15
|
+
if (!isOpen) prompt.value = null;
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const pending = computed(() => prompt.value?.pending ?? null);
|
|
20
|
+
const canResume = computed(() => !!pending.value?.checkoutUrl);
|
|
21
|
+
|
|
22
|
+
/** A paid checkout only waits for Stripe; one still opening only for time. */
|
|
23
|
+
const descriptionKey = computed(() => {
|
|
24
|
+
if (pending.value?.isPaid) return `${KEY_PREFIX}.description_paid`;
|
|
25
|
+
return canResume.value
|
|
26
|
+
? `${KEY_PREFIX}.description`
|
|
27
|
+
: `${KEY_PREFIX}.description_opening`;
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
async function restart(): Promise<void> {
|
|
31
|
+
isRestarting.value = true;
|
|
32
|
+
try {
|
|
33
|
+
await startOver();
|
|
34
|
+
} catch (error) {
|
|
35
|
+
toast.add({
|
|
36
|
+
title: resolveApiError(error, `${KEY_PREFIX}.restart_error`),
|
|
37
|
+
color: "error",
|
|
38
|
+
icon: "i-ph-warning-circle",
|
|
39
|
+
});
|
|
40
|
+
isRestarting.value = false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
</script>
|
|
44
|
+
|
|
45
|
+
<template>
|
|
46
|
+
<UModal
|
|
47
|
+
v-model:open="open"
|
|
48
|
+
:title="$t(`${KEY_PREFIX}.title`)"
|
|
49
|
+
:description="$t(descriptionKey)"
|
|
50
|
+
>
|
|
51
|
+
<template #footer>
|
|
52
|
+
<div class="flex w-full flex-wrap justify-end gap-2">
|
|
53
|
+
<UButton color="neutral" variant="subtle" @click="open = false">
|
|
54
|
+
{{ $t(`${KEY_PREFIX}.close`) }}
|
|
55
|
+
</UButton>
|
|
56
|
+
<UButton
|
|
57
|
+
v-if="canResume"
|
|
58
|
+
color="neutral"
|
|
59
|
+
variant="outline"
|
|
60
|
+
icon="i-ph-arrow-counter-clockwise"
|
|
61
|
+
:loading="isRestarting"
|
|
62
|
+
@click="restart"
|
|
63
|
+
>
|
|
64
|
+
{{ $t(`${KEY_PREFIX}.restart`) }}
|
|
65
|
+
</UButton>
|
|
66
|
+
<UButton
|
|
67
|
+
v-if="canResume"
|
|
68
|
+
color="primary"
|
|
69
|
+
icon="i-ph-lock-simple"
|
|
70
|
+
:disabled="isRestarting"
|
|
71
|
+
@click="resume"
|
|
72
|
+
>
|
|
73
|
+
{{ $t(`${KEY_PREFIX}.resume`) }}
|
|
74
|
+
</UButton>
|
|
75
|
+
</div>
|
|
76
|
+
</template>
|
|
77
|
+
</UModal>
|
|
78
|
+
</template>
|
|
@@ -20,6 +20,7 @@ const { t } = useI18n();
|
|
|
20
20
|
const toast = useToast();
|
|
21
21
|
const { resolveApiError } = useApiErrorMessage();
|
|
22
22
|
const { changePlan } = useTenantPlan();
|
|
23
|
+
const { offerFor: offerPendingCheckout } = usePendingCheckout();
|
|
23
24
|
const { formatFeatureValue } = usePlanFeatureFormat();
|
|
24
25
|
const { featureLabel, featureTooltip } = usePlanFeatureLabel();
|
|
25
26
|
const { formatMajorUnits } = useMoneyFormat();
|
|
@@ -102,6 +103,10 @@ async function select(plan: TenantPlanView): Promise<void> {
|
|
|
102
103
|
open.value = false;
|
|
103
104
|
emit("changed");
|
|
104
105
|
} catch (error) {
|
|
106
|
+
if (await offerPendingCheckout(error, plan._id)) {
|
|
107
|
+
open.value = false;
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
105
110
|
toast.add({
|
|
106
111
|
title: resolveApiError(error, "saas.workspace.plan.change_error"),
|
|
107
112
|
color: "error",
|
|
@@ -16,6 +16,7 @@ const { t } = useI18n();
|
|
|
16
16
|
const { resolveApiError } = useApiErrorMessage();
|
|
17
17
|
const identity = useBillingIdentity();
|
|
18
18
|
const { changePlan } = useTenantPlan();
|
|
19
|
+
const { offerFor: offerPendingCheckout } = usePendingCheckout();
|
|
19
20
|
const { formatMajorUnits } = useMoneyFormat();
|
|
20
21
|
const planIntervalLabel = usePlanIntervalLabel("saas.workspace.plan.interval");
|
|
21
22
|
|
|
@@ -87,6 +88,10 @@ async function confirm(): Promise<void> {
|
|
|
87
88
|
open.value = false;
|
|
88
89
|
emit("changed");
|
|
89
90
|
} catch (error) {
|
|
91
|
+
if (await offerPendingCheckout(error, plan._id)) {
|
|
92
|
+
open.value = false;
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
90
95
|
notifyError(resolveApiError(error, "saas.workspace.plan.change_error"));
|
|
91
96
|
} finally {
|
|
92
97
|
isSubmitting.value = false;
|
|
@@ -25,6 +25,7 @@ const { featureLabel } = usePlanFeatureLabel();
|
|
|
25
25
|
const planDescription = usePlanDescription();
|
|
26
26
|
const { workspace, load: loadWorkspace } = useCurrentWorkspace();
|
|
27
27
|
const { isOpen: isComparisonOpen } = usePlanComparison();
|
|
28
|
+
const { consumeCancelledCheckout } = usePendingCheckout();
|
|
28
29
|
const planIntervalLabel = usePlanIntervalLabel("saas.workspace.plan.interval");
|
|
29
30
|
|
|
30
31
|
const data = ref<TenantPlanResponse | null>(null);
|
|
@@ -164,8 +165,32 @@ async function loadWorkspaceForOwner(): Promise<void> {
|
|
|
164
165
|
if (status?.isTenantOwner) await loadWorkspace().catch(() => undefined);
|
|
165
166
|
}
|
|
166
167
|
|
|
167
|
-
|
|
168
|
+
/** Back from Stripe's cancel link: the checkout left behind is released
|
|
169
|
+
* before the card loads, so the plan buttons work straight away. */
|
|
170
|
+
async function releaseCancelledCheckout(): Promise<void> {
|
|
171
|
+
try {
|
|
172
|
+
const { released } = await consumeCancelledCheckout();
|
|
173
|
+
if (!released) return;
|
|
174
|
+
toast.add({
|
|
175
|
+
title: t("saas.workspace.plan.checkout_pending.cancelled"),
|
|
176
|
+
color: "info",
|
|
177
|
+
icon: "i-ph-info",
|
|
178
|
+
});
|
|
179
|
+
} catch (error) {
|
|
180
|
+
toast.add({
|
|
181
|
+
title: resolveApiError(
|
|
182
|
+
error,
|
|
183
|
+
"saas.workspace.plan.checkout_pending.restart_error",
|
|
184
|
+
),
|
|
185
|
+
color: "error",
|
|
186
|
+
icon: "i-ph-warning-circle",
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
onMounted(async () => {
|
|
168
192
|
void loadWorkspaceForOwner();
|
|
193
|
+
await releaseCancelledCheckout();
|
|
169
194
|
return fetchPlan(false);
|
|
170
195
|
});
|
|
171
196
|
</script>
|
|
@@ -279,6 +304,7 @@ onMounted(() => {
|
|
|
279
304
|
:plan="upgradeTarget"
|
|
280
305
|
@changed="refresh"
|
|
281
306
|
/>
|
|
307
|
+
<DmsSaasPendingCheckoutModal />
|
|
282
308
|
</div>
|
|
283
309
|
</UCard>
|
|
284
310
|
</template>
|
|
@@ -39,6 +39,7 @@ const planIntervalLabel = usePlanIntervalLabel(
|
|
|
39
39
|
);
|
|
40
40
|
const planDescription = usePlanDescription();
|
|
41
41
|
const config = useDmsRuntimeConfig();
|
|
42
|
+
const { user } = useCurrentUser();
|
|
42
43
|
|
|
43
44
|
const stripePublishableKey = computed<string>(
|
|
44
45
|
() => (config.public.dmsSaas as any)?.stripePublishableKey ?? "",
|
|
@@ -121,6 +122,7 @@ async function initStripeElement(): Promise<void> {
|
|
|
121
122
|
publishableKey: stripePublishableKey.value,
|
|
122
123
|
clientSecret: setup.clientSecret,
|
|
123
124
|
containerId: PAYMENT_ELEMENT_ID,
|
|
125
|
+
billingEmail: () => user.value?.email ?? "",
|
|
124
126
|
});
|
|
125
127
|
}
|
|
126
128
|
|
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
/** The i18n key the server answered a failed `$authFetch` with, if any. */
|
|
2
|
+
export function readApiErrorKey(error: unknown): string | undefined {
|
|
3
|
+
const data = (error as { data?: unknown } | undefined)?.data;
|
|
4
|
+
if (typeof data === "string") return data;
|
|
5
|
+
const message = (data as { message?: unknown } | undefined)?.message;
|
|
6
|
+
return typeof message === "string" ? message : undefined;
|
|
7
|
+
}
|
|
8
|
+
|
|
1
9
|
/**
|
|
2
10
|
* Resolves an unknown error thrown by `$authFetch` into a human-readable,
|
|
3
11
|
* translated message.
|
|
@@ -12,17 +20,10 @@
|
|
|
12
20
|
export function useApiErrorMessage() {
|
|
13
21
|
const nuxtApp = useDmsApp();
|
|
14
22
|
|
|
15
|
-
function extractServerKey(error: unknown): string | undefined {
|
|
16
|
-
const data = (error as { data?: unknown } | undefined)?.data;
|
|
17
|
-
if (typeof data === "string") return data;
|
|
18
|
-
const message = (data as { message?: unknown } | undefined)?.message;
|
|
19
|
-
return typeof message === "string" ? message : undefined;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
23
|
/** Translated server message, or `undefined` when the server sent no usable key. */
|
|
23
24
|
function resolveServerMessage(error: unknown): string | undefined {
|
|
24
25
|
const { t, te } = nuxtApp.$i18n;
|
|
25
|
-
const key =
|
|
26
|
+
const key = readApiErrorKey(error);
|
|
26
27
|
return key && te(key) ? t(key) : undefined;
|
|
27
28
|
}
|
|
28
29
|
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
const CHECKOUT_IN_PROGRESS_KEY = "saas.errors.plan.checkout_in_progress";
|
|
2
|
+
const PROMPT_STATE_KEY = "saas-pending-checkout-prompt";
|
|
3
|
+
const CHECKOUT_CANCELLED_PARAM = "checkout";
|
|
4
|
+
const CHECKOUT_CANCELLED_VALUE = "cancelled";
|
|
5
|
+
|
|
6
|
+
/** A plan change refused because an earlier checkout is still pending. */
|
|
7
|
+
export interface PendingCheckoutPrompt {
|
|
8
|
+
/** The plan the owner just chose, which starting over checks out. */
|
|
9
|
+
planId: string;
|
|
10
|
+
pending: PendingCheckout | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function isCheckoutInProgressError(error: unknown): boolean {
|
|
14
|
+
return readApiErrorKey(error) === CHECKOUT_IN_PROGRESS_KEY;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The owner's way out of a checkout they left unpaid: resume it, or give it
|
|
19
|
+
* up and start over. The prompt state is shared so the plan modals can raise
|
|
20
|
+
* it and the plan card, which outlives them, can host it.
|
|
21
|
+
*/
|
|
22
|
+
export function usePendingCheckout() {
|
|
23
|
+
const prompt = useDmsState<PendingCheckoutPrompt | null>(
|
|
24
|
+
PROMPT_STATE_KEY,
|
|
25
|
+
() => null,
|
|
26
|
+
);
|
|
27
|
+
const route = useDmsRoute();
|
|
28
|
+
const router = useDmsRouter();
|
|
29
|
+
const { loadPendingCheckout, cancelCheckout, changePlan } = useTenantPlan();
|
|
30
|
+
|
|
31
|
+
/** Raises the prompt when `error` is a pending-checkout refusal. */
|
|
32
|
+
async function offerFor(error: unknown, planId: string): Promise<boolean> {
|
|
33
|
+
if (!isCheckoutInProgressError(error)) return false;
|
|
34
|
+
const { pending } = await loadPendingCheckout().catch(() => ({
|
|
35
|
+
pending: null,
|
|
36
|
+
}));
|
|
37
|
+
prompt.value = { planId, pending };
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function resume(): void {
|
|
42
|
+
const url = prompt.value?.pending?.checkoutUrl;
|
|
43
|
+
if (url && typeof window !== "undefined") window.location.href = url;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Gives up the pending checkout, then opens one for the chosen plan. */
|
|
47
|
+
async function startOver(): Promise<void> {
|
|
48
|
+
const planId = prompt.value?.planId;
|
|
49
|
+
if (!planId) return;
|
|
50
|
+
await cancelCheckout();
|
|
51
|
+
const result = await changePlan(planId);
|
|
52
|
+
if (result.checkoutUrl && typeof window !== "undefined") {
|
|
53
|
+
window.location.href = result.checkoutUrl;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The operation named by the Stripe cancel link the page was opened from. */
|
|
58
|
+
function cancelledOperationId(): string | null {
|
|
59
|
+
const { query } = route;
|
|
60
|
+
const operationId = query[CHECKOUT_OPERATION_PARAM];
|
|
61
|
+
if (query[CHECKOUT_CANCELLED_PARAM] !== CHECKOUT_CANCELLED_VALUE) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
return typeof operationId === "string" && operationId ? operationId : null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Stripe's cancel link lands back on the billing page naming the checkout
|
|
69
|
+
* the owner walked out of; releasing it here lets them choose again at
|
|
70
|
+
* once. The marker is dropped either way, so a reload does not replay it.
|
|
71
|
+
*/
|
|
72
|
+
async function consumeCancelledCheckout(): Promise<CancelCheckoutResult> {
|
|
73
|
+
const operationId = cancelledOperationId();
|
|
74
|
+
if (!operationId) return { released: false };
|
|
75
|
+
const {
|
|
76
|
+
[CHECKOUT_CANCELLED_PARAM]: _cancelled,
|
|
77
|
+
[CHECKOUT_OPERATION_PARAM]: _operation,
|
|
78
|
+
...query
|
|
79
|
+
} = route.query;
|
|
80
|
+
try {
|
|
81
|
+
return await cancelCheckout(operationId);
|
|
82
|
+
} finally {
|
|
83
|
+
await router.replace({ query });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return { prompt, offerFor, resume, startOver, consumeCancelledCheckout };
|
|
88
|
+
}
|
|
@@ -1,14 +1,55 @@
|
|
|
1
|
-
import { onUnmounted } from "vue";
|
|
1
|
+
import { type MaybeRefOrGetter, onUnmounted, toValue } from "vue";
|
|
2
2
|
|
|
3
3
|
interface UseStripePaymentElementOptions {
|
|
4
4
|
publishableKey: string;
|
|
5
5
|
clientSecret: string;
|
|
6
6
|
containerId: string;
|
|
7
|
+
/**
|
|
8
|
+
* The payer's email, sent with the card: the Payment Element does not ask
|
|
9
|
+
* for it, since every caller already knows who is paying. Read at confirm
|
|
10
|
+
* time, so a form field may be passed as a getter.
|
|
11
|
+
*/
|
|
12
|
+
billingEmail: MaybeRefOrGetter<string>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface ConfirmError {
|
|
16
|
+
message: string;
|
|
7
17
|
}
|
|
8
18
|
|
|
9
19
|
interface ConfirmResult {
|
|
10
20
|
paymentMethodId?: string;
|
|
11
|
-
error?:
|
|
21
|
+
error?: ConfirmError;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface SetupIntentSnapshot {
|
|
25
|
+
payment_method?: unknown;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface StripeErrorSnapshot {
|
|
29
|
+
message?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface SetupIntentResult {
|
|
33
|
+
setupIntent?: SetupIntentSnapshot;
|
|
34
|
+
error?: StripeErrorSnapshot;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface BillingDetails {
|
|
38
|
+
email: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface PaymentMethodData {
|
|
42
|
+
billing_details: BillingDetails;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface SetupConfirmParams {
|
|
46
|
+
payment_method_data: PaymentMethodData;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface SetupConfirmation {
|
|
50
|
+
elements: unknown;
|
|
51
|
+
redirect: "if_required";
|
|
52
|
+
confirmParams: SetupConfirmParams;
|
|
12
53
|
}
|
|
13
54
|
|
|
14
55
|
interface StripePaymentElementHandle {
|
|
@@ -19,6 +60,43 @@ interface StripePaymentElementHandle {
|
|
|
19
60
|
// rejects `paymentMethodTypes` next to a `clientSecret`, since it only applies
|
|
20
61
|
// to an Elements instance created in deferred-intent `mode`.
|
|
21
62
|
const ALLOWED_CARD_BRANDS = ["visa", "mastercard"];
|
|
63
|
+
const UNKNOWN_STRIPE_ERROR = "Stripe could not confirm the card";
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The `confirmSetup` parameters. The element hides the email field, and
|
|
67
|
+
* Stripe then refuses any confirmation that does not carry the email itself.
|
|
68
|
+
*/
|
|
69
|
+
export function buildSetupConfirmation(
|
|
70
|
+
elements: unknown,
|
|
71
|
+
email: string,
|
|
72
|
+
): SetupConfirmation {
|
|
73
|
+
return {
|
|
74
|
+
elements,
|
|
75
|
+
redirect: "if_required",
|
|
76
|
+
confirmParams: {
|
|
77
|
+
payment_method_data: { billing_details: { email } },
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Maps a `confirmSetup` outcome to the confirmed card, or Stripe's reason. */
|
|
83
|
+
export function toConfirmResult(result: SetupIntentResult): ConfirmResult {
|
|
84
|
+
if (result.error) {
|
|
85
|
+
return { error: { message: result.error.message ?? UNKNOWN_STRIPE_ERROR } };
|
|
86
|
+
}
|
|
87
|
+
const paymentMethodId = result.setupIntent?.payment_method;
|
|
88
|
+
if (typeof paymentMethodId !== "string") {
|
|
89
|
+
return { error: { message: "No payment method returned" } };
|
|
90
|
+
}
|
|
91
|
+
return { paymentMethodId };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Stripe.js throws, rather than returns, integration errors: without this
|
|
95
|
+
// the caller only sees its own generic failure and the cause is lost.
|
|
96
|
+
function toThrownConfirmResult(error: unknown): ConfirmResult {
|
|
97
|
+
const message = error instanceof Error ? error.message : "";
|
|
98
|
+
return { error: { message: message || UNKNOWN_STRIPE_ERROR } };
|
|
99
|
+
}
|
|
22
100
|
|
|
23
101
|
export function useStripePaymentElement(
|
|
24
102
|
options: UseStripePaymentElementOptions,
|
|
@@ -49,18 +127,17 @@ export function useStripePaymentElement(
|
|
|
49
127
|
if (!stripeInstance || !elementsInstance) {
|
|
50
128
|
return { error: { message: "Stripe not initialized" } };
|
|
51
129
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
return
|
|
130
|
+
try {
|
|
131
|
+
const result: SetupIntentResult = await stripeInstance.confirmSetup(
|
|
132
|
+
buildSetupConfirmation(
|
|
133
|
+
elementsInstance,
|
|
134
|
+
toValue(options.billingEmail),
|
|
135
|
+
),
|
|
136
|
+
);
|
|
137
|
+
return toConfirmResult(result);
|
|
138
|
+
} catch (error) {
|
|
139
|
+
return toThrownConfirmResult(error);
|
|
62
140
|
}
|
|
63
|
-
return { paymentMethodId };
|
|
64
141
|
}
|
|
65
142
|
|
|
66
143
|
onUnmounted(() => {
|
|
@@ -2,6 +2,10 @@ import type { PlanInterval } from "./usePlanIntervalLabel";
|
|
|
2
2
|
|
|
3
3
|
const TENANT_PLAN_ENDPOINT = "/api/saas/tenant/plan";
|
|
4
4
|
const TENANT_PLAN_STATE_KEY = "saas-tenant-plan";
|
|
5
|
+
const TENANT_CHECKOUT_ENDPOINT = `${TENANT_PLAN_ENDPOINT}/checkout`;
|
|
6
|
+
|
|
7
|
+
/** Query parameter naming the checkout a Stripe cancel link belongs to. */
|
|
8
|
+
export const CHECKOUT_OPERATION_PARAM = "checkoutOperation";
|
|
5
9
|
|
|
6
10
|
export interface TenantPlanFeature {
|
|
7
11
|
featureId: string;
|
|
@@ -68,6 +72,24 @@ export interface ChangePlanResult {
|
|
|
68
72
|
checkoutUrl: string | null;
|
|
69
73
|
}
|
|
70
74
|
|
|
75
|
+
/** The Stripe Checkout a workspace is waiting on. */
|
|
76
|
+
export interface PendingCheckout {
|
|
77
|
+
targetPlanId: string | null;
|
|
78
|
+
/** Where the owner resumes paying; null once nothing is left to pay. */
|
|
79
|
+
checkoutUrl: string | null;
|
|
80
|
+
expiresAt: string | null;
|
|
81
|
+
/** Paid, and waiting for Stripe to confirm it. */
|
|
82
|
+
isPaid: boolean;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface PendingCheckoutResult {
|
|
86
|
+
pending: PendingCheckout | null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface CancelCheckoutResult {
|
|
90
|
+
released: boolean;
|
|
91
|
+
}
|
|
92
|
+
|
|
71
93
|
/** The payload resolves inheritance for every public plan and carries the
|
|
72
94
|
* features catalogue, so the plan card and the free-access banner share it. */
|
|
73
95
|
export function useTenantPlan() {
|
|
@@ -91,5 +113,23 @@ export function useTenantPlan() {
|
|
|
91
113
|
});
|
|
92
114
|
}
|
|
93
115
|
|
|
94
|
-
|
|
116
|
+
function loadPendingCheckout(): Promise<PendingCheckoutResult> {
|
|
117
|
+
return $authFetch<PendingCheckoutResult>(TENANT_CHECKOUT_ENDPOINT);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Without an operation id, gives up on whatever checkout is pending. */
|
|
121
|
+
function cancelCheckout(operationId?: string): Promise<CancelCheckoutResult> {
|
|
122
|
+
return $authFetch<CancelCheckoutResult>(TENANT_CHECKOUT_ENDPOINT, {
|
|
123
|
+
method: "DELETE",
|
|
124
|
+
query: operationId ? { [CHECKOUT_OPERATION_PARAM]: operationId } : {},
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
...shared,
|
|
130
|
+
changePlan,
|
|
131
|
+
cancelPendingChange,
|
|
132
|
+
loadPendingCheckout,
|
|
133
|
+
cancelCheckout,
|
|
134
|
+
};
|
|
95
135
|
}
|
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
"unpaid_invoice_blocks_downgrade": "An invoice is unpaid. Settle it before changing to a cheaper plan.",
|
|
46
46
|
"no_free_plan": "No free plan is available for sign-up right now.",
|
|
47
47
|
"checkout_in_progress": "A payment for a plan change is already open. Complete it, or wait for it to expire, before choosing another plan.",
|
|
48
|
+
"checkout_already_paid": "This payment has already been made and is being confirmed. Your plan will update in a moment.",
|
|
48
49
|
"change_in_progress": "A plan change is already being processed. Try again in a few minutes."
|
|
49
50
|
},
|
|
50
51
|
"workspace": {
|
|
@@ -1081,6 +1082,17 @@
|
|
|
1081
1082
|
"change_applied": "Plan changed.",
|
|
1082
1083
|
"change_scheduled": "Downgrade scheduled for the end of the current cycle.",
|
|
1083
1084
|
"change_error": "Failed to change plan",
|
|
1085
|
+
"checkout_pending": {
|
|
1086
|
+
"title": "A payment is already open",
|
|
1087
|
+
"description": "You opened the secure Stripe payment page without completing it. Resume that payment, or cancel it and start over.",
|
|
1088
|
+
"description_paid": "Your payment has been received and is being confirmed. Your plan will update in a moment.",
|
|
1089
|
+
"description_opening": "A payment page is still being opened for this workspace. Try again in a few minutes.",
|
|
1090
|
+
"resume": "Resume payment",
|
|
1091
|
+
"restart": "Cancel and start over",
|
|
1092
|
+
"close": "Close",
|
|
1093
|
+
"cancelled": "Payment cancelled. Nothing was charged and your plan is unchanged.",
|
|
1094
|
+
"restart_error": "The pending payment could not be cancelled. Try again in a moment."
|
|
1095
|
+
},
|
|
1084
1096
|
"interval": {
|
|
1085
1097
|
"month": "month",
|
|
1086
1098
|
"year": "year"
|
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
"unpaid_invoice_blocks_downgrade": "Une facture est impayée. Réglez-la avant de passer à un plan moins cher.",
|
|
46
46
|
"no_free_plan": "Aucun plan gratuit n'est disponible pour l'inscription pour le moment.",
|
|
47
47
|
"checkout_in_progress": "Un paiement de changement de plan est déjà ouvert. Terminez-le, ou attendez son expiration, avant de choisir un autre plan.",
|
|
48
|
+
"checkout_already_paid": "Ce paiement a déjà été effectué et est en cours de confirmation. Votre plan sera mis à jour dans un instant.",
|
|
48
49
|
"change_in_progress": "Un changement de plan est déjà en cours. Réessayez dans quelques minutes."
|
|
49
50
|
},
|
|
50
51
|
"workspace": {
|
|
@@ -1081,6 +1082,17 @@
|
|
|
1081
1082
|
"change_applied": "Plan modifié.",
|
|
1082
1083
|
"change_scheduled": "Downgrade programmé pour la fin du cycle en cours.",
|
|
1083
1084
|
"change_error": "Échec du changement de plan",
|
|
1085
|
+
"checkout_pending": {
|
|
1086
|
+
"title": "Un paiement est déjà en cours",
|
|
1087
|
+
"description": "Vous avez ouvert la page de paiement sécurisée de Stripe sans la terminer. Reprenez ce paiement, ou annulez-le pour recommencer.",
|
|
1088
|
+
"description_paid": "Votre paiement a été reçu et est en cours de confirmation. Votre plan sera mis à jour dans un instant.",
|
|
1089
|
+
"description_opening": "Une page de paiement est encore en cours d'ouverture pour cet espace de travail. Réessayez dans quelques minutes.",
|
|
1090
|
+
"resume": "Reprendre le paiement",
|
|
1091
|
+
"restart": "Annuler et recommencer",
|
|
1092
|
+
"close": "Fermer",
|
|
1093
|
+
"cancelled": "Paiement annulé. Rien n'a été débité et votre plan est inchangé.",
|
|
1094
|
+
"restart_error": "Le paiement en cours n'a pas pu être annulé. Réessayez dans un instant."
|
|
1095
|
+
},
|
|
1084
1096
|
"interval": {
|
|
1085
1097
|
"month": "mois",
|
|
1086
1098
|
"year": "an"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@antelopejs/dms-saas",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.11",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"@antelopejs/interface-dms": ">=0.3.2 <1.0.0",
|
|
39
39
|
"@antelopejs/interface-dms-saas": ">=0.2.3 <0.3.0",
|
|
40
40
|
"node-cron": "^4.2.1",
|
|
41
|
-
"stripe": "^
|
|
41
|
+
"stripe": "^22.6.2",
|
|
42
42
|
"zod": "^3.24.4"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|