@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.
Files changed (152) hide show
  1. package/dist/{BlockNoteEditor-THBJH2UR.js → BlockNoteEditor-AE3ZTEEH.js} +19 -19
  2. package/dist/{BlockNoteEditor-THBJH2UR.js.map → BlockNoteEditor-AE3ZTEEH.js.map} +1 -1
  3. package/dist/{BlockNoteEditor-GVQCKRMU.mjs → BlockNoteEditor-NEYG5L6W.mjs} +4 -4
  4. package/dist/{BlockNoteViewer-U6E3DS4F.mjs → BlockNoteViewer-3OUT5UFY.mjs} +2 -2
  5. package/dist/{BlockNoteViewer-VZOVKTFO.js → BlockNoteViewer-4CFKQM7U.js} +3 -3
  6. package/dist/{BlockNoteViewer-VZOVKTFO.js.map → BlockNoteViewer-4CFKQM7U.js.map} +1 -1
  7. package/dist/billing/index.d.mts +186 -25
  8. package/dist/billing/index.d.ts +186 -25
  9. package/dist/billing/index.js +1148 -587
  10. package/dist/billing/index.js.map +1 -1
  11. package/dist/billing/index.mjs +785 -224
  12. package/dist/billing/index.mjs.map +1 -1
  13. package/dist/{chunk-WPYT4UKJ.js → chunk-3I5AAJXV.js} +3 -3
  14. package/dist/{chunk-WPYT4UKJ.js.map → chunk-3I5AAJXV.js.map} +1 -1
  15. package/dist/{chunk-YDYVJM4I.js → chunk-53BAULDP.js} +4 -4
  16. package/dist/{chunk-YDYVJM4I.js.map → chunk-53BAULDP.js.map} +1 -1
  17. package/dist/{chunk-3CGROPF3.mjs → chunk-BTPUYUQ3.mjs} +2 -2
  18. package/dist/{chunk-VC5A3AUZ.mjs → chunk-EYA2F6AN.mjs} +696 -4
  19. package/dist/chunk-EYA2F6AN.mjs.map +1 -0
  20. package/dist/{chunk-G6UE6MEP.js → chunk-HKZAESS6.js} +896 -204
  21. package/dist/chunk-HKZAESS6.js.map +1 -0
  22. package/dist/{chunk-W7F5BDSV.mjs → chunk-KQHBONL4.mjs} +2 -2
  23. package/dist/{chunk-TY5FYP3G.js → chunk-LQ4DK4JF.js} +1919 -1056
  24. package/dist/chunk-LQ4DK4JF.js.map +1 -0
  25. package/dist/{chunk-S4YJ5WCW.js → chunk-RL3PL3KV.js} +8 -8
  26. package/dist/{chunk-S4YJ5WCW.js.map → chunk-RL3PL3KV.js.map} +1 -1
  27. package/dist/{chunk-X5WIUUNG.mjs → chunk-SFYPXCK4.mjs} +3237 -2374
  28. package/dist/chunk-SFYPXCK4.mjs.map +1 -0
  29. package/dist/{chunk-6SQYXUVP.mjs → chunk-Y7RR57EW.mjs} +2 -2
  30. package/dist/client/index.js +4 -4
  31. package/dist/client/index.mjs +3 -3
  32. package/dist/components/index.js +4 -4
  33. package/dist/components/index.mjs +3 -3
  34. package/dist/contexts/index.js +4 -4
  35. package/dist/contexts/index.mjs +3 -3
  36. package/dist/core/index.d.mts +242 -3
  37. package/dist/core/index.d.ts +242 -3
  38. package/dist/core/index.js +34 -2
  39. package/dist/core/index.js.map +1 -1
  40. package/dist/core/index.mjs +33 -1
  41. package/dist/email-automations/index.js +92 -92
  42. package/dist/email-automations/index.mjs +3 -3
  43. package/dist/features/analytics/index.js +121 -121
  44. package/dist/features/analytics/index.mjs +4 -4
  45. package/dist/features/analytics/server.js +4 -4
  46. package/dist/features/analytics/server.mjs +2 -2
  47. package/dist/features/blog/index.js +150 -150
  48. package/dist/features/blog/index.mjs +3 -3
  49. package/dist/features/blog/server.js +16 -16
  50. package/dist/features/blog/server.mjs +1 -1
  51. package/dist/features/help/index.js +37 -37
  52. package/dist/features/help/index.mjs +3 -3
  53. package/dist/features/tokenusage/index.js +124 -124
  54. package/dist/features/tokenusage/index.mjs +4 -4
  55. package/dist/{stripe-promotion-code.interface-C3qqh3mi.d.ts → format-options-BtX3o0Yu.d.ts} +104 -1
  56. package/dist/{stripe-promotion-code.interface-ClZ7DxS9.d.mts → format-options-D6c7FNDi.d.mts} +104 -1
  57. package/dist/index.d.mts +2 -2
  58. package/dist/index.d.ts +2 -2
  59. package/dist/index.js +35 -3
  60. package/dist/index.js.map +1 -1
  61. package/dist/index.mjs +34 -2
  62. package/dist/server/index.js +3 -3
  63. package/dist/server/index.mjs +2 -2
  64. package/messages/en.json +125 -3
  65. package/messages/it.json +125 -3
  66. package/package.json +1 -1
  67. package/scripts/validate-messages.mjs +1 -0
  68. package/src/billing/index.ts +1 -0
  69. package/src/client/index.ts +4 -0
  70. package/src/core/index.ts +1 -0
  71. package/src/core/registry/ModuleRegistry.ts +3 -0
  72. package/src/features/billing/components/cards/InvoicesSummaryCard.tsx +3 -2
  73. package/src/features/billing/components/cards/SubscriptionSummaryCard.tsx +4 -3
  74. package/src/features/billing/components/containers/BillingDashboardContainer.tsx +22 -5
  75. package/src/features/billing/components/utils/__tests__/discount.spec.ts +59 -0
  76. package/src/features/billing/components/utils/__tests__/url.spec.ts +32 -0
  77. package/src/features/billing/components/utils/discount.ts +38 -0
  78. package/src/features/billing/components/utils/index.ts +2 -0
  79. package/src/features/billing/components/utils/url.ts +12 -0
  80. package/src/features/billing/contexts/CouponContext.tsx +211 -0
  81. package/src/features/billing/contexts/index.ts +1 -0
  82. package/src/features/billing/i18n-keys.ts +79 -0
  83. package/src/features/billing/stripe-coupon/components/containers/CouponContainer.tsx +38 -0
  84. package/src/features/billing/stripe-coupon/components/containers/index.ts +1 -0
  85. package/src/features/billing/stripe-coupon/components/details/CouponDetails.tsx +69 -0
  86. package/src/features/billing/stripe-coupon/components/details/index.ts +1 -0
  87. package/src/features/billing/stripe-coupon/components/forms/CouponCodeEditor.tsx +79 -0
  88. package/src/features/billing/stripe-coupon/components/forms/CouponCodeFields.tsx +192 -0
  89. package/src/features/billing/stripe-coupon/components/forms/CouponEditor.tsx +333 -0
  90. package/src/features/billing/stripe-coupon/components/forms/CouponRefresher.tsx +45 -0
  91. package/src/features/billing/stripe-coupon/components/forms/index.ts +6 -0
  92. package/src/features/billing/stripe-coupon/components/index.ts +5 -0
  93. package/src/features/billing/stripe-coupon/components/lists/CouponCodesList.tsx +61 -0
  94. package/src/features/billing/stripe-coupon/components/lists/CouponsList.tsx +67 -0
  95. package/src/features/billing/stripe-coupon/components/lists/index.ts +2 -0
  96. package/src/features/billing/stripe-coupon/components/widgets/CouponCodeActions.tsx +80 -0
  97. package/src/features/billing/stripe-coupon/components/widgets/index.ts +1 -0
  98. package/src/features/billing/stripe-coupon/data/__tests__/stripe-coupon-models.spec.ts +114 -0
  99. package/src/features/billing/stripe-coupon/data/index.ts +10 -0
  100. package/src/features/billing/stripe-coupon/data/stripe-coupon-code.fields.ts +8 -0
  101. package/src/features/billing/stripe-coupon/data/stripe-coupon-code.interface.ts +44 -0
  102. package/src/features/billing/stripe-coupon/data/stripe-coupon-code.service.ts +92 -0
  103. package/src/features/billing/stripe-coupon/data/stripe-coupon-code.ts +132 -0
  104. package/src/features/billing/stripe-coupon/data/stripe-coupon-offer.interface.ts +17 -0
  105. package/src/features/billing/stripe-coupon/data/stripe-coupon-offer.ts +70 -0
  106. package/src/features/billing/stripe-coupon/data/stripe-coupon.fields.ts +7 -0
  107. package/src/features/billing/stripe-coupon/data/stripe-coupon.interface.ts +51 -0
  108. package/src/features/billing/stripe-coupon/data/stripe-coupon.service.ts +93 -0
  109. package/src/features/billing/stripe-coupon/data/stripe-coupon.ts +165 -0
  110. package/src/features/billing/stripe-coupon/hooks/useStripeCouponCodeTableStructure.tsx +154 -0
  111. package/src/features/billing/stripe-coupon/hooks/useStripeCouponTableStructure.tsx +113 -0
  112. package/src/features/billing/stripe-coupon/index.ts +5 -0
  113. package/src/features/billing/stripe-coupon/stripe-coupon-code.module.ts +14 -0
  114. package/src/features/billing/stripe-coupon/stripe-coupon-offer.module.ts +10 -0
  115. package/src/features/billing/stripe-coupon/stripe-coupon.module.ts +18 -0
  116. package/src/features/billing/stripe-coupon/utils/__tests__/coupon-places.spec.ts +19 -0
  117. package/src/features/billing/stripe-coupon/utils/__tests__/to-promotion-code-result.spec.ts +41 -0
  118. package/src/features/billing/stripe-coupon/utils/coupon-places.ts +23 -0
  119. package/src/features/billing/stripe-coupon/utils/format-coupon-discount.ts +36 -0
  120. package/src/features/billing/stripe-coupon/utils/index.ts +3 -0
  121. package/src/features/billing/stripe-coupon/utils/to-promotion-code-result.ts +23 -0
  122. package/src/features/billing/stripe-customer/components/forms/PaymentMethodForm.tsx +24 -8
  123. package/src/features/billing/stripe-product/components/containers/ProductsListContainer.tsx +21 -6
  124. package/src/features/billing/stripe-promotion-code/components/PromoCodeInput.tsx +60 -19
  125. package/src/features/billing/stripe-promotion-code/data/stripe-promotion-code.interface.ts +2 -0
  126. package/src/features/billing/stripe-subscription/components/containers/SubscriptionsContainer.tsx +24 -5
  127. package/src/features/billing/stripe-subscription/components/lists/SubscriptionsList.tsx +21 -2
  128. package/src/features/billing/stripe-subscription/components/wizards/SubscriptionWizard.tsx +1 -1
  129. package/src/features/billing/stripe-subscription/components/wizards/WizardStepPaymentMethod.tsx +1 -1
  130. package/src/features/billing/stripe-subscription/components/wizards/WizardStepReview.tsx +60 -49
  131. package/src/features/billing/stripe-subscription/components/wizards/__tests__/review-card.spec.ts +34 -0
  132. package/src/features/billing/stripe-subscription/components/wizards/review-card.ts +24 -0
  133. package/src/features/billing/stripe-subscription/data/__tests__/stripe-subscription.discount.spec.ts +67 -0
  134. package/src/features/billing/stripe-subscription/data/stripe-subscription.interface.ts +14 -0
  135. package/src/features/billing/stripe-subscription/data/stripe-subscription.service.ts +39 -0
  136. package/src/features/billing/stripe-subscription/data/stripe-subscription.ts +21 -0
  137. package/src/features/billing/stripe-subscription/hooks/__tests__/wizard-payment.spec.ts +48 -0
  138. package/src/features/billing/stripe-subscription/hooks/__tests__/wizard-promotion.spec.ts +132 -0
  139. package/src/features/billing/stripe-subscription/hooks/useSubscriptionWizard.ts +138 -12
  140. package/src/features/billing/stripe-subscription/hooks/wizard-payment.ts +30 -0
  141. package/src/features/billing/stripe-subscription/hooks/wizard-promotion.ts +54 -0
  142. package/src/features/user/contexts/CurrentUserContext.tsx +25 -0
  143. package/src/features/user/contexts/__tests__/CurrentUserContext.spec.tsx +121 -2
  144. package/dist/chunk-G6UE6MEP.js.map +0 -1
  145. package/dist/chunk-TY5FYP3G.js.map +0 -1
  146. package/dist/chunk-VC5A3AUZ.mjs.map +0 -1
  147. package/dist/chunk-X5WIUUNG.mjs.map +0 -1
  148. /package/dist/{BlockNoteEditor-GVQCKRMU.mjs.map → BlockNoteEditor-NEYG5L6W.mjs.map} +0 -0
  149. /package/dist/{BlockNoteViewer-U6E3DS4F.mjs.map → BlockNoteViewer-3OUT5UFY.mjs.map} +0 -0
  150. /package/dist/{chunk-3CGROPF3.mjs.map → chunk-BTPUYUQ3.mjs.map} +0 -0
  151. /package/dist/{chunk-W7F5BDSV.mjs.map → chunk-KQHBONL4.mjs.map} +0 -0
  152. /package/dist/{chunk-6SQYXUVP.mjs.map → chunk-Y7RR57EW.mjs.map} +0 -0
@@ -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
+ }
@@ -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 { StripeSubscriptionInterface, StripeSubscriptionService, SubscriptionStatus } from "../data";
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
- }, [state.selectedPrice, state.hasPaymentMethod, subscription, checkPaymentMethod, t]);
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?.promotionCodeId,
260
+ promotionCode: promotionCodeToSend(state.promotionCode),
190
261
  };
191
- await StripeSubscriptionService.changePlan(changePlanParams);
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?.promotionCodeId,
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
- // Go back to review to confirm subscription
230
- dispatch({ type: "SET_STEP", step: "review" });
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
  () => ({