@carlonicora/nextjs-jsonapi 5.5.0 → 5.7.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-GVQCKRMU.mjs → BlockNoteEditor-R3WXUFHX.mjs} +4 -4
  2. package/dist/{BlockNoteEditor-THBJH2UR.js → BlockNoteEditor-ZHXXFWUJ.js} +19 -19
  3. package/dist/{BlockNoteEditor-THBJH2UR.js.map → BlockNoteEditor-ZHXXFWUJ.js.map} +1 -1
  4. package/dist/{BlockNoteViewer-U6E3DS4F.mjs → BlockNoteViewer-BVVN6D3T.mjs} +2 -2
  5. package/dist/{BlockNoteViewer-VZOVKTFO.js → BlockNoteViewer-SD3ZFIXD.js} +3 -3
  6. package/dist/{BlockNoteViewer-VZOVKTFO.js.map → BlockNoteViewer-SD3ZFIXD.js.map} +1 -1
  7. package/dist/billing/index.d.mts +197 -46
  8. package/dist/billing/index.d.ts +197 -46
  9. package/dist/billing/index.js +1166 -589
  10. package/dist/billing/index.js.map +1 -1
  11. package/dist/billing/index.mjs +803 -226
  12. package/dist/billing/index.mjs.map +1 -1
  13. package/dist/{chunk-WPYT4UKJ.js → chunk-2TXE57LE.js} +3 -3
  14. package/dist/{chunk-WPYT4UKJ.js.map → chunk-2TXE57LE.js.map} +1 -1
  15. package/dist/{chunk-G6UE6MEP.js → chunk-5TH2DSAH.js} +901 -204
  16. package/dist/chunk-5TH2DSAH.js.map +1 -0
  17. package/dist/{chunk-X5WIUUNG.mjs → chunk-CFB6FHIY.mjs} +3342 -2377
  18. package/dist/chunk-CFB6FHIY.mjs.map +1 -0
  19. package/dist/{chunk-VC5A3AUZ.mjs → chunk-FK26NX3S.mjs} +701 -4
  20. package/dist/chunk-FK26NX3S.mjs.map +1 -0
  21. package/dist/{chunk-YDYVJM4I.js → chunk-IJME5SAM.js} +4 -4
  22. package/dist/{chunk-YDYVJM4I.js.map → chunk-IJME5SAM.js.map} +1 -1
  23. package/dist/{chunk-W7F5BDSV.mjs → chunk-OKCV4GPJ.mjs} +2 -2
  24. package/dist/{chunk-3CGROPF3.mjs → chunk-RGQYFSJF.mjs} +2 -2
  25. package/dist/{chunk-6SQYXUVP.mjs → chunk-WCHLCOZG.mjs} +2 -2
  26. package/dist/{chunk-S4YJ5WCW.js → chunk-XOSC3FFP.js} +8 -8
  27. package/dist/{chunk-S4YJ5WCW.js.map → chunk-XOSC3FFP.js.map} +1 -1
  28. package/dist/{chunk-TY5FYP3G.js → chunk-ZCOPOO7T.js} +2024 -1059
  29. package/dist/chunk-ZCOPOO7T.js.map +1 -0
  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 +248 -3
  37. package/dist/core/index.d.ts +248 -3
  38. package/dist/core/index.js +42 -2
  39. package/dist/core/index.js.map +1 -1
  40. package/dist/core/index.mjs +41 -1
  41. package/dist/{stripe-promotion-code.interface-ClZ7DxS9.d.mts → currency-DT_dveBx.d.mts} +137 -1
  42. package/dist/{stripe-promotion-code.interface-C3qqh3mi.d.ts → currency-pK9eRYRC.d.ts} +137 -1
  43. package/dist/email-automations/index.js +92 -92
  44. package/dist/email-automations/index.mjs +3 -3
  45. package/dist/features/analytics/index.js +121 -121
  46. package/dist/features/analytics/index.mjs +4 -4
  47. package/dist/features/analytics/server.js +4 -4
  48. package/dist/features/analytics/server.mjs +2 -2
  49. package/dist/features/blog/index.js +150 -150
  50. package/dist/features/blog/index.mjs +3 -3
  51. package/dist/features/blog/server.js +16 -16
  52. package/dist/features/blog/server.mjs +1 -1
  53. package/dist/features/help/index.js +37 -37
  54. package/dist/features/help/index.mjs +3 -3
  55. package/dist/features/tokenusage/index.js +124 -124
  56. package/dist/features/tokenusage/index.mjs +4 -4
  57. package/dist/index.d.mts +2 -2
  58. package/dist/index.d.ts +2 -2
  59. package/dist/index.js +43 -3
  60. package/dist/index.js.map +1 -1
  61. package/dist/index.mjs +42 -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 +4 -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 +209 -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 +72 -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 +80 -0
  88. package/src/features/billing/stripe-coupon/components/forms/CouponCodeFields.tsx +214 -0
  89. package/src/features/billing/stripe-coupon/components/forms/CouponEditor.tsx +425 -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 +81 -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 +55 -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 +167 -0
  110. package/src/features/billing/stripe-coupon/hooks/useStripeCouponCodeTableStructure.tsx +161 -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 +15 -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 +25 -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 +61 -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-R3WXUFHX.mjs.map} +0 -0
  149. /package/dist/{BlockNoteViewer-U6E3DS4F.mjs.map → BlockNoteViewer-BVVN6D3T.mjs.map} +0 -0
  150. /package/dist/{chunk-W7F5BDSV.mjs.map → chunk-OKCV4GPJ.mjs.map} +0 -0
  151. /package/dist/{chunk-3CGROPF3.mjs.map → chunk-RGQYFSJF.mjs.map} +0 -0
  152. /package/dist/{chunk-6SQYXUVP.mjs.map → chunk-WCHLCOZG.mjs.map} +0 -0
