@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,67 @@
1
+ "use client";
2
+
3
+ import { TicketPercentIcon } from "lucide-react";
4
+ import { useTranslations } from "next-intl";
5
+ import { ReactNode, useEffect } from "react";
6
+ import { ContentListTable } from "../../../../../components";
7
+ import { Modules } from "../../../../../core";
8
+ import { DataListRetriever, useDataListRetriever } from "../../../../../hooks";
9
+ import { EmptyState } from "../../../../../shadcnui";
10
+ import { useCouponContext } from "../../../contexts/CouponContext";
11
+ import { StripeCouponFields } from "../../data/stripe-coupon.fields";
12
+ import { StripeCouponInterface } from "../../data/stripe-coupon.interface";
13
+ import { StripeCouponService } from "../../data/stripe-coupon.service";
14
+ import CouponEditor from "../forms/CouponEditor";
15
+
16
+ type CouponsListProps = {
17
+ /**
18
+ * Pass this whenever the list is the page's own content inside a
19
+ * `RoundPageContainer fullWidth`. Without it ContentListTable wraps itself in
20
+ * `rounded-md border`, drawing a second bordered card inside the page's
21
+ * rounded shell.
22
+ */
23
+ fullWidth?: boolean;
24
+ };
25
+
26
+ export function CouponsList({ fullWidth }: CouponsListProps = {}) {
27
+ const t = useTranslations();
28
+ const { catalogueVersion } = useCouponContext();
29
+
30
+ const data: DataListRetriever<StripeCouponInterface> = useDataListRetriever({
31
+ retriever: (params) => StripeCouponService.listCoupons(params),
32
+ retrieverParams: {},
33
+ module: Modules.StripeCoupon,
34
+ });
35
+
36
+ // Renames and code changes happen on the detail page, so the list has to
37
+ // learn about them through the provider's counter rather than a direct callback.
38
+ useEffect(() => {
39
+ if (catalogueVersion > 0) void data.refresh();
40
+ }, [catalogueVersion]);
41
+
42
+ const functions: ReactNode[] = [<CouponEditor key="create-coupon" />];
43
+
44
+ return (
45
+ <ContentListTable
46
+ data={data}
47
+ fields={[
48
+ StripeCouponFields.name,
49
+ StripeCouponFields.discount,
50
+ StripeCouponFields.places,
51
+ StripeCouponFields.codes,
52
+ StripeCouponFields.status,
53
+ ]}
54
+ tableGeneratorType={Modules.StripeCoupon}
55
+ functions={functions}
56
+ fullWidth={fullWidth}
57
+ title={t("billing.admin.coupons.title")}
58
+ emptyState={
59
+ <EmptyState
60
+ icon={TicketPercentIcon}
61
+ title={t("billing.admin.coupons.empty.title")}
62
+ description={t("billing.admin.coupons.empty.description")}
63
+ />
64
+ }
65
+ />
66
+ );
67
+ }
@@ -0,0 +1,2 @@
1
+ export * from "./CouponsList";
2
+ export * from "./CouponCodesList";
@@ -0,0 +1,80 @@
1
+ "use client";
2
+
3
+ import { PowerIcon, RotateCcwIcon } from "lucide-react";
4
+ import { useTranslations } from "next-intl";
5
+ import { useState } from "react";
6
+ import { errorToast } from "../../../../../components/errors";
7
+ import { Button, ConfirmDialog, Switch } from "../../../../../shadcnui";
8
+ import { useCouponContext } from "../../../contexts/CouponContext";
9
+ import { StripeCouponCodeInterface } from "../../data/stripe-coupon-code.interface";
10
+ import type { CouponCodeStatus } from "../../hooks/useStripeCouponCodeTableStructure";
11
+
12
+ type CouponCodeActionsProps = {
13
+ code: StripeCouponCodeInterface;
14
+ status: CouponCodeStatus;
15
+ };
16
+
17
+ /**
18
+ * Row controls of a code: the "Apply automatically" switch and the deactivate /
19
+ * reactivate button. The button sits behind ConfirmDialog, which is controlled
20
+ * and trigger-less by design — the caller owns the trigger button, so none of
21
+ * Base UI's trigger-composition pitfalls apply (no asChild, no nested <button>).
22
+ */
23
+ export function CouponCodeActions({ code, status }: CouponCodeActionsProps) {
24
+ const t = useTranslations();
25
+ const { updateCodeFlags } = useCouponContext();
26
+ const [open, setOpen] = useState<boolean>(false);
27
+ const [pending, setPending] = useState<boolean>(false);
28
+
29
+ // An expired or used-up code stays dead: Stripe never revives it.
30
+ const isDead = status === "expired" || status === "used_up";
31
+ const isInactive = !code.active;
32
+ const scope = isInactive ? "reactivate" : "deactivate";
33
+
34
+ return (
35
+ <div className="flex items-center justify-end gap-x-4">
36
+ <label className="flex items-center gap-x-2 text-xs">
37
+ <Switch
38
+ checked={code.automatic}
39
+ // Switching off is always allowed; switching on only for a live code.
40
+ disabled={pending || (!code.automatic && status !== "active")}
41
+ onCheckedChange={async (checked) => {
42
+ setPending(true);
43
+ try {
44
+ // The server clears the flag on every other code in the same
45
+ // write; the context's refreshCatalogue re-reads the list so the
46
+ // other row turns off too.
47
+ await updateCodeFlags({ id: code.id, automatic: checked });
48
+ } catch (error) {
49
+ errorToast({ title: t("billing.admin.coupons.fields.automatic"), error });
50
+ } finally {
51
+ setPending(false);
52
+ }
53
+ }}
54
+ />
55
+ {t("billing.admin.coupons.fields.automatic")}
56
+ </label>
57
+ <Button variant="outline" size="sm" disabled={isDead} onClick={() => setOpen(true)}>
58
+ {isInactive ? <RotateCcwIcon /> : <PowerIcon />}
59
+ {t(`billing.admin.coupons.codes.${scope}.confirm`)}
60
+ </Button>
61
+ <ConfirmDialog
62
+ open={open}
63
+ onOpenChange={setOpen}
64
+ title={t(`billing.admin.coupons.codes.${scope}.title`)}
65
+ description={t(`billing.admin.coupons.codes.${scope}.description`, { code: code.code })}
66
+ confirmLabel={t(`billing.admin.coupons.codes.${scope}.confirm`)}
67
+ cancelLabel={t("ui.buttons.cancel")}
68
+ destructive={!isInactive}
69
+ onConfirm={async () => {
70
+ try {
71
+ await updateCodeFlags({ id: code.id, active: isInactive });
72
+ } catch (error) {
73
+ errorToast({ title: t(`billing.admin.coupons.codes.${scope}.title`), error });
74
+ throw error; // keeps the dialog open — ConfirmDialog swallows rejections
75
+ }
76
+ }}
77
+ />
78
+ </div>
79
+ );
80
+ }
@@ -0,0 +1 @@
1
+ export * from "./CouponCodeActions";
@@ -0,0 +1,114 @@
1
+ import { beforeAll, describe, expect, it } from "vitest";
2
+ import { DataClassRegistry } from "../../../../../core/registry/DataClassRegistry";
3
+ import { ModuleRegistry } from "../../../../../core/registry/ModuleRegistry";
4
+ import { ModuleFactory, ModuleWithPermissions } from "../../../../../permissions/types";
5
+ import { StripeCouponCodeModule } from "../../stripe-coupon-code.module";
6
+ import { StripeCouponModule } from "../../stripe-coupon.module";
7
+ import { StripeCouponOfferModule } from "../../stripe-coupon-offer.module";
8
+ import { StripeCoupon } from "../stripe-coupon";
9
+ import { StripeCouponCode } from "../stripe-coupon-code";
10
+
11
+ // createJsonApi reads Modules.X.name (resolved lazily via ModuleRegistry), and
12
+ // _readIncluded rehydrates through the registered model, so the modules must be
13
+ // registered first — mirrors email-automation-models.spec.
14
+ const moduleFactory: ModuleFactory = (params) =>
15
+ ({
16
+ pageUrl: params.pageUrl,
17
+ name: params.name,
18
+ model: params.model,
19
+ }) as ModuleWithPermissions;
20
+
21
+ beforeAll(() => {
22
+ const coupon = StripeCouponModule(moduleFactory);
23
+ const code = StripeCouponCodeModule(moduleFactory);
24
+ ModuleRegistry.register("StripeCoupon", coupon);
25
+ ModuleRegistry.register("StripeCouponCode", code);
26
+ ModuleRegistry.register("StripeCouponOffer", StripeCouponOfferModule(moduleFactory));
27
+ // The included code is rehydrated through the class registry (normally filled by bootstrap()).
28
+ DataClassRegistry.registerObjectClass(coupon, StripeCoupon);
29
+ DataClassRegistry.registerObjectClass(code, StripeCouponCode);
30
+ });
31
+
32
+ describe("stripe coupon models", () => {
33
+ it("rehydrates redeemBy into a Date and reads the included codes", () => {
34
+ const coupon = new StripeCoupon().rehydrate({
35
+ jsonApi: {
36
+ id: "co-1",
37
+ type: "stripe-coupons",
38
+ attributes: {
39
+ stripeCouponId: "co_1",
40
+ name: "Founding GM",
41
+ discountType: "percent_off",
42
+ percentOff: 20,
43
+ duration: "forever",
44
+ redeemBy: "2027-01-01T00:00:00.000Z",
45
+ maxRedemptions: 50,
46
+ timesRedeemed: 13,
47
+ valid: true,
48
+ },
49
+ relationships: { codes: { data: [{ type: "stripe-coupon-codes", id: "cc-1" }] } },
50
+ },
51
+ included: [
52
+ {
53
+ id: "cc-1",
54
+ type: "stripe-coupon-codes",
55
+ attributes: {
56
+ stripePromotionCodeId: "promo_1",
57
+ code: "FOUNDER",
58
+ active: true,
59
+ automatic: true,
60
+ expiresAt: "2027-02-01T00:00:00.000Z",
61
+ timesRedeemed: 13,
62
+ },
63
+ },
64
+ ],
65
+ } as any);
66
+
67
+ expect(coupon.redeemBy).toBeInstanceOf(Date);
68
+ expect(coupon.redeemBy?.toISOString()).toBe("2027-01-01T00:00:00.000Z");
69
+ expect(coupon.codes.length).toBe(1);
70
+ expect(coupon.codes[0].code).toBe("FOUNDER");
71
+ expect(coupon.codes[0].expiresAt).toBeInstanceOf(Date);
72
+ expect(coupon.timesRedeemed).toBe(13);
73
+ });
74
+
75
+ it("serialises the datetimes of the coupon and its first code as ISO strings", () => {
76
+ const body = new StripeCoupon().createJsonApi({
77
+ id: "co-1",
78
+ name: "Founding GM",
79
+ discountType: "percent_off",
80
+ percentOff: 20,
81
+ duration: "forever",
82
+ productIds: ["p1"],
83
+ redeemBy: new Date("2027-01-01T00:00:00.000Z"),
84
+ maxRedemptions: 50,
85
+ firstCode: { code: "FOUNDER", automatic: true, expiresAt: new Date("2027-02-01T00:00:00.000Z") },
86
+ });
87
+
88
+ expect(body.data.type).toBe("stripe-coupons");
89
+ expect(body.data.attributes.redeemBy).toBe("2027-01-01T00:00:00.000Z");
90
+ expect(body.data.attributes.firstCode.expiresAt).toBe("2027-02-01T00:00:00.000Z");
91
+ expect(body.data.attributes.firstCode.automatic).toBe(true);
92
+ expect(body.data.attributes.productIds).toEqual(["p1"]);
93
+ expect("amountOff" in body.data.attributes).toBe(false);
94
+ });
95
+
96
+ it("builds the rename body with the name only", () => {
97
+ expect(new StripeCoupon().createRenameJsonApi({ id: "co-1", name: "Renamed" })).toEqual({
98
+ data: { type: "stripe-coupons", id: "co-1", attributes: { name: "Renamed" } },
99
+ });
100
+ });
101
+
102
+ it("writes the code's coupon relationship", () => {
103
+ const body = new StripeCouponCode().createJsonApi({ id: "c1", couponId: "co-1", code: "SPRING" });
104
+ expect(body.data.type).toBe("stripe-coupon-codes");
105
+ expect(body.data.attributes).toEqual({ code: "SPRING" });
106
+ expect(body.data.relationships.coupon).toEqual({ data: { type: "stripe-coupons", id: "co-1" } });
107
+ });
108
+
109
+ it("builds the flags body with only the flags that are set", () => {
110
+ const body = new StripeCouponCode().createFlagsJsonApi({ id: "c1", automatic: true });
111
+ expect(body).toEqual({ data: { type: "stripe-coupon-codes", id: "c1", attributes: { automatic: true } } });
112
+ expect("active" in body.data.attributes).toBe(false);
113
+ });
114
+ });
@@ -0,0 +1,10 @@
1
+ export * from "./stripe-coupon";
2
+ export * from "./stripe-coupon.fields";
3
+ export * from "./stripe-coupon.interface";
4
+ export * from "./stripe-coupon.service";
5
+ export * from "./stripe-coupon-code";
6
+ export * from "./stripe-coupon-code.fields";
7
+ export * from "./stripe-coupon-code.interface";
8
+ export * from "./stripe-coupon-code.service";
9
+ export * from "./stripe-coupon-offer";
10
+ export * from "./stripe-coupon-offer.interface";
@@ -0,0 +1,8 @@
1
+ export enum StripeCouponCodeFields {
2
+ code = "code",
3
+ places = "places",
4
+ expiresAt = "expiresAt",
5
+ automatic = "automatic",
6
+ status = "status",
7
+ actions = "actions",
8
+ }
@@ -0,0 +1,44 @@
1
+ import { ApiDataInterface } from "../../../../core";
2
+ import { StripeCouponInterface } from "./stripe-coupon.interface";
3
+
4
+ // ============================================================================
5
+ // Stripe Coupon Code Interfaces (a customer-facing promotion code of a coupon)
6
+ // ============================================================================
7
+
8
+ export interface StripeCouponCodeInterface extends ApiDataInterface {
9
+ get stripePromotionCodeId(): string;
10
+ get code(): string;
11
+ get active(): boolean;
12
+ get automatic(): boolean;
13
+ get firstTimeTransaction(): boolean;
14
+ get minimumAmount(): number | undefined; // minor units
15
+ get minimumAmountCurrency(): string | undefined;
16
+ get stripeCustomerId(): string | undefined;
17
+ get expiresAt(): Date | undefined;
18
+ get maxRedemptions(): number | undefined;
19
+ get timesRedeemed(): number;
20
+ get coupon(): StripeCouponInterface | undefined;
21
+ }
22
+
23
+ // ============================================================================
24
+ // Stripe Coupon Code Input DTOs
25
+ // ============================================================================
26
+
27
+ export type StripeCouponCodeInput = {
28
+ id: string;
29
+ couponId: string;
30
+ code?: string;
31
+ firstTimeTransaction?: boolean;
32
+ minimumAmount?: number;
33
+ minimumAmountCurrency?: string;
34
+ stripeCustomerId?: string;
35
+ expiresAt?: Date;
36
+ maxRedemptions?: number;
37
+ automatic?: boolean;
38
+ };
39
+
40
+ export type StripeCouponCodeFlagsInput = {
41
+ id: string;
42
+ active?: boolean;
43
+ automatic?: boolean;
44
+ };
@@ -0,0 +1,92 @@
1
+ import { AbstractService, EndpointCreator, HttpMethod, Modules, NextRef, PreviousRef } from "../../../../core";
2
+ import { STRIPE_COUPON_CODE_AUTOMATIC_SEGMENT } from "../stripe-coupon-code.module";
3
+ import { StripeCouponCode } from "./stripe-coupon-code";
4
+ import {
5
+ StripeCouponCodeFlagsInput,
6
+ StripeCouponCodeInput,
7
+ StripeCouponCodeInterface,
8
+ } from "./stripe-coupon-code.interface";
9
+
10
+ /**
11
+ * Billing service for the customer-facing codes of a coupon
12
+ */
13
+ export class StripeCouponCodeService extends AbstractService {
14
+ /**
15
+ * List the codes of a coupon (admin)
16
+ */
17
+ static async listByCoupon(params: {
18
+ couponId: string;
19
+ next?: NextRef;
20
+ prev?: PreviousRef;
21
+ }): Promise<StripeCouponCodeInterface[]> {
22
+ const endpoint = new EndpointCreator({
23
+ endpoint: Modules.StripeCoupon,
24
+ id: params.couponId,
25
+ childEndpoint: Modules.StripeCouponCode,
26
+ });
27
+
28
+ return this.callApi({
29
+ type: Modules.StripeCouponCode,
30
+ method: HttpMethod.GET,
31
+ endpoint: endpoint.generate(),
32
+ next: params.next,
33
+ previous: params.prev,
34
+ });
35
+ }
36
+
37
+ /**
38
+ * Create a code on an existing coupon (admin)
39
+ */
40
+ static async createCode(params: StripeCouponCodeInput): Promise<StripeCouponCodeInterface> {
41
+ const endpoint = new EndpointCreator({
42
+ endpoint: Modules.StripeCouponCode,
43
+ });
44
+
45
+ return this.callApi<StripeCouponCodeInterface>({
46
+ type: Modules.StripeCouponCode,
47
+ method: HttpMethod.POST,
48
+ endpoint: endpoint.generate(),
49
+ input: params,
50
+ });
51
+ }
52
+
53
+ /**
54
+ * Switch "Apply automatically" and / or deactivate / reactivate a code (admin)
55
+ */
56
+ static async updateFlags(params: StripeCouponCodeFlagsInput): Promise<StripeCouponCodeInterface> {
57
+ const endpoint = new EndpointCreator({
58
+ endpoint: Modules.StripeCouponCode,
59
+ id: params.id,
60
+ });
61
+
62
+ return this.callApi<StripeCouponCodeInterface>({
63
+ type: Modules.StripeCouponCode,
64
+ method: HttpMethod.PATCH,
65
+ endpoint: endpoint.generate(),
66
+ input: new StripeCouponCode().createFlagsJsonApi(params),
67
+ overridesJsonApiCreation: true,
68
+ });
69
+ }
70
+
71
+ /**
72
+ * The code the server would apply automatically for this price at a first
73
+ * payment, or null when none applies (logged-in company administrator)
74
+ */
75
+ static async findAutomatic(params: { priceId: string }): Promise<StripeCouponCodeInterface | null> {
76
+ const endpoint = new EndpointCreator({
77
+ endpoint: Modules.StripeCouponCode,
78
+ id: STRIPE_COUPON_CODE_AUTOMATIC_SEGMENT,
79
+ });
80
+ endpoint.addAdditionalParam("priceId", params.priceId);
81
+
82
+ const codes = await this.callApi<StripeCouponCodeInterface[]>({
83
+ type: Modules.StripeCouponCode,
84
+ method: HttpMethod.GET,
85
+ endpoint: endpoint.generate(),
86
+ // A failed lookup must reach the wizard, which then refuses to show full price as the truth
87
+ suppressGlobalError: true,
88
+ });
89
+
90
+ return codes?.[0] ?? null;
91
+ }
92
+ }
@@ -0,0 +1,132 @@
1
+ import { AbstractApiData, JsonApiHydratedDataInterface, Modules } from "../../../../core";
2
+ import {
3
+ StripeCouponCodeFlagsInput,
4
+ StripeCouponCodeInput,
5
+ StripeCouponCodeInterface,
6
+ } from "./stripe-coupon-code.interface";
7
+ import { StripeCouponInterface } from "./stripe-coupon.interface";
8
+ import { StripeCoupon } from "./stripe-coupon";
9
+
10
+ export class StripeCouponCode extends AbstractApiData implements StripeCouponCodeInterface {
11
+ private _stripePromotionCodeId?: string;
12
+ private _code: string = "";
13
+ private _active: boolean = true;
14
+ private _automatic: boolean = false;
15
+ private _firstTimeTransaction: boolean = false;
16
+ private _minimumAmount?: number;
17
+ private _minimumAmountCurrency?: string;
18
+ private _stripeCustomerId?: string;
19
+ private _expiresAt?: Date;
20
+ private _maxRedemptions?: number;
21
+ private _timesRedeemed: number = 0;
22
+ private _coupon?: StripeCouponInterface;
23
+
24
+ get stripePromotionCodeId(): string {
25
+ if (!this._stripePromotionCodeId) throw new Error("stripePromotionCodeId is not defined");
26
+ return this._stripePromotionCodeId;
27
+ }
28
+
29
+ get code(): string {
30
+ return this._code;
31
+ }
32
+
33
+ get active(): boolean {
34
+ return this._active;
35
+ }
36
+
37
+ get automatic(): boolean {
38
+ return this._automatic;
39
+ }
40
+
41
+ get firstTimeTransaction(): boolean {
42
+ return this._firstTimeTransaction;
43
+ }
44
+
45
+ get minimumAmount(): number | undefined {
46
+ return this._minimumAmount;
47
+ }
48
+
49
+ get minimumAmountCurrency(): string | undefined {
50
+ return this._minimumAmountCurrency;
51
+ }
52
+
53
+ get stripeCustomerId(): string | undefined {
54
+ return this._stripeCustomerId;
55
+ }
56
+
57
+ get expiresAt(): Date | undefined {
58
+ return this._expiresAt;
59
+ }
60
+
61
+ get maxRedemptions(): number | undefined {
62
+ return this._maxRedemptions;
63
+ }
64
+
65
+ get timesRedeemed(): number {
66
+ return this._timesRedeemed;
67
+ }
68
+
69
+ get coupon(): StripeCouponInterface | undefined {
70
+ return this._coupon;
71
+ }
72
+
73
+ rehydrate(data: JsonApiHydratedDataInterface): this {
74
+ super.rehydrate(data);
75
+
76
+ const attributes = data.jsonApi.attributes;
77
+
78
+ this._stripePromotionCodeId = attributes.stripePromotionCodeId;
79
+ this._code = attributes.code ?? "";
80
+ this._active = attributes.active ?? true;
81
+ this._automatic = attributes.automatic ?? false;
82
+ this._firstTimeTransaction = attributes.firstTimeTransaction ?? false;
83
+ this._minimumAmount = attributes.minimumAmount ?? undefined;
84
+ this._minimumAmountCurrency = attributes.minimumAmountCurrency ?? undefined;
85
+ this._stripeCustomerId = attributes.stripeCustomerId ?? undefined;
86
+ this._expiresAt = attributes.expiresAt ? new Date(attributes.expiresAt) : undefined;
87
+ this._maxRedemptions = attributes.maxRedemptions ?? undefined;
88
+ this._timesRedeemed = attributes.timesRedeemed ?? 0;
89
+
90
+ this._coupon = this._readIncluded(data, "coupon", Modules.StripeCoupon) as StripeCouponInterface | undefined;
91
+
92
+ return this;
93
+ }
94
+
95
+ createJsonApi(data: StripeCouponCodeInput): any {
96
+ const { id, couponId, ...code } = data;
97
+
98
+ return {
99
+ data: {
100
+ type: Modules.StripeCouponCode.name,
101
+ id,
102
+ attributes: StripeCoupon.codeAttributes(code),
103
+ relationships: {
104
+ coupon: {
105
+ data: {
106
+ type: Modules.StripeCoupon.name,
107
+ id: couponId,
108
+ },
109
+ },
110
+ },
111
+ },
112
+ };
113
+ }
114
+
115
+ /**
116
+ * PATCH body for the flags a code can still change after creation.
117
+ * Only the flags that are set are sent, so switching one never touches the other.
118
+ */
119
+ createFlagsJsonApi(data: StripeCouponCodeFlagsInput): any {
120
+ const attributes: Record<string, boolean> = {};
121
+ if (data.active !== undefined) attributes.active = data.active;
122
+ if (data.automatic !== undefined) attributes.automatic = data.automatic;
123
+
124
+ return {
125
+ data: {
126
+ type: Modules.StripeCouponCode.name,
127
+ id: data.id,
128
+ attributes,
129
+ },
130
+ };
131
+ }
132
+ }
@@ -0,0 +1,17 @@
1
+ import { ApiDataInterface } from "../../../../core";
2
+ import { StripeCouponDiscountType, StripeCouponDurationType } from "./stripe-coupon.interface";
3
+
4
+ // ============================================================================
5
+ // Stripe Coupon Offer Interface (public, synthetic: the automatic code's places left)
6
+ // ============================================================================
7
+
8
+ export interface StripeCouponOfferInterface extends ApiDataInterface {
9
+ get discountType(): StripeCouponDiscountType;
10
+ get percentOff(): number | undefined;
11
+ get amountOff(): number | undefined; // minor units
12
+ get currency(): string | undefined;
13
+ get duration(): StripeCouponDurationType;
14
+ get durationInMonths(): number | undefined;
15
+ get placesLeft(): number;
16
+ get placesTotal(): number;
17
+ }
@@ -0,0 +1,70 @@
1
+ import { AbstractApiData, JsonApiHydratedDataInterface } from "../../../../core";
2
+ import { StripeCouponOfferInterface } from "./stripe-coupon-offer.interface";
3
+ import { StripeCouponDiscountType, StripeCouponDurationType } from "./stripe-coupon.interface";
4
+
5
+ /** Read-only public resource: never created or updated from the frontend. */
6
+ export class StripeCouponOffer extends AbstractApiData implements StripeCouponOfferInterface {
7
+ private _discountType?: StripeCouponDiscountType;
8
+ private _percentOff?: number;
9
+ private _amountOff?: number;
10
+ private _currency?: string;
11
+ private _duration?: StripeCouponDurationType;
12
+ private _durationInMonths?: number;
13
+ private _placesLeft: number = 0;
14
+ private _placesTotal: number = 0;
15
+
16
+ get discountType(): StripeCouponDiscountType {
17
+ if (!this._discountType) throw new Error("discountType is not defined");
18
+ return this._discountType;
19
+ }
20
+
21
+ get percentOff(): number | undefined {
22
+ return this._percentOff;
23
+ }
24
+
25
+ get amountOff(): number | undefined {
26
+ return this._amountOff;
27
+ }
28
+
29
+ get currency(): string | undefined {
30
+ return this._currency;
31
+ }
32
+
33
+ get duration(): StripeCouponDurationType {
34
+ if (!this._duration) throw new Error("duration is not defined");
35
+ return this._duration;
36
+ }
37
+
38
+ get durationInMonths(): number | undefined {
39
+ return this._durationInMonths;
40
+ }
41
+
42
+ get placesLeft(): number {
43
+ return this._placesLeft;
44
+ }
45
+
46
+ get placesTotal(): number {
47
+ return this._placesTotal;
48
+ }
49
+
50
+ rehydrate(data: JsonApiHydratedDataInterface): this {
51
+ super.rehydrate(data);
52
+
53
+ const attributes = data.jsonApi.attributes;
54
+
55
+ this._discountType = attributes.discountType;
56
+ this._percentOff = attributes.percentOff ?? undefined;
57
+ this._amountOff = attributes.amountOff ?? undefined;
58
+ this._currency = attributes.currency ?? undefined;
59
+ this._duration = attributes.duration;
60
+ this._durationInMonths = attributes.durationInMonths ?? undefined;
61
+ this._placesLeft = attributes.placesLeft ?? 0;
62
+ this._placesTotal = attributes.placesTotal ?? 0;
63
+
64
+ return this;
65
+ }
66
+
67
+ createJsonApi(_data?: any): any {
68
+ throw new Error("StripeCouponOffer is declared by the API and is read-only");
69
+ }
70
+ }
@@ -0,0 +1,7 @@
1
+ export enum StripeCouponFields {
2
+ name = "name",
3
+ discount = "discount",
4
+ places = "places",
5
+ codes = "codes",
6
+ status = "status",
7
+ }