@carlonicora/nextjs-jsonapi 5.5.0 → 5.6.0
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/dist/{BlockNoteEditor-THBJH2UR.js → BlockNoteEditor-AE3ZTEEH.js} +19 -19
- package/dist/{BlockNoteEditor-THBJH2UR.js.map → BlockNoteEditor-AE3ZTEEH.js.map} +1 -1
- package/dist/{BlockNoteEditor-GVQCKRMU.mjs → BlockNoteEditor-NEYG5L6W.mjs} +4 -4
- package/dist/{BlockNoteViewer-U6E3DS4F.mjs → BlockNoteViewer-3OUT5UFY.mjs} +2 -2
- package/dist/{BlockNoteViewer-VZOVKTFO.js → BlockNoteViewer-4CFKQM7U.js} +3 -3
- package/dist/{BlockNoteViewer-VZOVKTFO.js.map → BlockNoteViewer-4CFKQM7U.js.map} +1 -1
- package/dist/billing/index.d.mts +186 -25
- package/dist/billing/index.d.ts +186 -25
- package/dist/billing/index.js +1148 -587
- package/dist/billing/index.js.map +1 -1
- package/dist/billing/index.mjs +785 -224
- package/dist/billing/index.mjs.map +1 -1
- package/dist/{chunk-WPYT4UKJ.js → chunk-3I5AAJXV.js} +3 -3
- package/dist/{chunk-WPYT4UKJ.js.map → chunk-3I5AAJXV.js.map} +1 -1
- package/dist/{chunk-YDYVJM4I.js → chunk-53BAULDP.js} +4 -4
- package/dist/{chunk-YDYVJM4I.js.map → chunk-53BAULDP.js.map} +1 -1
- package/dist/{chunk-3CGROPF3.mjs → chunk-BTPUYUQ3.mjs} +2 -2
- package/dist/{chunk-VC5A3AUZ.mjs → chunk-EYA2F6AN.mjs} +696 -4
- package/dist/chunk-EYA2F6AN.mjs.map +1 -0
- package/dist/{chunk-G6UE6MEP.js → chunk-HKZAESS6.js} +896 -204
- package/dist/chunk-HKZAESS6.js.map +1 -0
- package/dist/{chunk-W7F5BDSV.mjs → chunk-KQHBONL4.mjs} +2 -2
- package/dist/{chunk-TY5FYP3G.js → chunk-LQ4DK4JF.js} +1919 -1056
- package/dist/chunk-LQ4DK4JF.js.map +1 -0
- package/dist/{chunk-S4YJ5WCW.js → chunk-RL3PL3KV.js} +8 -8
- package/dist/{chunk-S4YJ5WCW.js.map → chunk-RL3PL3KV.js.map} +1 -1
- package/dist/{chunk-X5WIUUNG.mjs → chunk-SFYPXCK4.mjs} +3237 -2374
- package/dist/chunk-SFYPXCK4.mjs.map +1 -0
- package/dist/{chunk-6SQYXUVP.mjs → chunk-Y7RR57EW.mjs} +2 -2
- package/dist/client/index.js +4 -4
- package/dist/client/index.mjs +3 -3
- package/dist/components/index.js +4 -4
- package/dist/components/index.mjs +3 -3
- package/dist/contexts/index.js +4 -4
- package/dist/contexts/index.mjs +3 -3
- package/dist/core/index.d.mts +242 -3
- package/dist/core/index.d.ts +242 -3
- package/dist/core/index.js +34 -2
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +33 -1
- package/dist/email-automations/index.js +92 -92
- package/dist/email-automations/index.mjs +3 -3
- package/dist/features/analytics/index.js +121 -121
- package/dist/features/analytics/index.mjs +4 -4
- package/dist/features/analytics/server.js +4 -4
- package/dist/features/analytics/server.mjs +2 -2
- package/dist/features/blog/index.js +150 -150
- package/dist/features/blog/index.mjs +3 -3
- package/dist/features/blog/server.js +16 -16
- package/dist/features/blog/server.mjs +1 -1
- package/dist/features/help/index.js +37 -37
- package/dist/features/help/index.mjs +3 -3
- package/dist/features/tokenusage/index.js +124 -124
- package/dist/features/tokenusage/index.mjs +4 -4
- package/dist/{stripe-promotion-code.interface-C3qqh3mi.d.ts → format-options-BtX3o0Yu.d.ts} +104 -1
- package/dist/{stripe-promotion-code.interface-ClZ7DxS9.d.mts → format-options-D6c7FNDi.d.mts} +104 -1
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +35 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +34 -2
- package/dist/server/index.js +3 -3
- package/dist/server/index.mjs +2 -2
- package/messages/en.json +125 -3
- package/messages/it.json +125 -3
- package/package.json +1 -1
- package/scripts/validate-messages.mjs +1 -0
- package/src/billing/index.ts +1 -0
- package/src/client/index.ts +4 -0
- package/src/core/index.ts +1 -0
- package/src/core/registry/ModuleRegistry.ts +3 -0
- package/src/features/billing/components/cards/InvoicesSummaryCard.tsx +3 -2
- package/src/features/billing/components/cards/SubscriptionSummaryCard.tsx +4 -3
- package/src/features/billing/components/containers/BillingDashboardContainer.tsx +22 -5
- package/src/features/billing/components/utils/__tests__/discount.spec.ts +59 -0
- package/src/features/billing/components/utils/__tests__/url.spec.ts +32 -0
- package/src/features/billing/components/utils/discount.ts +38 -0
- package/src/features/billing/components/utils/index.ts +2 -0
- package/src/features/billing/components/utils/url.ts +12 -0
- package/src/features/billing/contexts/CouponContext.tsx +211 -0
- package/src/features/billing/contexts/index.ts +1 -0
- package/src/features/billing/i18n-keys.ts +79 -0
- package/src/features/billing/stripe-coupon/components/containers/CouponContainer.tsx +38 -0
- package/src/features/billing/stripe-coupon/components/containers/index.ts +1 -0
- package/src/features/billing/stripe-coupon/components/details/CouponDetails.tsx +69 -0
- package/src/features/billing/stripe-coupon/components/details/index.ts +1 -0
- package/src/features/billing/stripe-coupon/components/forms/CouponCodeEditor.tsx +79 -0
- package/src/features/billing/stripe-coupon/components/forms/CouponCodeFields.tsx +192 -0
- package/src/features/billing/stripe-coupon/components/forms/CouponEditor.tsx +333 -0
- package/src/features/billing/stripe-coupon/components/forms/CouponRefresher.tsx +45 -0
- package/src/features/billing/stripe-coupon/components/forms/index.ts +6 -0
- package/src/features/billing/stripe-coupon/components/index.ts +5 -0
- package/src/features/billing/stripe-coupon/components/lists/CouponCodesList.tsx +61 -0
- package/src/features/billing/stripe-coupon/components/lists/CouponsList.tsx +67 -0
- package/src/features/billing/stripe-coupon/components/lists/index.ts +2 -0
- package/src/features/billing/stripe-coupon/components/widgets/CouponCodeActions.tsx +80 -0
- package/src/features/billing/stripe-coupon/components/widgets/index.ts +1 -0
- package/src/features/billing/stripe-coupon/data/__tests__/stripe-coupon-models.spec.ts +114 -0
- package/src/features/billing/stripe-coupon/data/index.ts +10 -0
- package/src/features/billing/stripe-coupon/data/stripe-coupon-code.fields.ts +8 -0
- package/src/features/billing/stripe-coupon/data/stripe-coupon-code.interface.ts +44 -0
- package/src/features/billing/stripe-coupon/data/stripe-coupon-code.service.ts +92 -0
- package/src/features/billing/stripe-coupon/data/stripe-coupon-code.ts +132 -0
- package/src/features/billing/stripe-coupon/data/stripe-coupon-offer.interface.ts +17 -0
- package/src/features/billing/stripe-coupon/data/stripe-coupon-offer.ts +70 -0
- package/src/features/billing/stripe-coupon/data/stripe-coupon.fields.ts +7 -0
- package/src/features/billing/stripe-coupon/data/stripe-coupon.interface.ts +51 -0
- package/src/features/billing/stripe-coupon/data/stripe-coupon.service.ts +93 -0
- package/src/features/billing/stripe-coupon/data/stripe-coupon.ts +165 -0
- package/src/features/billing/stripe-coupon/hooks/useStripeCouponCodeTableStructure.tsx +154 -0
- package/src/features/billing/stripe-coupon/hooks/useStripeCouponTableStructure.tsx +113 -0
- package/src/features/billing/stripe-coupon/index.ts +5 -0
- package/src/features/billing/stripe-coupon/stripe-coupon-code.module.ts +14 -0
- package/src/features/billing/stripe-coupon/stripe-coupon-offer.module.ts +10 -0
- package/src/features/billing/stripe-coupon/stripe-coupon.module.ts +18 -0
- package/src/features/billing/stripe-coupon/utils/__tests__/coupon-places.spec.ts +19 -0
- package/src/features/billing/stripe-coupon/utils/__tests__/to-promotion-code-result.spec.ts +41 -0
- package/src/features/billing/stripe-coupon/utils/coupon-places.ts +23 -0
- package/src/features/billing/stripe-coupon/utils/format-coupon-discount.ts +36 -0
- package/src/features/billing/stripe-coupon/utils/index.ts +3 -0
- package/src/features/billing/stripe-coupon/utils/to-promotion-code-result.ts +23 -0
- package/src/features/billing/stripe-customer/components/forms/PaymentMethodForm.tsx +24 -8
- package/src/features/billing/stripe-product/components/containers/ProductsListContainer.tsx +21 -6
- package/src/features/billing/stripe-promotion-code/components/PromoCodeInput.tsx +60 -19
- package/src/features/billing/stripe-promotion-code/data/stripe-promotion-code.interface.ts +2 -0
- package/src/features/billing/stripe-subscription/components/containers/SubscriptionsContainer.tsx +24 -5
- package/src/features/billing/stripe-subscription/components/lists/SubscriptionsList.tsx +21 -2
- package/src/features/billing/stripe-subscription/components/wizards/SubscriptionWizard.tsx +1 -1
- package/src/features/billing/stripe-subscription/components/wizards/WizardStepPaymentMethod.tsx +1 -1
- package/src/features/billing/stripe-subscription/components/wizards/WizardStepReview.tsx +60 -49
- package/src/features/billing/stripe-subscription/components/wizards/__tests__/review-card.spec.ts +34 -0
- package/src/features/billing/stripe-subscription/components/wizards/review-card.ts +24 -0
- package/src/features/billing/stripe-subscription/data/__tests__/stripe-subscription.discount.spec.ts +67 -0
- package/src/features/billing/stripe-subscription/data/stripe-subscription.interface.ts +14 -0
- package/src/features/billing/stripe-subscription/data/stripe-subscription.service.ts +39 -0
- package/src/features/billing/stripe-subscription/data/stripe-subscription.ts +21 -0
- package/src/features/billing/stripe-subscription/hooks/__tests__/wizard-payment.spec.ts +48 -0
- package/src/features/billing/stripe-subscription/hooks/__tests__/wizard-promotion.spec.ts +132 -0
- package/src/features/billing/stripe-subscription/hooks/useSubscriptionWizard.ts +138 -12
- package/src/features/billing/stripe-subscription/hooks/wizard-payment.ts +30 -0
- package/src/features/billing/stripe-subscription/hooks/wizard-promotion.ts +54 -0
- package/src/features/user/contexts/CurrentUserContext.tsx +25 -0
- package/src/features/user/contexts/__tests__/CurrentUserContext.spec.tsx +121 -2
- package/dist/chunk-G6UE6MEP.js.map +0 -1
- package/dist/chunk-TY5FYP3G.js.map +0 -1
- package/dist/chunk-VC5A3AUZ.mjs.map +0 -1
- package/dist/chunk-X5WIUUNG.mjs.map +0 -1
- /package/dist/{BlockNoteEditor-GVQCKRMU.mjs.map → BlockNoteEditor-NEYG5L6W.mjs.map} +0 -0
- /package/dist/{BlockNoteViewer-U6E3DS4F.mjs.map → BlockNoteViewer-3OUT5UFY.mjs.map} +0 -0
- /package/dist/{chunk-3CGROPF3.mjs.map → chunk-BTPUYUQ3.mjs.map} +0 -0
- /package/dist/{chunk-W7F5BDSV.mjs.map → chunk-KQHBONL4.mjs.map} +0 -0
- /package/dist/{chunk-6SQYXUVP.mjs.map → chunk-Y7RR57EW.mjs.map} +0 -0
package/src/features/billing/stripe-subscription/components/wizards/__tests__/review-card.spec.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { PaymentMethodInterface } from "../../../../stripe-customer/data/payment-method.interface";
|
|
3
|
+
import { reviewCard } from "../review-card";
|
|
4
|
+
|
|
5
|
+
const card = (id: string, brand: string, last4: string): PaymentMethodInterface => ({
|
|
6
|
+
id,
|
|
7
|
+
type: "card",
|
|
8
|
+
card: { brand, last4, expMonth: 1, expYear: 2030 },
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
describe("reviewCard", () => {
|
|
12
|
+
it("returns null when there is no card", () => {
|
|
13
|
+
expect(reviewCard([], "pm_1")).toBe(null);
|
|
14
|
+
expect(reviewCard(null, null)).toBe(null);
|
|
15
|
+
expect(reviewCard(undefined, undefined)).toBe(null);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("returns the customer's default card", () => {
|
|
19
|
+
expect(reviewCard([card("pm_new", "visa", "4242"), card("pm_old", "mastercard", "0005")], "pm_old")).toEqual({
|
|
20
|
+
brand: "Mastercard",
|
|
21
|
+
last4: "0005",
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("falls back to the first card when no default is set or the default is gone", () => {
|
|
26
|
+
const methods = [card("pm_new", "visa", "4242"), card("pm_old", "mastercard", "0005")];
|
|
27
|
+
expect(reviewCard(methods, null)).toEqual({ brand: "Visa", last4: "4242" });
|
|
28
|
+
expect(reviewCard(methods, "pm_detached")).toEqual({ brand: "Visa", last4: "4242" });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("returns null when the card has no last 4 digits", () => {
|
|
32
|
+
expect(reviewCard([{ id: "pm_1", type: "sepa_debit" }], "pm_1")).toBe(null);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { PaymentMethodInterface } from "../../../stripe-customer/data/payment-method.interface";
|
|
2
|
+
|
|
3
|
+
export type ReviewCard = { brand: string; last4: string };
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The card the review shows as the one on file: the customer's default, or the
|
|
7
|
+
* first card listed when no default is set (or the default is no longer listed).
|
|
8
|
+
* Null when no card with a brand and last 4 digits is known.
|
|
9
|
+
*/
|
|
10
|
+
export function reviewCard(
|
|
11
|
+
methods: PaymentMethodInterface[] | null | undefined,
|
|
12
|
+
defaultPaymentMethodId: string | null | undefined,
|
|
13
|
+
): ReviewCard | null {
|
|
14
|
+
if (!methods || methods.length === 0) return null;
|
|
15
|
+
|
|
16
|
+
const method = (defaultPaymentMethodId && methods.find((m) => m.id === defaultPaymentMethodId)) || methods[0];
|
|
17
|
+
if (!method.card?.last4) return null;
|
|
18
|
+
|
|
19
|
+
const brand = method.card.brand
|
|
20
|
+
? method.card.brand.charAt(0).toUpperCase() + method.card.brand.slice(1)
|
|
21
|
+
: method.type.charAt(0).toUpperCase() + method.type.slice(1);
|
|
22
|
+
|
|
23
|
+
return { brand, last4: method.card.last4 };
|
|
24
|
+
}
|
package/src/features/billing/stripe-subscription/data/__tests__/stripe-subscription.discount.spec.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { beforeAll, describe, expect, it } from "vitest";
|
|
2
|
+
import { ModuleRegistry } from "../../../../../core/registry/ModuleRegistry";
|
|
3
|
+
import { ModuleFactory, ModuleWithPermissions } from "../../../../../permissions/types";
|
|
4
|
+
import { StripePriceModule } from "../../../stripe-price/stripe-price.module";
|
|
5
|
+
import { StripeSubscription } from "../stripe-subscription";
|
|
6
|
+
|
|
7
|
+
// rehydrate reads Modules.StripePrice for the price relationship, so the module must be registered.
|
|
8
|
+
const moduleFactory: ModuleFactory = (params) =>
|
|
9
|
+
({ pageUrl: params.pageUrl, name: params.name, model: params.model }) as ModuleWithPermissions;
|
|
10
|
+
|
|
11
|
+
beforeAll(() => {
|
|
12
|
+
ModuleRegistry.register("StripePrice", StripePriceModule(moduleFactory));
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const subscriptionWith = (attributes: Record<string, unknown>) =>
|
|
16
|
+
new StripeSubscription().rehydrate({
|
|
17
|
+
jsonApi: {
|
|
18
|
+
id: "sub-1",
|
|
19
|
+
type: "stripe-subscriptions",
|
|
20
|
+
attributes: {
|
|
21
|
+
stripeSubscriptionId: "sub_1",
|
|
22
|
+
status: "active",
|
|
23
|
+
currentPeriodStart: "2026-10-01T00:00:00.000Z",
|
|
24
|
+
currentPeriodEnd: "2026-11-01T00:00:00.000Z",
|
|
25
|
+
...attributes,
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
included: [],
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("StripeSubscription discount", () => {
|
|
32
|
+
it("rehydrates a percentage discount that lasts forever", () => {
|
|
33
|
+
const subscription = subscriptionWith({ discountPercentOff: 20, discountDuration: "forever" });
|
|
34
|
+
|
|
35
|
+
expect(subscription.discount).toEqual({
|
|
36
|
+
percentOff: 20,
|
|
37
|
+
amountOff: undefined,
|
|
38
|
+
currency: undefined,
|
|
39
|
+
duration: "forever",
|
|
40
|
+
durationInMonths: undefined,
|
|
41
|
+
end: undefined,
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("rehydrates a repeating amount discount with its end as a Date", () => {
|
|
46
|
+
const subscription = subscriptionWith({
|
|
47
|
+
discountAmountOff: 200,
|
|
48
|
+
discountCurrency: "eur",
|
|
49
|
+
discountDuration: "repeating",
|
|
50
|
+
discountDurationInMonths: 3,
|
|
51
|
+
discountEnd: "2027-01-01T00:00:00.000Z",
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
expect(subscription.discount?.amountOff).toBe(200);
|
|
55
|
+
expect(subscription.discount?.currency).toBe("eur");
|
|
56
|
+
expect(subscription.discount?.durationInMonths).toBe(3);
|
|
57
|
+
expect(subscription.discount?.end).toEqual(new Date("2027-01-01T00:00:00.000Z"));
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("has no discount when the API sends none", () => {
|
|
61
|
+
expect(subscriptionWith({}).discount).toBeUndefined();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("has no discount when the duration comes without an amount", () => {
|
|
65
|
+
expect(subscriptionWith({ discountDuration: "forever" }).discount).toBeUndefined();
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -19,6 +19,19 @@ export enum SubscriptionStatus {
|
|
|
19
19
|
// Subscription Interfaces
|
|
20
20
|
// ============================================================================
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* The discount Stripe applies to the subscription (read-only, synced from Stripe).
|
|
24
|
+
* Exactly one of percentOff / amountOff is set.
|
|
25
|
+
*/
|
|
26
|
+
export type StripeSubscriptionDiscount = {
|
|
27
|
+
percentOff?: number;
|
|
28
|
+
amountOff?: number; // minor units, in currency
|
|
29
|
+
currency?: string;
|
|
30
|
+
duration: "forever" | "once" | "repeating";
|
|
31
|
+
durationInMonths?: number;
|
|
32
|
+
end?: Date; // when a repeating discount stops
|
|
33
|
+
};
|
|
34
|
+
|
|
22
35
|
export interface StripeSubscriptionInterface extends ApiDataInterface {
|
|
23
36
|
get stripeSubscriptionId(): string;
|
|
24
37
|
get status(): SubscriptionStatus;
|
|
@@ -29,6 +42,7 @@ export interface StripeSubscriptionInterface extends ApiDataInterface {
|
|
|
29
42
|
get trialStart(): Date | undefined;
|
|
30
43
|
get trialEnd(): Date | undefined;
|
|
31
44
|
get price(): StripePriceInterface | undefined;
|
|
45
|
+
get discount(): StripeSubscriptionDiscount | undefined;
|
|
32
46
|
}
|
|
33
47
|
|
|
34
48
|
// ============================================================================
|
|
@@ -65,6 +65,8 @@ export class StripeSubscriptionService extends AbstractService {
|
|
|
65
65
|
method: HttpMethod.POST,
|
|
66
66
|
endpoint: endpoint.generate(),
|
|
67
67
|
input: params,
|
|
68
|
+
// The wizard reads 410 / 409 / 402 / 422 itself; a global handler must not swallow them
|
|
69
|
+
suppressGlobalError: true,
|
|
68
70
|
});
|
|
69
71
|
|
|
70
72
|
return {
|
|
@@ -92,9 +94,44 @@ export class StripeSubscriptionService extends AbstractService {
|
|
|
92
94
|
method: HttpMethod.POST,
|
|
93
95
|
endpoint: endpoint.generate(),
|
|
94
96
|
input: params,
|
|
97
|
+
// The wizard reads 410 / 409 / 402 / 422 itself; a global handler must not swallow them
|
|
98
|
+
suppressGlobalError: true,
|
|
95
99
|
});
|
|
96
100
|
}
|
|
97
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Change the plan of an existing subscription, with the payment meta.
|
|
104
|
+
*
|
|
105
|
+
* A trial upgrade charges at once; when the card asks for 3D Secure the API
|
|
106
|
+
* answers with `meta.requiresAction` and the `clientSecret` to confirm in the
|
|
107
|
+
* browser. Without that meta the change needs nothing more.
|
|
108
|
+
*/
|
|
109
|
+
static async changePlanWithMeta(params: StripeSubscriptionInput): Promise<StripeSubscriptionCreateResponse> {
|
|
110
|
+
const endpoint = new EndpointCreator({
|
|
111
|
+
endpoint: Modules.StripeSubscription,
|
|
112
|
+
id: params.id,
|
|
113
|
+
childEndpoint: "change-plan",
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const result = await this.callApiWithMeta<StripeSubscriptionInterface>({
|
|
117
|
+
type: Modules.StripeSubscription,
|
|
118
|
+
method: HttpMethod.POST,
|
|
119
|
+
endpoint: endpoint.generate(),
|
|
120
|
+
input: params,
|
|
121
|
+
// The wizard reads 410 / 409 / 402 / 422 itself; a global handler must not swallow them
|
|
122
|
+
suppressGlobalError: true,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
subscription: result.data,
|
|
127
|
+
meta: (result.meta as StripeSubscriptionCreateMeta) ?? {
|
|
128
|
+
clientSecret: null,
|
|
129
|
+
paymentIntentId: null,
|
|
130
|
+
requiresAction: false,
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
98
135
|
/**
|
|
99
136
|
* Get a proration preview for a plan change
|
|
100
137
|
*/
|
|
@@ -188,6 +225,8 @@ export class StripeSubscriptionService extends AbstractService {
|
|
|
188
225
|
type: Modules.StripeSubscription,
|
|
189
226
|
method: HttpMethod.POST,
|
|
190
227
|
endpoint: endpoint.generate(),
|
|
228
|
+
// The wizard logs a failed sync and carries on (the webhook catches up): no global error after a paid subscription
|
|
229
|
+
suppressGlobalError: true,
|
|
191
230
|
});
|
|
192
231
|
}
|
|
193
232
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { AbstractApiData, JsonApiHydratedDataInterface, Modules } from "../../../../core";
|
|
2
2
|
import { StripePriceInterface } from "../../stripe-price";
|
|
3
3
|
import {
|
|
4
|
+
StripeSubscriptionDiscount,
|
|
4
5
|
StripeSubscriptionInput,
|
|
5
6
|
StripeSubscriptionInterface,
|
|
6
7
|
SubscriptionStatus,
|
|
@@ -16,6 +17,7 @@ export class StripeSubscription extends AbstractApiData implements StripeSubscri
|
|
|
16
17
|
private _trialStart?: Date;
|
|
17
18
|
private _trialEnd?: Date;
|
|
18
19
|
private _price?: StripePriceInterface;
|
|
20
|
+
private _discount?: StripeSubscriptionDiscount;
|
|
19
21
|
|
|
20
22
|
get stripeSubscriptionId(): string {
|
|
21
23
|
if (!this._stripeSubscriptionId) throw new Error("stripeSubscriptionId is not defined");
|
|
@@ -57,6 +59,10 @@ export class StripeSubscription extends AbstractApiData implements StripeSubscri
|
|
|
57
59
|
return this._price;
|
|
58
60
|
}
|
|
59
61
|
|
|
62
|
+
get discount(): StripeSubscriptionDiscount | undefined {
|
|
63
|
+
return this._discount;
|
|
64
|
+
}
|
|
65
|
+
|
|
60
66
|
rehydrate(data: JsonApiHydratedDataInterface): this {
|
|
61
67
|
super.rehydrate(data);
|
|
62
68
|
|
|
@@ -76,6 +82,21 @@ export class StripeSubscription extends AbstractApiData implements StripeSubscri
|
|
|
76
82
|
this._trialStart = data.jsonApi.attributes.trialStart ? new Date(data.jsonApi.attributes.trialStart) : undefined;
|
|
77
83
|
this._trialEnd = data.jsonApi.attributes.trialEnd ? new Date(data.jsonApi.attributes.trialEnd) : undefined;
|
|
78
84
|
|
|
85
|
+
const attributes = data.jsonApi.attributes;
|
|
86
|
+
const percentOff = attributes.discountPercentOff ?? undefined;
|
|
87
|
+
const amountOff = attributes.discountAmountOff ?? undefined;
|
|
88
|
+
this._discount =
|
|
89
|
+
attributes.discountDuration && (percentOff !== undefined || amountOff !== undefined)
|
|
90
|
+
? {
|
|
91
|
+
percentOff,
|
|
92
|
+
amountOff,
|
|
93
|
+
currency: attributes.discountCurrency ?? undefined,
|
|
94
|
+
duration: attributes.discountDuration,
|
|
95
|
+
durationInMonths: attributes.discountDurationInMonths ?? undefined,
|
|
96
|
+
end: attributes.discountEnd ? new Date(attributes.discountEnd) : undefined,
|
|
97
|
+
}
|
|
98
|
+
: undefined;
|
|
99
|
+
|
|
79
100
|
// Hydrate price relationship
|
|
80
101
|
this._price = this._readIncluded(data, "price", Modules.StripePrice) as StripePriceInterface;
|
|
81
102
|
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { clientSecretToConfirm, subscriptionIdToSync } from "../wizard-payment";
|
|
3
|
+
|
|
4
|
+
describe("clientSecretToConfirm", () => {
|
|
5
|
+
it("returns the client secret when the payment waits on the browser", () => {
|
|
6
|
+
expect(clientSecretToConfirm({ requiresAction: true, clientSecret: "pi_1_secret", paymentIntentId: "pi_1" })).toBe(
|
|
7
|
+
"pi_1_secret",
|
|
8
|
+
);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("returns null when no action is required (trial, already paid)", () => {
|
|
12
|
+
expect(clientSecretToConfirm({ requiresAction: false, clientSecret: "pi_1_secret", paymentIntentId: "pi_1" })).toBe(
|
|
13
|
+
null,
|
|
14
|
+
);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("returns null when action is required but there is no client secret", () => {
|
|
18
|
+
expect(clientSecretToConfirm({ requiresAction: true, clientSecret: null, paymentIntentId: null })).toBe(null);
|
|
19
|
+
expect(clientSecretToConfirm({ requiresAction: true, clientSecret: "" })).toBe(null);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("returns null when the answer carries no meta (plan change with nothing to confirm)", () => {
|
|
23
|
+
expect(clientSecretToConfirm(undefined)).toBe(null);
|
|
24
|
+
expect(clientSecretToConfirm(null)).toBe(null);
|
|
25
|
+
expect(clientSecretToConfirm({})).toBe(null);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("subscriptionIdToSync", () => {
|
|
30
|
+
it("syncs the subscription the API just created", () => {
|
|
31
|
+
expect(subscriptionIdToSync({ id: "sub_new" }, undefined)).toBe("sub_new");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("prefers the answered subscription over the one the wizard was opened on", () => {
|
|
35
|
+
expect(subscriptionIdToSync({ id: "sub_answered" }, { id: "sub_opened" })).toBe("sub_answered");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("falls back to the subscription the wizard was opened on (trial upgrade)", () => {
|
|
39
|
+
expect(subscriptionIdToSync(undefined, { id: "sub_trial" })).toBe("sub_trial");
|
|
40
|
+
expect(subscriptionIdToSync({}, { id: "sub_trial" })).toBe("sub_trial");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("returns null when there is no subscription id", () => {
|
|
44
|
+
expect(subscriptionIdToSync(undefined, undefined)).toBe(null);
|
|
45
|
+
expect(subscriptionIdToSync(null, null)).toBe(null);
|
|
46
|
+
expect(subscriptionIdToSync({ id: "" }, {})).toBe(null);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { PromotionCodeValidationResult } from "../../../stripe-promotion-code/data/stripe-promotion-code.interface";
|
|
3
|
+
import {
|
|
4
|
+
isDiscountSoldOut,
|
|
5
|
+
isDiscountUnavailable,
|
|
6
|
+
promotionCodeToSend,
|
|
7
|
+
shouldLookUpAutomatic,
|
|
8
|
+
} from "../wizard-promotion";
|
|
9
|
+
|
|
10
|
+
const typed = { valid: true, promotionCodeId: "promo_typed", code: "SPRING" } as PromotionCodeValidationResult;
|
|
11
|
+
const automatic = {
|
|
12
|
+
valid: true,
|
|
13
|
+
promotionCodeId: "promo_auto",
|
|
14
|
+
code: "FOUNDING",
|
|
15
|
+
automatic: true,
|
|
16
|
+
} as PromotionCodeValidationResult;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The error `AbstractService.callApi` throws for a non-ok response: `status` is the
|
|
20
|
+
* HTTP status and `body` the JSON:API error document the API's HttpExceptionFilter sends.
|
|
21
|
+
*/
|
|
22
|
+
function apiError(status: number, code?: string): any {
|
|
23
|
+
const error = new Error(`${status}:boom`) as any;
|
|
24
|
+
error.status = status;
|
|
25
|
+
error.digest = `HTTP_${status}`;
|
|
26
|
+
error.body = {
|
|
27
|
+
message: "boom",
|
|
28
|
+
errors: [
|
|
29
|
+
{
|
|
30
|
+
status: String(status),
|
|
31
|
+
...(code ? { code } : {}),
|
|
32
|
+
title: "Error",
|
|
33
|
+
detail: "boom",
|
|
34
|
+
source: { pointer: "/stripe-subscriptions" },
|
|
35
|
+
meta: { timestamp: "2026-10-24T00:00:00.000Z", ...(code ? { context: { code } } : {}) },
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
};
|
|
39
|
+
return error;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("promotionCodeToSend", () => {
|
|
43
|
+
it("sends nothing when there is no code", () => {
|
|
44
|
+
expect(promotionCodeToSend(null)).toBeUndefined();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("sends the promotion code id of an automatic code", () => {
|
|
48
|
+
expect(promotionCodeToSend(automatic)).toBe("promo_auto");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("sends the promotion code id of a typed code", () => {
|
|
52
|
+
expect(promotionCodeToSend(typed)).toBe("promo_typed");
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("shouldLookUpAutomatic", () => {
|
|
57
|
+
it("looks up when there is no code", () => {
|
|
58
|
+
expect(shouldLookUpAutomatic(null)).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("looks up again when the current code is automatic", () => {
|
|
62
|
+
expect(shouldLookUpAutomatic(automatic)).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("does not look up when the GM typed a code", () => {
|
|
66
|
+
expect(shouldLookUpAutomatic(typed)).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("isDiscountSoldOut", () => {
|
|
71
|
+
it("is true for a 410 carrying the discount_sold_out code", () => {
|
|
72
|
+
expect(isDiscountSoldOut(apiError(410, "discount_sold_out"))).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("reads the code from meta.context when the top-level code is missing", () => {
|
|
76
|
+
const error = apiError(410, "discount_sold_out");
|
|
77
|
+
delete error.body.errors[0].code;
|
|
78
|
+
expect(isDiscountSoldOut(error)).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("accepts the status on error.response", () => {
|
|
82
|
+
const error = apiError(410, "discount_sold_out");
|
|
83
|
+
delete error.status;
|
|
84
|
+
error.response = { status: 410 };
|
|
85
|
+
expect(isDiscountSoldOut(error)).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("is false for a 410 without the code", () => {
|
|
89
|
+
expect(isDiscountSoldOut(apiError(410))).toBe(false);
|
|
90
|
+
expect(isDiscountSoldOut({ status: 410 })).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("is false for the code on another status", () => {
|
|
94
|
+
expect(isDiscountSoldOut(apiError(422, "discount_sold_out"))).toBe(false);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("is false for other errors", () => {
|
|
98
|
+
expect(isDiscountSoldOut(apiError(409))).toBe(false);
|
|
99
|
+
expect(isDiscountSoldOut(apiError(402))).toBe(false);
|
|
100
|
+
expect(isDiscountSoldOut(apiError(422, "discount_unavailable"))).toBe(false);
|
|
101
|
+
expect(isDiscountSoldOut(new Error("boom"))).toBe(false);
|
|
102
|
+
expect(isDiscountSoldOut(undefined)).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("isDiscountUnavailable", () => {
|
|
107
|
+
it("is true for a 422 carrying the discount_unavailable code", () => {
|
|
108
|
+
expect(isDiscountUnavailable(apiError(422, "discount_unavailable"))).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("reads the code from meta.context when the top-level code is missing", () => {
|
|
112
|
+
const error = apiError(422, "discount_unavailable");
|
|
113
|
+
delete error.body.errors[0].code;
|
|
114
|
+
expect(isDiscountUnavailable(error)).toBe(true);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("is false for a 422 without the code (a typed code's own rejection)", () => {
|
|
118
|
+
expect(isDiscountUnavailable(apiError(422))).toBe(false);
|
|
119
|
+
expect(isDiscountUnavailable({ status: 422 })).toBe(false);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("is false for the code on another status", () => {
|
|
123
|
+
expect(isDiscountUnavailable(apiError(410, "discount_unavailable"))).toBe(false);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("is false for other errors", () => {
|
|
127
|
+
expect(isDiscountUnavailable(apiError(410, "discount_sold_out"))).toBe(false);
|
|
128
|
+
expect(isDiscountUnavailable(apiError(409))).toBe(false);
|
|
129
|
+
expect(isDiscountUnavailable(new Error("boom"))).toBe(false);
|
|
130
|
+
expect(isDiscountUnavailable(undefined)).toBe(false);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -7,9 +7,24 @@ import { useI18nLocale } from "../../../../i18n";
|
|
|
7
7
|
import { StripeCustomerService } from "../../stripe-customer/data/stripe-customer.service";
|
|
8
8
|
import { ProrationPreviewInterface } from "../../stripe-invoice/data/stripe-invoice.interface";
|
|
9
9
|
import { StripePriceInterface } from "../../stripe-price/data/stripe-price.interface";
|
|
10
|
+
import { StripeCouponCodeService } from "../../stripe-coupon/data/stripe-coupon-code.service";
|
|
11
|
+
import { toPromotionCodeResult } from "../../stripe-coupon/utils/to-promotion-code-result";
|
|
10
12
|
import { PromotionCodeValidationResult, StripePromotionCodeService } from "../../stripe-promotion-code";
|
|
11
13
|
import { BillingInterval } from "../components/widgets/IntervalToggle";
|
|
12
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
StripeSubscriptionCreateResponse,
|
|
16
|
+
StripeSubscriptionInterface,
|
|
17
|
+
StripeSubscriptionService,
|
|
18
|
+
SubscriptionStatus,
|
|
19
|
+
} from "../data";
|
|
20
|
+
import {
|
|
21
|
+
isDiscountSoldOut,
|
|
22
|
+
isDiscountUnavailable,
|
|
23
|
+
promotionCodeToSend,
|
|
24
|
+
shouldLookUpAutomatic,
|
|
25
|
+
} from "./wizard-promotion";
|
|
26
|
+
import { useConfirmSubscriptionPayment } from "./useConfirmSubscriptionPayment";
|
|
27
|
+
import { clientSecretToConfirm, subscriptionIdToSync } from "./wizard-payment";
|
|
13
28
|
|
|
14
29
|
export type WizardStep = "plan-selection" | "review" | "payment-method";
|
|
15
30
|
|
|
@@ -97,6 +112,7 @@ export type UseSubscriptionWizardOptions = {
|
|
|
97
112
|
export function useSubscriptionWizard({ subscription, onSuccess, onClose }: UseSubscriptionWizardOptions) {
|
|
98
113
|
const t = useTranslations();
|
|
99
114
|
const locale = useI18nLocale();
|
|
115
|
+
const { confirmPayment } = useConfirmSubscriptionPayment();
|
|
100
116
|
const [state, dispatch] = useReducer(wizardReducer, {
|
|
101
117
|
...initialState,
|
|
102
118
|
selectedPrice: subscription?.price || null,
|
|
@@ -130,6 +146,42 @@ export function useSubscriptionWizard({ subscription, onSuccess, onClose }: UseS
|
|
|
130
146
|
dispatch({ type: "SET_STEP", step });
|
|
131
147
|
}, []);
|
|
132
148
|
|
|
149
|
+
/**
|
|
150
|
+
* Puts the automatic code for this price (or none) in the wizard state, so the
|
|
151
|
+
* review shows what the server would really charge. Returns false when the
|
|
152
|
+
* lookup itself failed: the caller must then not show a price as the truth.
|
|
153
|
+
*/
|
|
154
|
+
const lookUpAutomaticCode = useCallback(async (priceId: string): Promise<boolean> => {
|
|
155
|
+
try {
|
|
156
|
+
const automaticCode = await StripeCouponCodeService.findAutomatic({ priceId });
|
|
157
|
+
dispatch({
|
|
158
|
+
type: "SET_PROMOTION_CODE",
|
|
159
|
+
code: automaticCode ? toPromotionCodeResult(automaticCode) : null,
|
|
160
|
+
});
|
|
161
|
+
return true;
|
|
162
|
+
} catch (error) {
|
|
163
|
+
console.error("[useSubscriptionWizard] Failed to look up the automatic promotion code:", error);
|
|
164
|
+
dispatch({ type: "SET_PROMOTION_CODE", code: null });
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
}, []);
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Re-reads the automatic code after the review's code changed. When the lookup
|
|
171
|
+
* fails, the review price cannot be trusted: back to plan selection.
|
|
172
|
+
*/
|
|
173
|
+
const refreshAutomaticCodeOrLeaveReview = useCallback(
|
|
174
|
+
async (priceId: string): Promise<void> => {
|
|
175
|
+
const found = await lookUpAutomaticCode(priceId);
|
|
176
|
+
if (!found) {
|
|
177
|
+
// SET_STEP clears the error, so the error goes second
|
|
178
|
+
dispatch({ type: "SET_STEP", step: "plan-selection" });
|
|
179
|
+
dispatch({ type: "SET_ERROR", error: t("billing.ui.wizard.errors.prepare_review") });
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
[lookUpAutomaticCode, t],
|
|
183
|
+
);
|
|
184
|
+
|
|
133
185
|
const goToReview = useCallback(async () => {
|
|
134
186
|
if (!state.selectedPrice) return;
|
|
135
187
|
|
|
@@ -156,6 +208,16 @@ export function useSubscriptionWizard({ subscription, onSuccess, onClose }: UseS
|
|
|
156
208
|
}
|
|
157
209
|
}
|
|
158
210
|
|
|
211
|
+
// Fill the automatic code for this price, unless the GM typed one
|
|
212
|
+
// A failed lookup never shows full price as if nothing applied: stay off the review
|
|
213
|
+
if (shouldLookUpAutomatic(state.promotionCode)) {
|
|
214
|
+
const found = await lookUpAutomaticCode(state.selectedPrice.id);
|
|
215
|
+
if (!found) {
|
|
216
|
+
dispatch({ type: "SET_ERROR", error: t("billing.ui.wizard.errors.prepare_review") });
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
159
221
|
// If editing subscription, get proration preview
|
|
160
222
|
if (subscription && state.selectedPrice.id !== subscription.price?.id) {
|
|
161
223
|
const preview = await StripeSubscriptionService.getProrationPreview({
|
|
@@ -172,7 +234,15 @@ export function useSubscriptionWizard({ subscription, onSuccess, onClose }: UseS
|
|
|
172
234
|
} finally {
|
|
173
235
|
dispatch({ type: "SET_PROCESSING", isProcessing: false });
|
|
174
236
|
}
|
|
175
|
-
}, [
|
|
237
|
+
}, [
|
|
238
|
+
state.selectedPrice,
|
|
239
|
+
state.hasPaymentMethod,
|
|
240
|
+
state.promotionCode,
|
|
241
|
+
subscription,
|
|
242
|
+
checkPaymentMethod,
|
|
243
|
+
lookUpAutomaticCode,
|
|
244
|
+
t,
|
|
245
|
+
]);
|
|
176
246
|
|
|
177
247
|
const confirmSubscription = useCallback(async () => {
|
|
178
248
|
if (!state.selectedPrice) return;
|
|
@@ -181,27 +251,71 @@ export function useSubscriptionWizard({ subscription, onSuccess, onClose }: UseS
|
|
|
181
251
|
dispatch({ type: "SET_ERROR", error: null });
|
|
182
252
|
|
|
183
253
|
try {
|
|
254
|
+
let result: StripeSubscriptionCreateResponse;
|
|
184
255
|
if (subscription) {
|
|
185
256
|
// Change existing subscription
|
|
186
257
|
const changePlanParams = {
|
|
187
258
|
id: subscription.id,
|
|
188
259
|
newPriceId: state.selectedPrice.id,
|
|
189
|
-
promotionCode: state.promotionCode
|
|
260
|
+
promotionCode: promotionCodeToSend(state.promotionCode),
|
|
190
261
|
};
|
|
191
|
-
await StripeSubscriptionService.
|
|
262
|
+
result = await StripeSubscriptionService.changePlanWithMeta(changePlanParams);
|
|
192
263
|
} else {
|
|
193
264
|
// Create new subscription
|
|
194
265
|
const createParams = {
|
|
195
266
|
id: v4(),
|
|
196
267
|
priceId: state.selectedPrice.id,
|
|
197
|
-
promotionCode: state.promotionCode
|
|
268
|
+
promotionCode: promotionCodeToSend(state.promotionCode),
|
|
198
269
|
};
|
|
199
|
-
await StripeSubscriptionService.createSubscription(createParams);
|
|
270
|
+
result = await StripeSubscriptionService.createSubscription(createParams);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// The first payment waits on the browser (confirmation, 3D Secure): until it
|
|
274
|
+
// is confirmed nothing is paid, so the wizard stays open on a failure
|
|
275
|
+
const clientSecret = clientSecretToConfirm(result.meta);
|
|
276
|
+
if (clientSecret) {
|
|
277
|
+
const confirmation = await confirmPayment(clientSecret);
|
|
278
|
+
if (!confirmation.success) {
|
|
279
|
+
console.error("[useSubscriptionWizard] Payment not confirmed:", confirmation.error);
|
|
280
|
+
dispatch({
|
|
281
|
+
type: "SET_ERROR",
|
|
282
|
+
error: t("billing.ui.wizard.errors.payment_not_confirmed", { reason: confirmation.error ?? "" }),
|
|
283
|
+
});
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Our copy is still incomplete until the webhooks land: store what Stripe has now,
|
|
288
|
+
// so the billing page shows the plan at once. A failed sync never blocks success.
|
|
289
|
+
const subscriptionId = subscriptionIdToSync(result.subscription, subscription);
|
|
290
|
+
if (subscriptionId) {
|
|
291
|
+
try {
|
|
292
|
+
await StripeSubscriptionService.syncSubscription({ subscriptionId });
|
|
293
|
+
} catch (syncError) {
|
|
294
|
+
console.error("[useSubscriptionWizard] Subscription sync after payment failed:", syncError);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
200
297
|
}
|
|
201
298
|
|
|
202
299
|
onSuccessRef.current();
|
|
203
300
|
onCloseRef.current();
|
|
204
301
|
} catch (error: any) {
|
|
302
|
+
// 410 discount_sold_out (the automatic discount's last place has just gone) or
|
|
303
|
+
// 422 discount_unavailable (the automatic code shown is no longer valid): nothing
|
|
304
|
+
// was charged. Never retry: drop the code and re-read the automatic one, so the
|
|
305
|
+
// review shows the price that would really be charged now.
|
|
306
|
+
const soldOut = isDiscountSoldOut(error);
|
|
307
|
+
if (soldOut || isDiscountUnavailable(error)) {
|
|
308
|
+
dispatch({
|
|
309
|
+
type: "SET_ERROR",
|
|
310
|
+
error: soldOut
|
|
311
|
+
? t("billing.ui.wizard.errors.discount_sold_out")
|
|
312
|
+
: t("billing.ui.wizard.errors.discount_unavailable"),
|
|
313
|
+
});
|
|
314
|
+
dispatch({ type: "SET_PROMOTION_CODE", code: null });
|
|
315
|
+
await refreshAutomaticCodeOrLeaveReview(state.selectedPrice.id);
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
205
319
|
// Handle 409 Conflict - duplicate recurring subscription
|
|
206
320
|
if (error?.status === 409 || error?.response?.status === 409) {
|
|
207
321
|
dispatch({
|
|
@@ -222,13 +336,14 @@ export function useSubscriptionWizard({ subscription, onSuccess, onClose }: UseS
|
|
|
222
336
|
} finally {
|
|
223
337
|
dispatch({ type: "SET_PROCESSING", isProcessing: false });
|
|
224
338
|
}
|
|
225
|
-
}, [state.selectedPrice, state.promotionCode, subscription, t]);
|
|
339
|
+
}, [state.selectedPrice, state.promotionCode, subscription, refreshAutomaticCodeOrLeaveReview, confirmPayment, t]);
|
|
226
340
|
|
|
227
341
|
const handlePaymentMethodSuccess = useCallback(async () => {
|
|
228
342
|
dispatch({ type: "SET_HAS_PAYMENT_METHOD", hasMethod: true });
|
|
229
|
-
//
|
|
230
|
-
|
|
231
|
-
|
|
343
|
+
// Prepare the review exactly as "Next: Review" does: the trial path sent the GM
|
|
344
|
+
// here before the automatic-code lookup and the proration preview had run
|
|
345
|
+
await goToReview();
|
|
346
|
+
}, [goToReview]);
|
|
232
347
|
|
|
233
348
|
const reset = useCallback(() => {
|
|
234
349
|
dispatch({ type: "RESET" });
|
|
@@ -267,11 +382,22 @@ export function useSubscriptionWizard({ subscription, onSuccess, onClose }: UseS
|
|
|
267
382
|
[state.selectedPrice?.id, t, locale],
|
|
268
383
|
);
|
|
269
384
|
|
|
270
|
-
const clearPromoCode = useCallback(() => {
|
|
385
|
+
const clearPromoCode = useCallback(async () => {
|
|
271
386
|
dispatch({ type: "SET_PROMOTION_CODE", code: null });
|
|
272
387
|
dispatch({ type: "SET_PROMO_CODE_ERROR", error: null });
|
|
273
388
|
dispatch({ type: "SET_ERROR", error: null });
|
|
274
|
-
|
|
389
|
+
|
|
390
|
+
if (!state.selectedPrice) return;
|
|
391
|
+
|
|
392
|
+
// Removing a typed code: the server attaches the automatic code again, so the
|
|
393
|
+
// review must show it again
|
|
394
|
+
dispatch({ type: "SET_PROCESSING", isProcessing: true });
|
|
395
|
+
try {
|
|
396
|
+
await refreshAutomaticCodeOrLeaveReview(state.selectedPrice.id);
|
|
397
|
+
} finally {
|
|
398
|
+
dispatch({ type: "SET_PROCESSING", isProcessing: false });
|
|
399
|
+
}
|
|
400
|
+
}, [state.selectedPrice, refreshAutomaticCodeOrLeaveReview]);
|
|
275
401
|
|
|
276
402
|
const actions = useMemo(
|
|
277
403
|
() => ({
|