@@ -2,14 +2,23 @@
2
2
 
3
3
  import { useTranslations } from "next-intl";
4
4
  import { AlertCircle } from "lucide-react";
5
+ import { useEffect, useState } from "react";
5
6
  import { Alert, AlertDescription, Badge, Button } from "../../../../../shadcnui";
6
7
  import { SectionHeader } from "../../../../../components/typography";
7
- import { formatCurrency, useBillingFormatOptions } from "../../../components/utils";
8
+ import {
9
+ applyDiscount,
10
+ formatCurrency,
11
+ promotionCodeDiscount,
12
+ subscriptionDiscount,
13
+ useBillingFormatOptions,
14
+ } from "../../../components/utils";
8
15
  import { StripePriceInterface } from "../../../stripe-price/data/stripe-price.interface";
9
16
  import { ProrationPreviewInterface } from "../../../stripe-invoice/data/stripe-invoice.interface";
10
17
  import { PromotionCodeValidationResult } from "../../../stripe-promotion-code";
11
18
  import { PromoCodeInput } from "../../../stripe-promotion-code/components/PromoCodeInput";
12
19
  import { StripeSubscriptionInterface } from "../../data";
20
+ import { StripeCustomerService } from "../../../stripe-customer/data/stripe-customer.service";
21
+ import { ReviewCard, reviewCard } from "./review-card";
13
22
 
14
23
  type WizardStepReviewProps = {
15
24
  selectedPrice: StripePriceInterface | null;
@@ -50,6 +59,29 @@ export function WizardStepReview({
50
59
  }: WizardStepReviewProps) {
51
60
  const t = useTranslations();
52
61
  const fmt = useBillingFormatOptions();
62
+ const [card, setCard] = useState<ReviewCard | null>(null);
63
+
64
+ // Show which card is on file. The step remounts after the payment step, so a
65
+ // card just added is read again here.
66
+ useEffect(() => {
67
+ if (!hasPaymentMethod) {
68
+ setCard(null);
69
+ return;
70
+ }
71
+ let cancelled = false;
72
+ Promise.all([StripeCustomerService.listPaymentMethods(), StripeCustomerService.getCustomer()])
73
+ .then(([methods, customer]) => {
74
+ if (!cancelled) setCard(reviewCard(methods, customer?.defaultPaymentMethodId));
75
+ })
76
+ .catch((err) => {
77
+ console.error("[WizardStepReview] Failed to load the card on file:", err);
78
+ if (!cancelled) setCard(null);
79
+ });
80
+ return () => {
81
+ cancelled = true;
82
+ };
83
+ }, [hasPaymentMethod]);
84
+
53
85
  if (!selectedPrice) {
54
86
  return <div className="text-center py-8 text-muted-foreground">{t("billing.ui.wizard.review.no_plan")}</div>;
55
87
  }
@@ -64,60 +96,28 @@ export function WizardStepReview({
64
96
  : t("billing.ui.wizard.review.interval_monthly");
65
97
  };
66
98
 
67
- // Calculate discounted price if promotion code is applied
68
- const calculateDiscountedPrice = (): number | null => {
69
- if (!promotionCode?.valid || !selectedPrice.unitAmount) return null;
70
-
71
- const originalPrice = selectedPrice.unitAmount;
72
-
73
- if (promotionCode.discountType === "percent_off" && promotionCode.discountValue) {
74
- return originalPrice * (1 - promotionCode.discountValue / 100);
75
- }
76
-
77
- if (promotionCode.discountType === "amount_off" && promotionCode.discountValue) {
78
- // amount_off is in cents, same as unitAmount
79
- return Math.max(0, originalPrice - promotionCode.discountValue);
80
- }
99
+ // A code (typed or automatic) replaces the subscription's discount in Stripe.
100
+ // Without one, a plan change keeps the discount the subscription already carries.
101
+ const codeDiscount = promotionCodeDiscount(promotionCode);
102
+ const carriedDiscount = isChangingPlan && !codeDiscount ? subscriptionDiscount(subscription) : null;
103
+ const discount = codeDiscount ?? carriedDiscount;
81
104
 
82
- return null;
83
- };
84
-
85
- const discountedPrice = calculateDiscountedPrice();
105
+ const discountedPrice = applyDiscount(selectedPrice.unitAmount, discount);
86
106
 
87
107
  // Calculate discounted immediate charge for proration preview
88
- const calculateDiscountedImmediateCharge = (): number | null => {
89
- if (!promotionCode?.valid || !prorationPreview?.immediateCharge) return null;
90
-
91
- const originalCharge = prorationPreview.immediateCharge;
92
-
93
- if (promotionCode.discountType === "percent_off" && promotionCode.discountValue) {
94
- return originalCharge * (1 - promotionCode.discountValue / 100);
95
- }
96
-
97
- if (promotionCode.discountType === "amount_off" && promotionCode.discountValue) {
98
- return Math.max(0, originalCharge - promotionCode.discountValue);
99
- }
100
-
101
- return null;
102
- };
103
-
104
- const discountedImmediateCharge = calculateDiscountedImmediateCharge();
108
+ const discountedImmediateCharge = applyDiscount(prorationPreview?.immediateCharge, discount);
105
109
 
106
110
  // Format the discount description
107
111
  const getDiscountDescription = (): string | null => {
108
- if (!promotionCode?.valid) return null;
112
+ if (!discount) return null;
109
113
 
110
- if (promotionCode.discountType === "percent_off" && promotionCode.discountValue) {
111
- return t("billing.ui.wizard.review.percent_off", { value: promotionCode.discountValue });
114
+ if (discount.discountType === "percent_off") {
115
+ return t("billing.ui.wizard.review.percent_off", { value: discount.discountValue });
112
116
  }
113
117
 
114
- if (promotionCode.discountType === "amount_off" && promotionCode.discountValue) {
115
- return t("billing.ui.wizard.review.amount_off", {
116
- amount: formatCurrency(promotionCode.discountValue, promotionCode.currency || selectedPrice.currency, fmt),
117
- });
118
- }
119
-
120
- return null;
118
+ return t("billing.ui.wizard.review.amount_off", {
119
+ amount: formatCurrency(discount.discountValue, discount.currency || selectedPrice.currency, fmt),
120
+ });
121
121
  };
122
122
 
123
123
  const discountDescription = getDiscountDescription();
@@ -142,6 +142,9 @@ export function WizardStepReview({
142
142
  {formatCurrency(discountedPrice, selectedPrice.currency, fmt)}
143
143
  </p>
144
144
  {discountDescription && <Badge variant="softGreen">{discountDescription}</Badge>}
145
+ {carriedDiscount && (
146
+ <p className="text-sm text-muted-foreground">{t("billing.ui.wizard.review.discount_carries_over")}</p>
147
+ )}
145
148
  </>
146
149
  ) : (
147
150
  <p className="font-semibold text-lg">
@@ -165,7 +168,9 @@ export function WizardStepReview({
165
168
  </h4>
166
169
  <p className={`text-sm ${isTrialUpgrade ? "text-warning" : "text-foreground"}`}>
167
170
  {isTrialUpgrade
168
- ? t("billing.ui.wizard.review.trial_upgrade_description")
171
+ ? discountedImmediateCharge !== null
172
+ ? t("billing.ui.wizard.review.trial_upgrade_description_discounted")
173
+ : t("billing.ui.wizard.review.trial_upgrade_description")
169
174
  : t("billing.ui.wizard.review.proration_description")}
170
175
  </p>
171
176
  <div className="flex justify-between text-sm">
@@ -210,11 +215,17 @@ export function WizardStepReview({
210
215
  <h4 className="font-medium">{t("billing.ui.payment_method.title")}</h4>
211
216
  <p className="text-sm text-muted-foreground">
212
217
  {hasPaymentMethod
213
- ? t("billing.ui.wizard.review.payment_on_file")
218
+ ? card
219
+ ? t("billing.ui.wizard.review.card_on_file", { brand: card.brand, last4: card.last4 })
220
+ : t("billing.ui.wizard.review.payment_on_file")
214
221
  : t("billing.ui.wizard.review.no_payment_on_file")}
215
222
  </p>
216
223
  </div>
217
- {!hasPaymentMethod && (
224
+ {hasPaymentMethod ? (
225
+ <Button variant="outline" onClick={onAddPaymentMethod} disabled={isProcessing}>
226
+ {t("billing.ui.wizard.review.use_another_card")}
227
+ </Button>
228
+ ) : (
218
229
  <Button variant="outline" onClick={onAddPaymentMethod}>
219
230
  {t("billing.ui.payment_methods.add")}
220
231
  </Button>
@@ -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
+ });