@carlonicora/nextjs-jsonapi 5.4.0 → 5.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{BlockNoteEditor-4YCAIEPT.js → BlockNoteEditor-AE3ZTEEH.js} +20 -20
- package/dist/{BlockNoteEditor-4YCAIEPT.js.map → BlockNoteEditor-AE3ZTEEH.js.map} +1 -1
- package/dist/{BlockNoteEditor-YQL2SGFR.mjs → BlockNoteEditor-NEYG5L6W.mjs} +5 -5
- package/dist/{BlockNoteViewer-AEJKJ27Q.mjs → BlockNoteViewer-3OUT5UFY.mjs} +3 -3
- package/dist/{BlockNoteViewer-YL3J7J2Y.js → BlockNoteViewer-4CFKQM7U.js} +4 -4
- package/dist/{BlockNoteViewer-YL3J7J2Y.js.map → BlockNoteViewer-4CFKQM7U.js.map} +1 -1
- package/dist/billing/index.d.mts +186 -25
- package/dist/billing/index.d.ts +186 -25
- package/dist/billing/index.js +1149 -588
- package/dist/billing/index.js.map +1 -1
- package/dist/billing/index.mjs +786 -225
- package/dist/billing/index.mjs.map +1 -1
- package/dist/{chunk-5PPG6PL7.js → chunk-3I5AAJXV.js} +3 -3
- package/dist/{chunk-5PPG6PL7.js.map → chunk-3I5AAJXV.js.map} +1 -1
- package/dist/{chunk-YW4ZCEWB.js → chunk-53BAULDP.js} +4 -4
- package/dist/{chunk-YW4ZCEWB.js.map → chunk-53BAULDP.js.map} +1 -1
- package/dist/{chunk-XCHSX3VC.mjs → chunk-BTPUYUQ3.mjs} +2 -2
- package/dist/{chunk-WC63PJSC.mjs → chunk-EYA2F6AN.mjs} +756 -24
- package/dist/chunk-EYA2F6AN.mjs.map +1 -0
- package/dist/{chunk-NFBOSPNJ.js → chunk-HKZAESS6.js} +954 -222
- package/dist/chunk-HKZAESS6.js.map +1 -0
- package/dist/{chunk-6DC2WVVB.mjs → chunk-KQHBONL4.mjs} +2 -2
- package/dist/{chunk-3NLAXVIN.js → chunk-LQ4DK4JF.js} +1988 -1089
- package/dist/chunk-LQ4DK4JF.js.map +1 -0
- package/dist/{chunk-YNCQSWQW.js → chunk-RL3PL3KV.js} +8 -8
- package/dist/{chunk-YNCQSWQW.js.map → chunk-RL3PL3KV.js.map} +1 -1
- package/dist/{chunk-EUKTBOI2.mjs → chunk-SFYPXCK4.mjs} +3382 -2483
- package/dist/chunk-SFYPXCK4.mjs.map +1 -0
- package/dist/{chunk-QMYQRPQC.mjs → chunk-Y7RR57EW.mjs} +2 -2
- package/dist/client/index.js +5 -5
- package/dist/client/index.js.map +1 -1
- package/dist/client/index.mjs +4 -4
- package/dist/components/editors/server.d.mts +15 -0
- package/dist/components/editors/server.d.ts +15 -0
- package/dist/components/editors/server.js +38 -0
- package/dist/components/editors/server.js.map +1 -0
- package/dist/components/editors/server.mjs +38 -0
- package/dist/components/editors/server.mjs.map +1 -0
- package/dist/components/index.d.mts +33 -10
- package/dist/components/index.d.ts +33 -10
- package/dist/components/index.js +7 -5
- package/dist/components/index.js.map +1 -1
- package/dist/components/index.mjs +8 -6
- package/dist/contexts/index.d.mts +9 -1
- package/dist/contexts/index.d.ts +9 -1
- package/dist/contexts/index.js +5 -5
- package/dist/contexts/index.mjs +4 -4
- package/dist/core/index.d.mts +242 -3
- package/dist/core/index.d.ts +242 -3
- package/dist/core/index.js +36 -4
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +35 -3
- package/dist/email-automations/index.d.mts +3 -0
- package/dist/email-automations/index.d.ts +3 -0
- package/dist/email-automations/index.js +99 -95
- package/dist/email-automations/index.js.map +1 -1
- package/dist/email-automations/index.mjs +30 -26
- package/dist/email-automations/index.mjs.map +1 -1
- package/dist/features/analytics/index.d.mts +11 -4
- package/dist/features/analytics/index.d.ts +11 -4
- package/dist/features/analytics/index.js +127 -135
- package/dist/features/analytics/index.js.map +1 -1
- package/dist/features/analytics/index.mjs +16 -24
- package/dist/features/analytics/index.mjs.map +1 -1
- package/dist/features/analytics/server.d.mts +5 -2
- package/dist/features/analytics/server.d.ts +5 -2
- package/dist/features/analytics/server.js +7 -7
- package/dist/features/analytics/server.js.map +1 -1
- package/dist/features/analytics/server.mjs +5 -5
- package/dist/features/analytics/server.mjs.map +1 -1
- package/dist/features/blog/index.d.mts +12 -2
- package/dist/features/blog/index.d.ts +12 -2
- package/dist/features/blog/index.js +206 -195
- package/dist/features/blog/index.js.map +1 -1
- package/dist/features/blog/index.mjs +71 -60
- package/dist/features/blog/index.mjs.map +1 -1
- package/dist/features/blog/server.d.mts +4 -1
- package/dist/features/blog/server.d.ts +4 -1
- package/dist/features/blog/server.js +49 -21
- package/dist/features/blog/server.js.map +1 -1
- package/dist/features/blog/server.mjs +36 -8
- package/dist/features/blog/server.mjs.map +1 -1
- package/dist/features/help/index.js +38 -38
- package/dist/features/help/index.mjs +4 -4
- package/dist/features/tokenusage/index.d.mts +1 -3
- package/dist/features/tokenusage/index.d.ts +1 -3
- package/dist/features/tokenusage/index.js +127 -135
- package/dist/features/tokenusage/index.js.map +1 -1
- package/dist/features/tokenusage/index.mjs +10 -18
- package/dist/features/tokenusage/index.mjs.map +1 -1
- package/dist/{stripe-promotion-code.interface-C3qqh3mi.d.ts → format-options-BtX3o0Yu.d.ts} +104 -1
- package/dist/{stripe-promotion-code.interface-ClZ7DxS9.d.mts → format-options-D6c7FNDi.d.mts} +104 -1
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +37 -5
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +36 -4
- package/dist/server/index.js +4 -4
- package/dist/server/index.mjs +3 -3
- package/messages/en.json +125 -3
- package/messages/it.json +125 -3
- package/package.json +9 -1
- package/scripts/validate-messages.mjs +1 -0
- package/src/billing/index.ts +1 -0
- package/src/client/index.ts +4 -0
- package/src/components/editors/server/BlockNoteServerContent.tsx +28 -0
- package/src/components/editors/server/__tests__/renderBlockNoteHTML.spec.ts +44 -0
- package/src/components/editors/server/renderBlockNoteHTML.ts +26 -0
- package/src/components/editors/server-entry.ts +7 -0
- package/src/components/index.ts +1 -0
- package/src/components/viewers/BlockNoteStaticViewer.tsx +44 -0
- package/src/core/index.ts +1 -0
- package/src/core/registry/ModuleRegistry.ts +3 -0
- package/src/features/administration/components/AdminIndexContainer.tsx +61 -33
- package/src/features/administration/components/__tests__/AdminIndexContainer.spec.tsx +61 -1
- package/src/features/administration/components/index.ts +2 -1
- package/src/features/administration/contexts/AdministrationContext.tsx +11 -3
- package/src/features/administration/contexts/__tests__/AdministrationContext.spec.tsx +40 -0
- package/src/features/ai-connection/ai-connection.module.ts +2 -0
- package/src/features/ai-connection/contexts/AiConnectionsContext.tsx +3 -16
- package/src/features/ai-connection/contexts/__tests__/AiConnectionsContext.spec.tsx +33 -0
- package/src/features/analytics/__tests__/server-entry.spec.tsx +2 -1
- package/src/features/analytics/analytics.modules.ts +22 -5
- package/src/features/analytics/components/AnalyticsAdminContainer.tsx +4 -3
- package/src/features/analytics/components/AnalyticsAdminPageContainer.tsx +6 -3
- package/src/features/analytics/components/AnalyticsJourneyContainer.tsx +4 -3
- package/src/features/analytics/components/__tests__/AnalyticsJourneyContainer.spec.tsx +11 -0
- package/src/features/analytics/components/__tests__/AnalyticsPageContainers.spec.tsx +12 -1
- package/src/features/analytics/components/server/AnalyticsAdminPage.tsx +6 -3
- package/src/features/analytics/contexts/AnalyticsAdminContext.tsx +8 -17
- package/src/features/analytics/contexts/__tests__/AnalyticsAdminContext.spec.tsx +17 -0
- package/src/features/billing/components/cards/InvoicesSummaryCard.tsx +3 -2
- package/src/features/billing/components/cards/SubscriptionSummaryCard.tsx +4 -3
- package/src/features/billing/components/containers/BillingDashboardContainer.tsx +22 -5
- package/src/features/billing/components/utils/__tests__/discount.spec.ts +59 -0
- package/src/features/billing/components/utils/__tests__/url.spec.ts +32 -0
- package/src/features/billing/components/utils/discount.ts +38 -0
- package/src/features/billing/components/utils/index.ts +2 -0
- package/src/features/billing/components/utils/url.ts +12 -0
- package/src/features/billing/contexts/CouponContext.tsx +211 -0
- package/src/features/billing/contexts/index.ts +1 -0
- package/src/features/billing/i18n-keys.ts +79 -0
- package/src/features/billing/stripe-coupon/components/containers/CouponContainer.tsx +38 -0
- package/src/features/billing/stripe-coupon/components/containers/index.ts +1 -0
- package/src/features/billing/stripe-coupon/components/details/CouponDetails.tsx +69 -0
- package/src/features/billing/stripe-coupon/components/details/index.ts +1 -0
- package/src/features/billing/stripe-coupon/components/forms/CouponCodeEditor.tsx +79 -0
- package/src/features/billing/stripe-coupon/components/forms/CouponCodeFields.tsx +192 -0
- package/src/features/billing/stripe-coupon/components/forms/CouponEditor.tsx +333 -0
- package/src/features/billing/stripe-coupon/components/forms/CouponRefresher.tsx +45 -0
- package/src/features/billing/stripe-coupon/components/forms/index.ts +6 -0
- package/src/features/billing/stripe-coupon/components/index.ts +5 -0
- package/src/features/billing/stripe-coupon/components/lists/CouponCodesList.tsx +61 -0
- package/src/features/billing/stripe-coupon/components/lists/CouponsList.tsx +67 -0
- package/src/features/billing/stripe-coupon/components/lists/index.ts +2 -0
- package/src/features/billing/stripe-coupon/components/widgets/CouponCodeActions.tsx +80 -0
- package/src/features/billing/stripe-coupon/components/widgets/index.ts +1 -0
- package/src/features/billing/stripe-coupon/data/__tests__/stripe-coupon-models.spec.ts +114 -0
- package/src/features/billing/stripe-coupon/data/index.ts +10 -0
- package/src/features/billing/stripe-coupon/data/stripe-coupon-code.fields.ts +8 -0
- package/src/features/billing/stripe-coupon/data/stripe-coupon-code.interface.ts +44 -0
- package/src/features/billing/stripe-coupon/data/stripe-coupon-code.service.ts +92 -0
- package/src/features/billing/stripe-coupon/data/stripe-coupon-code.ts +132 -0
- package/src/features/billing/stripe-coupon/data/stripe-coupon-offer.interface.ts +17 -0
- package/src/features/billing/stripe-coupon/data/stripe-coupon-offer.ts +70 -0
- package/src/features/billing/stripe-coupon/data/stripe-coupon.fields.ts +7 -0
- package/src/features/billing/stripe-coupon/data/stripe-coupon.interface.ts +51 -0
- package/src/features/billing/stripe-coupon/data/stripe-coupon.service.ts +93 -0
- package/src/features/billing/stripe-coupon/data/stripe-coupon.ts +165 -0
- package/src/features/billing/stripe-coupon/hooks/useStripeCouponCodeTableStructure.tsx +154 -0
- package/src/features/billing/stripe-coupon/hooks/useStripeCouponTableStructure.tsx +113 -0
- package/src/features/billing/stripe-coupon/index.ts +5 -0
- package/src/features/billing/stripe-coupon/stripe-coupon-code.module.ts +14 -0
- package/src/features/billing/stripe-coupon/stripe-coupon-offer.module.ts +10 -0
- package/src/features/billing/stripe-coupon/stripe-coupon.module.ts +18 -0
- package/src/features/billing/stripe-coupon/utils/__tests__/coupon-places.spec.ts +19 -0
- package/src/features/billing/stripe-coupon/utils/__tests__/to-promotion-code-result.spec.ts +41 -0
- package/src/features/billing/stripe-coupon/utils/coupon-places.ts +23 -0
- package/src/features/billing/stripe-coupon/utils/format-coupon-discount.ts +36 -0
- package/src/features/billing/stripe-coupon/utils/index.ts +3 -0
- package/src/features/billing/stripe-coupon/utils/to-promotion-code-result.ts +23 -0
- package/src/features/billing/stripe-customer/components/forms/PaymentMethodForm.tsx +24 -8
- package/src/features/billing/stripe-price/stripe-price.module.ts +2 -0
- package/src/features/billing/stripe-product/components/containers/ProductsListContainer.tsx +21 -6
- package/src/features/billing/stripe-product/stripe-product.module.ts +2 -0
- package/src/features/billing/stripe-promotion-code/components/PromoCodeInput.tsx +60 -19
- package/src/features/billing/stripe-promotion-code/data/stripe-promotion-code.interface.ts +2 -0
- package/src/features/billing/stripe-subscription/components/containers/SubscriptionsContainer.tsx +24 -5
- package/src/features/billing/stripe-subscription/components/lists/SubscriptionsList.tsx +21 -2
- package/src/features/billing/stripe-subscription/components/wizards/SubscriptionWizard.tsx +1 -1
- package/src/features/billing/stripe-subscription/components/wizards/WizardStepPaymentMethod.tsx +1 -1
- package/src/features/billing/stripe-subscription/components/wizards/WizardStepReview.tsx +60 -49
- package/src/features/billing/stripe-subscription/components/wizards/__tests__/review-card.spec.ts +34 -0
- package/src/features/billing/stripe-subscription/components/wizards/review-card.ts +24 -0
- package/src/features/billing/stripe-subscription/data/__tests__/stripe-subscription.discount.spec.ts +67 -0
- package/src/features/billing/stripe-subscription/data/stripe-subscription.interface.ts +14 -0
- package/src/features/billing/stripe-subscription/data/stripe-subscription.service.ts +39 -0
- package/src/features/billing/stripe-subscription/data/stripe-subscription.ts +21 -0
- package/src/features/billing/stripe-subscription/hooks/__tests__/wizard-payment.spec.ts +48 -0
- package/src/features/billing/stripe-subscription/hooks/__tests__/wizard-promotion.spec.ts +132 -0
- package/src/features/billing/stripe-subscription/hooks/useSubscriptionWizard.ts +138 -12
- package/src/features/billing/stripe-subscription/hooks/wizard-payment.ts +30 -0
- package/src/features/billing/stripe-subscription/hooks/wizard-promotion.ts +54 -0
- package/src/features/blog/components/admin/BlogAdminContainer.tsx +13 -6
- package/src/features/blog/components/admin/BlogCategoriesList.tsx +1 -1
- package/src/features/blog/components/admin/BlogCoverImageField.tsx +1 -1
- package/src/features/blog/components/admin/BlogPostContainer.tsx +0 -1
- package/src/features/blog/components/admin/BlogPostEditor.tsx +42 -33
- package/src/features/blog/components/admin/BlogPostTranslationEditor.tsx +12 -4
- package/src/features/blog/components/admin/BlogPostsList.tsx +1 -1
- package/src/features/blog/components/public/BlogPostCard.tsx +2 -9
- package/src/features/blog/components/public/BlogPostMeta.tsx +2 -2
- package/src/features/blog/components/public/BlogPostView.tsx +9 -3
- package/src/features/blog/components/public/__tests__/BlogList.spec.tsx +2 -0
- package/src/features/blog/components/public/__tests__/BlogPostView.spec.tsx +17 -11
- package/src/features/blog/server/fetchers.ts +57 -8
- package/src/features/email-automation/components/containers/EmailAutomationsListContainer.tsx +12 -3
- package/src/features/rbac/contexts/RbacContext.tsx +4 -7
- package/src/features/rbac/contexts/__tests__/RbacContext.spec.tsx +42 -0
- package/src/features/tokenusage/components/TokenUsageAdminContainer.tsx +4 -3
- package/src/features/tokenusage/contexts/TokenUsageAdminContext.tsx +3 -18
- package/src/features/tokenusage/contexts/__tests__/TokenUsageAdminContext.spec.tsx +17 -0
- package/src/features/tokenusage/tokenusage-admin.module.ts +4 -3
- package/src/features/tokenusage/tokenusage.modules.ts +12 -2
- package/src/features/user/contexts/CurrentUserContext.tsx +25 -0
- package/src/features/user/contexts/UserContext.tsx +18 -3
- package/src/features/user/contexts/__tests__/CurrentUserContext.spec.tsx +121 -2
- package/src/features/user/contexts/__tests__/UserContext.spec.tsx +95 -0
- package/dist/chunk-3NLAXVIN.js.map +0 -1
- package/dist/chunk-EUKTBOI2.mjs.map +0 -1
- package/dist/chunk-NFBOSPNJ.js.map +0 -1
- package/dist/chunk-WC63PJSC.mjs.map +0 -1
- /package/dist/{BlockNoteEditor-YQL2SGFR.mjs.map → BlockNoteEditor-NEYG5L6W.mjs.map} +0 -0
- /package/dist/{BlockNoteViewer-AEJKJ27Q.mjs.map → BlockNoteViewer-3OUT5UFY.mjs.map} +0 -0
- /package/dist/{chunk-XCHSX3VC.mjs.map → chunk-BTPUYUQ3.mjs.map} +0 -0
- /package/dist/{chunk-6DC2WVVB.mjs.map → chunk-KQHBONL4.mjs.map} +0 -0
- /package/dist/{chunk-QMYQRPQC.mjs.map → chunk-Y7RR57EW.mjs.map} +0 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { AbstractApiData, JsonApiHydratedDataInterface, Modules } from "../../../../core";
|
|
2
|
+
import { StripeCouponCodeInput, StripeCouponCodeInterface } from "./stripe-coupon-code.interface";
|
|
3
|
+
import {
|
|
4
|
+
StripeCouponDiscountType,
|
|
5
|
+
StripeCouponDurationType,
|
|
6
|
+
StripeCouponInput,
|
|
7
|
+
StripeCouponInterface,
|
|
8
|
+
StripeCouponRenameInput,
|
|
9
|
+
} from "./stripe-coupon.interface";
|
|
10
|
+
|
|
11
|
+
export class StripeCoupon extends AbstractApiData implements StripeCouponInterface {
|
|
12
|
+
private _stripeCouponId?: string;
|
|
13
|
+
private _name?: string;
|
|
14
|
+
private _discountType?: StripeCouponDiscountType;
|
|
15
|
+
private _percentOff?: number;
|
|
16
|
+
private _amountOff?: number;
|
|
17
|
+
private _currency?: string;
|
|
18
|
+
private _duration?: StripeCouponDurationType;
|
|
19
|
+
private _durationInMonths?: number;
|
|
20
|
+
private _stripeProductIds: string[] = [];
|
|
21
|
+
private _redeemBy?: Date;
|
|
22
|
+
private _maxRedemptions?: number;
|
|
23
|
+
private _timesRedeemed: number = 0;
|
|
24
|
+
private _valid: boolean = true;
|
|
25
|
+
private _codes: StripeCouponCodeInterface[] = [];
|
|
26
|
+
|
|
27
|
+
get stripeCouponId(): string {
|
|
28
|
+
if (!this._stripeCouponId) throw new Error("stripeCouponId is not defined");
|
|
29
|
+
return this._stripeCouponId;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
get name(): string {
|
|
33
|
+
return this._name ?? "";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
get discountType(): StripeCouponDiscountType {
|
|
37
|
+
if (!this._discountType) throw new Error("discountType is not defined");
|
|
38
|
+
return this._discountType;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
get percentOff(): number | undefined {
|
|
42
|
+
return this._percentOff;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
get amountOff(): number | undefined {
|
|
46
|
+
return this._amountOff;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
get currency(): string | undefined {
|
|
50
|
+
return this._currency;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
get duration(): StripeCouponDurationType {
|
|
54
|
+
if (!this._duration) throw new Error("duration is not defined");
|
|
55
|
+
return this._duration;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
get durationInMonths(): number | undefined {
|
|
59
|
+
return this._durationInMonths;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
get stripeProductIds(): string[] {
|
|
63
|
+
return this._stripeProductIds;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
get redeemBy(): Date | undefined {
|
|
67
|
+
return this._redeemBy;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
get maxRedemptions(): number | undefined {
|
|
71
|
+
return this._maxRedemptions;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
get timesRedeemed(): number {
|
|
75
|
+
return this._timesRedeemed;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
get valid(): boolean {
|
|
79
|
+
return this._valid;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
get codes(): StripeCouponCodeInterface[] {
|
|
83
|
+
return this._codes;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
rehydrate(data: JsonApiHydratedDataInterface): this {
|
|
87
|
+
super.rehydrate(data);
|
|
88
|
+
|
|
89
|
+
const attributes = data.jsonApi.attributes;
|
|
90
|
+
|
|
91
|
+
this._stripeCouponId = attributes.stripeCouponId;
|
|
92
|
+
this._name = attributes.name;
|
|
93
|
+
this._discountType = attributes.discountType;
|
|
94
|
+
this._percentOff = attributes.percentOff ?? undefined;
|
|
95
|
+
this._amountOff = attributes.amountOff ?? undefined;
|
|
96
|
+
this._currency = attributes.currency ?? undefined;
|
|
97
|
+
this._duration = attributes.duration;
|
|
98
|
+
this._durationInMonths = attributes.durationInMonths ?? undefined;
|
|
99
|
+
this._stripeProductIds = attributes.stripeProductIds ?? [];
|
|
100
|
+
this._redeemBy = attributes.redeemBy ? new Date(attributes.redeemBy) : undefined;
|
|
101
|
+
this._maxRedemptions = attributes.maxRedemptions ?? undefined;
|
|
102
|
+
this._timesRedeemed = attributes.timesRedeemed ?? 0;
|
|
103
|
+
this._valid = attributes.valid ?? true;
|
|
104
|
+
|
|
105
|
+
this._codes =
|
|
106
|
+
(this._readIncluded(data, "codes", Modules.StripeCouponCode) as StripeCouponCodeInterface[] | undefined) ?? [];
|
|
107
|
+
|
|
108
|
+
return this;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
createJsonApi(data: StripeCouponInput): any {
|
|
112
|
+
const response: any = {
|
|
113
|
+
data: {
|
|
114
|
+
type: Modules.StripeCoupon.name,
|
|
115
|
+
id: data.id,
|
|
116
|
+
attributes: {
|
|
117
|
+
name: data.name,
|
|
118
|
+
discountType: data.discountType,
|
|
119
|
+
duration: data.duration,
|
|
120
|
+
firstCode: StripeCoupon.codeAttributes(data.firstCode),
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const attributes = response.data.attributes;
|
|
126
|
+
if (data.stripeCouponId) attributes.stripeCouponId = data.stripeCouponId;
|
|
127
|
+
if (data.percentOff !== undefined) attributes.percentOff = data.percentOff;
|
|
128
|
+
if (data.amountOff !== undefined) attributes.amountOff = data.amountOff;
|
|
129
|
+
if (data.currency) attributes.currency = data.currency;
|
|
130
|
+
if (data.durationInMonths !== undefined) attributes.durationInMonths = data.durationInMonths;
|
|
131
|
+
if (data.productIds && data.productIds.length > 0) attributes.productIds = data.productIds;
|
|
132
|
+
if (data.redeemBy !== undefined) attributes.redeemBy = data.redeemBy.toISOString();
|
|
133
|
+
if (data.maxRedemptions !== undefined) attributes.maxRedemptions = data.maxRedemptions;
|
|
134
|
+
|
|
135
|
+
return response;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Rename is the only change Stripe allows on a coupon after creation.
|
|
140
|
+
* Dedicated method so the service never needs a generic override.
|
|
141
|
+
*/
|
|
142
|
+
createRenameJsonApi(data: StripeCouponRenameInput): any {
|
|
143
|
+
return {
|
|
144
|
+
data: {
|
|
145
|
+
type: Modules.StripeCoupon.name,
|
|
146
|
+
id: data.id,
|
|
147
|
+
attributes: { name: data.name },
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Attributes of a code, shared by the coupon's first code and StripeCouponCode.createJsonApi. */
|
|
153
|
+
static codeAttributes(code: Omit<StripeCouponCodeInput, "id" | "couponId">): Record<string, any> {
|
|
154
|
+
const attributes: Record<string, any> = {};
|
|
155
|
+
if (code.code) attributes.code = code.code;
|
|
156
|
+
if (code.firstTimeTransaction !== undefined) attributes.firstTimeTransaction = code.firstTimeTransaction;
|
|
157
|
+
if (code.minimumAmount !== undefined) attributes.minimumAmount = code.minimumAmount;
|
|
158
|
+
if (code.minimumAmountCurrency) attributes.minimumAmountCurrency = code.minimumAmountCurrency;
|
|
159
|
+
if (code.stripeCustomerId) attributes.stripeCustomerId = code.stripeCustomerId;
|
|
160
|
+
if (code.expiresAt !== undefined) attributes.expiresAt = code.expiresAt.toISOString();
|
|
161
|
+
if (code.maxRedemptions !== undefined) attributes.maxRedemptions = code.maxRedemptions;
|
|
162
|
+
if (code.automatic !== undefined) attributes.automatic = code.automatic;
|
|
163
|
+
return attributes;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { ColumnDef } from "@tanstack/react-table";
|
|
4
|
+
import { useTranslations } from "next-intl";
|
|
5
|
+
import { useMemo } from "react";
|
|
6
|
+
import { Badge } from "../../../../shadcnui";
|
|
7
|
+
import { registerTableGenerator, TableContent, UseTableStructureHook } from "../../../../hooks";
|
|
8
|
+
import { formatDate, useBillingFormatOptions } from "../../components/utils";
|
|
9
|
+
import { useCouponContext } from "../../contexts/CouponContext";
|
|
10
|
+
import { CouponCodeActions } from "../components/widgets/CouponCodeActions";
|
|
11
|
+
import { StripeCouponCodeFields } from "../data/stripe-coupon-code.fields";
|
|
12
|
+
import { StripeCouponCodeInterface } from "../data/stripe-coupon-code.interface";
|
|
13
|
+
import { StripeCouponInterface } from "../data/stripe-coupon.interface";
|
|
14
|
+
import { couponPlacesLeft } from "../utils/coupon-places";
|
|
15
|
+
|
|
16
|
+
export type CouponCodeStatus = "active" | "inactive" | "expired" | "used_up";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Expired and used-up codes are dead for good (Stripe never revives them), so
|
|
20
|
+
* they win over a plain inactive flag. Places follow the same rule as the
|
|
21
|
+
* backend: the smaller of the code's and the coupon's remaining limits.
|
|
22
|
+
*/
|
|
23
|
+
export function couponCodeStatus(code: StripeCouponCodeInterface, coupon?: StripeCouponInterface): CouponCodeStatus {
|
|
24
|
+
if (code.expiresAt && code.expiresAt.getTime() <= Date.now()) return "expired";
|
|
25
|
+
const left = couponPlacesLeft(code, coupon ?? { maxRedemptions: undefined, timesRedeemed: 0 });
|
|
26
|
+
if (left === 0) return "used_up";
|
|
27
|
+
return code.active ? "active" : "inactive";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const STATUS_VARIANT: Record<CouponCodeStatus, "softGreen" | "softGray" | "softAmber" | "softRed"> = {
|
|
31
|
+
active: "softGreen",
|
|
32
|
+
inactive: "softGray",
|
|
33
|
+
expired: "softAmber",
|
|
34
|
+
used_up: "softRed",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const useStripeCouponCodeTableStructure: UseTableStructureHook<
|
|
38
|
+
StripeCouponCodeInterface,
|
|
39
|
+
StripeCouponCodeFields
|
|
40
|
+
> = (params) => {
|
|
41
|
+
const t = useTranslations();
|
|
42
|
+
const fmt = useBillingFormatOptions();
|
|
43
|
+
// The codes list is mounted on the coupon's own page, so the coupon whose
|
|
44
|
+
// limits share the places is the context's, not whatever the code includes.
|
|
45
|
+
const { coupon } = useCouponContext();
|
|
46
|
+
|
|
47
|
+
const tableData = useMemo(() => {
|
|
48
|
+
return params.data.map((code: StripeCouponCodeInterface) => {
|
|
49
|
+
const entry: TableContent<StripeCouponCodeInterface> = { jsonApiData: code };
|
|
50
|
+
params.fields.forEach((field) => {
|
|
51
|
+
entry[field] = (code as any)[field as keyof StripeCouponCodeInterface];
|
|
52
|
+
});
|
|
53
|
+
return entry;
|
|
54
|
+
});
|
|
55
|
+
}, [params.data, params.fields]);
|
|
56
|
+
|
|
57
|
+
const fieldColumnMap: Partial<Record<StripeCouponCodeFields, () => any>> = {
|
|
58
|
+
[StripeCouponCodeFields.code]: () => ({
|
|
59
|
+
id: "code",
|
|
60
|
+
accessorKey: "code",
|
|
61
|
+
header: t("billing.admin.coupons.fields.code"),
|
|
62
|
+
cell: ({ row }: { row: TableContent<StripeCouponCodeInterface> }) => {
|
|
63
|
+
const code: StripeCouponCodeInterface = row.original.jsonApiData;
|
|
64
|
+
return <span className="text-xs font-medium">{code.code}</span>;
|
|
65
|
+
},
|
|
66
|
+
enableSorting: false,
|
|
67
|
+
enableHiding: false,
|
|
68
|
+
}),
|
|
69
|
+
[StripeCouponCodeFields.places]: () => ({
|
|
70
|
+
id: "places",
|
|
71
|
+
accessorKey: "places",
|
|
72
|
+
header: t("billing.admin.coupons.fields.places"),
|
|
73
|
+
cell: ({ row }: { row: TableContent<StripeCouponCodeInterface> }) => {
|
|
74
|
+
const code: StripeCouponCodeInterface = row.original.jsonApiData;
|
|
75
|
+
// Typography role 15 (numeric): tabular-nums, end-aligned.
|
|
76
|
+
return (
|
|
77
|
+
<span className="block text-xs tabular-nums text-end">
|
|
78
|
+
{code.maxRedemptions !== undefined
|
|
79
|
+
? t("billing.admin.coupons.places.limited", {
|
|
80
|
+
used: String(code.timesRedeemed),
|
|
81
|
+
limit: String(code.maxRedemptions),
|
|
82
|
+
})
|
|
83
|
+
: t("billing.admin.coupons.places.unlimited", { used: String(code.timesRedeemed) })}
|
|
84
|
+
</span>
|
|
85
|
+
);
|
|
86
|
+
},
|
|
87
|
+
enableSorting: false,
|
|
88
|
+
enableHiding: false,
|
|
89
|
+
}),
|
|
90
|
+
[StripeCouponCodeFields.expiresAt]: () => ({
|
|
91
|
+
id: "expiresAt",
|
|
92
|
+
accessorKey: "expiresAt",
|
|
93
|
+
header: t("billing.admin.coupons.fields.expiresAt"),
|
|
94
|
+
cell: ({ row }: { row: TableContent<StripeCouponCodeInterface> }) => {
|
|
95
|
+
const code: StripeCouponCodeInterface = row.original.jsonApiData;
|
|
96
|
+
return (
|
|
97
|
+
<span className="text-xs tabular-nums">
|
|
98
|
+
{code.expiresAt ? formatDate(code.expiresAt, fmt) : t("billing.admin.coupons.codes.never")}
|
|
99
|
+
</span>
|
|
100
|
+
);
|
|
101
|
+
},
|
|
102
|
+
enableSorting: false,
|
|
103
|
+
enableHiding: false,
|
|
104
|
+
}),
|
|
105
|
+
[StripeCouponCodeFields.automatic]: () => ({
|
|
106
|
+
id: "automatic",
|
|
107
|
+
accessorKey: "automatic",
|
|
108
|
+
header: t("billing.admin.coupons.fields.automatic"),
|
|
109
|
+
cell: ({ row }: { row: TableContent<StripeCouponCodeInterface> }) => {
|
|
110
|
+
const code: StripeCouponCodeInterface = row.original.jsonApiData;
|
|
111
|
+
return code.automatic ? (
|
|
112
|
+
<Badge variant="softBlue">{t("billing.admin.coupons.codes.automatic")}</Badge>
|
|
113
|
+
) : (
|
|
114
|
+
<span className="text-muted-foreground text-xs">—</span>
|
|
115
|
+
);
|
|
116
|
+
},
|
|
117
|
+
enableSorting: false,
|
|
118
|
+
enableHiding: false,
|
|
119
|
+
}),
|
|
120
|
+
[StripeCouponCodeFields.status]: () => ({
|
|
121
|
+
id: "status",
|
|
122
|
+
accessorKey: "status",
|
|
123
|
+
header: t("billing.admin.coupons.fields.status"),
|
|
124
|
+
cell: ({ row }: { row: TableContent<StripeCouponCodeInterface> }) => {
|
|
125
|
+
const code: StripeCouponCodeInterface = row.original.jsonApiData;
|
|
126
|
+
const status = couponCodeStatus(code, code.coupon ?? coupon);
|
|
127
|
+
return <Badge variant={STATUS_VARIANT[status]}>{t(`billing.admin.coupons.codes.status.${status}`)}</Badge>;
|
|
128
|
+
},
|
|
129
|
+
enableSorting: false,
|
|
130
|
+
enableHiding: false,
|
|
131
|
+
}),
|
|
132
|
+
[StripeCouponCodeFields.actions]: () => ({
|
|
133
|
+
id: "actions",
|
|
134
|
+
accessorKey: "actions",
|
|
135
|
+
header: "",
|
|
136
|
+
cell: ({ row }: { row: TableContent<StripeCouponCodeInterface> }) => {
|
|
137
|
+
const code: StripeCouponCodeInterface = row.original.jsonApiData;
|
|
138
|
+
return <CouponCodeActions code={code} status={couponCodeStatus(code, code.coupon ?? coupon)} />;
|
|
139
|
+
},
|
|
140
|
+
enableSorting: false,
|
|
141
|
+
enableHiding: false,
|
|
142
|
+
}),
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const columns = useMemo(() => {
|
|
146
|
+
return params.fields.map((field) => fieldColumnMap[field]?.()).filter((col) => col !== undefined) as ColumnDef<
|
|
147
|
+
TableContent<StripeCouponCodeInterface>
|
|
148
|
+
>[];
|
|
149
|
+
}, [params.fields, fieldColumnMap, t, coupon]);
|
|
150
|
+
|
|
151
|
+
return useMemo(() => ({ data: tableData, columns: columns }), [tableData, columns]);
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
registerTableGenerator("stripe-coupon-codes", useStripeCouponCodeTableStructure);
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { ColumnDef } from "@tanstack/react-table";
|
|
4
|
+
import { useTranslations } from "next-intl";
|
|
5
|
+
import { useMemo } from "react";
|
|
6
|
+
import { Badge, Link } from "../../../../shadcnui";
|
|
7
|
+
import { Modules } from "../../../../core";
|
|
8
|
+
import { registerTableGenerator, TableContent, usePageUrlGenerator, UseTableStructureHook } from "../../../../hooks";
|
|
9
|
+
import { useBillingFormatOptions } from "../../components/utils";
|
|
10
|
+
import { StripeCouponFields } from "../data/stripe-coupon.fields";
|
|
11
|
+
import { StripeCouponInterface } from "../data/stripe-coupon.interface";
|
|
12
|
+
import { formatCouponDiscount } from "../utils/format-coupon-discount";
|
|
13
|
+
|
|
14
|
+
export const useStripeCouponTableStructure: UseTableStructureHook<StripeCouponInterface, StripeCouponFields> = (
|
|
15
|
+
params,
|
|
16
|
+
) => {
|
|
17
|
+
const t = useTranslations();
|
|
18
|
+
const fmt = useBillingFormatOptions();
|
|
19
|
+
const generateUrl = usePageUrlGenerator();
|
|
20
|
+
|
|
21
|
+
const tableData = useMemo(() => {
|
|
22
|
+
return params.data.map((coupon: StripeCouponInterface) => {
|
|
23
|
+
const entry: TableContent<StripeCouponInterface> = { jsonApiData: coupon };
|
|
24
|
+
params.fields.forEach((field) => {
|
|
25
|
+
entry[field] = (coupon as any)[field as keyof StripeCouponInterface];
|
|
26
|
+
});
|
|
27
|
+
return entry;
|
|
28
|
+
});
|
|
29
|
+
}, [params.data, params.fields]);
|
|
30
|
+
|
|
31
|
+
const fieldColumnMap: Partial<Record<StripeCouponFields, () => any>> = {
|
|
32
|
+
[StripeCouponFields.name]: () => ({
|
|
33
|
+
id: "name",
|
|
34
|
+
accessorKey: "name",
|
|
35
|
+
header: t("billing.admin.coupons.fields.name"),
|
|
36
|
+
cell: ({ row }: { row: TableContent<StripeCouponInterface> }) => {
|
|
37
|
+
const coupon: StripeCouponInterface = row.original.jsonApiData;
|
|
38
|
+
return <Link href={generateUrl({ page: Modules.StripeCoupon, id: coupon.id })}>{coupon.name}</Link>;
|
|
39
|
+
},
|
|
40
|
+
enableSorting: false,
|
|
41
|
+
enableHiding: false,
|
|
42
|
+
}),
|
|
43
|
+
[StripeCouponFields.discount]: () => ({
|
|
44
|
+
id: "discount",
|
|
45
|
+
accessorKey: "discount",
|
|
46
|
+
header: t("billing.admin.coupons.fields.discount"),
|
|
47
|
+
cell: ({ row }: { row: TableContent<StripeCouponInterface> }) => {
|
|
48
|
+
const coupon: StripeCouponInterface = row.original.jsonApiData;
|
|
49
|
+
return <span className="text-xs">{formatCouponDiscount(coupon, t, fmt)}</span>;
|
|
50
|
+
},
|
|
51
|
+
enableSorting: false,
|
|
52
|
+
enableHiding: false,
|
|
53
|
+
}),
|
|
54
|
+
[StripeCouponFields.places]: () => ({
|
|
55
|
+
id: "places",
|
|
56
|
+
accessorKey: "places",
|
|
57
|
+
header: t("billing.admin.coupons.fields.places"),
|
|
58
|
+
cell: ({ row }: { row: TableContent<StripeCouponInterface> }) => {
|
|
59
|
+
const coupon: StripeCouponInterface = row.original.jsonApiData;
|
|
60
|
+
// Typography role 15 (numeric): tabular-nums, end-aligned. NEVER
|
|
61
|
+
// font-mono — no monospace font is loaded in these apps.
|
|
62
|
+
return (
|
|
63
|
+
<span className="block text-xs tabular-nums text-end">
|
|
64
|
+
{coupon.maxRedemptions !== undefined
|
|
65
|
+
? t("billing.admin.coupons.places.limited", {
|
|
66
|
+
used: String(coupon.timesRedeemed),
|
|
67
|
+
limit: String(coupon.maxRedemptions),
|
|
68
|
+
})
|
|
69
|
+
: t("billing.admin.coupons.places.unlimited", { used: String(coupon.timesRedeemed) })}
|
|
70
|
+
</span>
|
|
71
|
+
);
|
|
72
|
+
},
|
|
73
|
+
enableSorting: false,
|
|
74
|
+
enableHiding: false,
|
|
75
|
+
}),
|
|
76
|
+
[StripeCouponFields.codes]: () => ({
|
|
77
|
+
id: "codes",
|
|
78
|
+
accessorKey: "codes",
|
|
79
|
+
header: t("billing.admin.coupons.fields.codes"),
|
|
80
|
+
cell: ({ row }: { row: TableContent<StripeCouponInterface> }) => {
|
|
81
|
+
const coupon: StripeCouponInterface = row.original.jsonApiData;
|
|
82
|
+
return <span className="block text-xs tabular-nums text-end">{coupon.codes.length}</span>;
|
|
83
|
+
},
|
|
84
|
+
enableSorting: false,
|
|
85
|
+
enableHiding: false,
|
|
86
|
+
}),
|
|
87
|
+
[StripeCouponFields.status]: () => ({
|
|
88
|
+
id: "status",
|
|
89
|
+
accessorKey: "status",
|
|
90
|
+
header: t("billing.admin.coupons.fields.status"),
|
|
91
|
+
cell: ({ row }: { row: TableContent<StripeCouponInterface> }) => {
|
|
92
|
+
const coupon: StripeCouponInterface = row.original.jsonApiData;
|
|
93
|
+
return coupon.valid ? (
|
|
94
|
+
<Badge variant="softGreen">{t("billing.admin.coupons.status.active")}</Badge>
|
|
95
|
+
) : (
|
|
96
|
+
<Badge variant="softGray">{t("billing.admin.coupons.status.ended")}</Badge>
|
|
97
|
+
);
|
|
98
|
+
},
|
|
99
|
+
enableSorting: false,
|
|
100
|
+
enableHiding: false,
|
|
101
|
+
}),
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const columns = useMemo(() => {
|
|
105
|
+
return params.fields.map((field) => fieldColumnMap[field]?.()).filter((col) => col !== undefined) as ColumnDef<
|
|
106
|
+
TableContent<StripeCouponInterface>
|
|
107
|
+
>[];
|
|
108
|
+
}, [params.fields, fieldColumnMap, t, generateUrl]);
|
|
109
|
+
|
|
110
|
+
return useMemo(() => ({ data: tableData, columns: columns }), [tableData, columns]);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
registerTableGenerator("stripe-coupons", useStripeCouponTableStructure);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { ModuleFactory } from "../../../permissions";
|
|
2
|
+
import { StripeCouponCode } from "./data/stripe-coupon-code";
|
|
3
|
+
|
|
4
|
+
/** The code endpoint's segment that returns the automatic code for a price. */
|
|
5
|
+
export const STRIPE_COUPON_CODE_AUTOMATIC_SEGMENT = "automatic";
|
|
6
|
+
|
|
7
|
+
export const StripeCouponCodeModule = (factory: ModuleFactory) =>
|
|
8
|
+
factory({
|
|
9
|
+
name: "stripe-coupon-codes",
|
|
10
|
+
// Codes are managed on their coupon's page.
|
|
11
|
+
pageUrl: "/administration/coupons",
|
|
12
|
+
model: StripeCouponCode,
|
|
13
|
+
moduleId: "85feb4c7-ff22-416a-b856-8dafcf28ff26",
|
|
14
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { ModuleFactory } from "../../../permissions";
|
|
2
|
+
import { StripeCouponOffer } from "./data/stripe-coupon-offer";
|
|
3
|
+
|
|
4
|
+
export const StripeCouponOfferModule = (factory: ModuleFactory) =>
|
|
5
|
+
factory({
|
|
6
|
+
name: "stripe-coupon-offers",
|
|
7
|
+
pageUrl: "/pricing",
|
|
8
|
+
model: StripeCouponOffer,
|
|
9
|
+
moduleId: "6859d6cc-b94c-4cf7-8a02-b71ad59d75d2",
|
|
10
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { TicketPercentIcon } from "lucide-react";
|
|
2
|
+
import { ModuleFactory } from "../../../permissions";
|
|
3
|
+
import { StripeCoupon } from "./data/stripe-coupon";
|
|
4
|
+
|
|
5
|
+
/** The coupon endpoint's non-resource segment that re-reads its counts from Stripe. */
|
|
6
|
+
export const STRIPE_COUPON_REFRESH_SEGMENT = "refresh";
|
|
7
|
+
|
|
8
|
+
export const StripeCouponModule = (factory: ModuleFactory) =>
|
|
9
|
+
factory({
|
|
10
|
+
name: "stripe-coupons",
|
|
11
|
+
// The administration route this entity is served at. `name` above stays the
|
|
12
|
+
// API endpoint; this drives generateUrl / rewriteUrl / EditorSheet
|
|
13
|
+
// navigation, so it must match the real Next.js route.
|
|
14
|
+
pageUrl: "/administration/coupons",
|
|
15
|
+
model: StripeCoupon,
|
|
16
|
+
moduleId: "50bdb0d2-539c-4358-b609-d6beab248bdb",
|
|
17
|
+
icon: TicketPercentIcon,
|
|
18
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { couponPlacesLeft, couponPlacesTotal } from "../coupon-places";
|
|
3
|
+
|
|
4
|
+
describe("coupon places", () => {
|
|
5
|
+
it("returns null when neither limit is set", () =>
|
|
6
|
+
expect(couponPlacesLeft({ timesRedeemed: 3 }, { timesRedeemed: 3 })).toBeNull());
|
|
7
|
+
it("uses the coupon limit alone", () =>
|
|
8
|
+
expect(couponPlacesLeft({ timesRedeemed: 0 }, { maxRedemptions: 50, timesRedeemed: 13 })).toBe(37));
|
|
9
|
+
it("takes the smaller of coupon and code", () =>
|
|
10
|
+
expect(couponPlacesLeft({ maxRedemptions: 10, timesRedeemed: 8 }, { maxRedemptions: 50, timesRedeemed: 13 })).toBe(
|
|
11
|
+
2,
|
|
12
|
+
));
|
|
13
|
+
it("never goes below zero", () =>
|
|
14
|
+
expect(couponPlacesLeft({ timesRedeemed: 0 }, { maxRedemptions: 50, timesRedeemed: 51 })).toBe(0));
|
|
15
|
+
it("placesTotal is the smaller set limit", () =>
|
|
16
|
+
expect(couponPlacesTotal({ maxRedemptions: 10, timesRedeemed: 0 }, { maxRedemptions: 50, timesRedeemed: 0 })).toBe(
|
|
17
|
+
10,
|
|
18
|
+
));
|
|
19
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { StripeCouponCodeInterface } from "../../data/stripe-coupon-code.interface";
|
|
3
|
+
import { StripeCouponInterface } from "../../data/stripe-coupon.interface";
|
|
4
|
+
import { toPromotionCodeResult } from "../to-promotion-code-result";
|
|
5
|
+
|
|
6
|
+
const codeWith = (coupon: Partial<StripeCouponInterface>): StripeCouponCodeInterface =>
|
|
7
|
+
({ stripePromotionCodeId: "promo_1", code: "FOUNDER", coupon }) as unknown as StripeCouponCodeInterface;
|
|
8
|
+
|
|
9
|
+
describe("toPromotionCodeResult", () => {
|
|
10
|
+
it("maps a percentage coupon", () => {
|
|
11
|
+
expect(
|
|
12
|
+
toPromotionCodeResult(codeWith({ discountType: "percent_off", percentOff: 20, duration: "forever" })),
|
|
13
|
+
).toEqual({
|
|
14
|
+
valid: true,
|
|
15
|
+
automatic: true,
|
|
16
|
+
promotionCodeId: "promo_1",
|
|
17
|
+
code: "FOUNDER",
|
|
18
|
+
discountType: "percent_off",
|
|
19
|
+
discountValue: 20,
|
|
20
|
+
duration: "forever",
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("maps a fixed-amount coupon to amountOff and its currency", () => {
|
|
25
|
+
const result = toPromotionCodeResult(
|
|
26
|
+
codeWith({
|
|
27
|
+
discountType: "amount_off",
|
|
28
|
+
amountOff: 500,
|
|
29
|
+
currency: "eur",
|
|
30
|
+
duration: "repeating",
|
|
31
|
+
durationInMonths: 3,
|
|
32
|
+
}),
|
|
33
|
+
);
|
|
34
|
+
expect(result.discountType).toBe("amount_off");
|
|
35
|
+
expect(result.discountValue).toBe(500);
|
|
36
|
+
expect(result.currency).toBe("eur");
|
|
37
|
+
expect(result.duration).toBe("repeating");
|
|
38
|
+
expect(result.durationInMonths).toBe(3);
|
|
39
|
+
expect(result.automatic).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Places left on a code: the smaller of (coupon limit - coupon redeemed) and
|
|
3
|
+
* (code limit - code redeemed), counting only the limits that are set, never
|
|
4
|
+
* below zero. `null` = unlimited. Same rule as the backend `placesLeft`.
|
|
5
|
+
*/
|
|
6
|
+
type Redeemable = { maxRedemptions?: number; timesRedeemed: number };
|
|
7
|
+
|
|
8
|
+
export function couponPlacesLeft(code: Redeemable, coupon: Redeemable): number | null {
|
|
9
|
+
const remaining = [code, coupon]
|
|
10
|
+
.filter((item) => typeof item.maxRedemptions === "number")
|
|
11
|
+
.map((item) => Math.max(0, (item.maxRedemptions as number) - (item.timesRedeemed ?? 0)));
|
|
12
|
+
|
|
13
|
+
return remaining.length === 0 ? null : Math.min(...remaining);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The smaller of the set limits, or `null` when neither is set. */
|
|
17
|
+
export function couponPlacesTotal(code: Redeemable, coupon: Redeemable): number | null {
|
|
18
|
+
const limits = [code.maxRedemptions, coupon.maxRedemptions].filter(
|
|
19
|
+
(limit): limit is number => typeof limit === "number",
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
return limits.length === 0 ? null : Math.min(...limits);
|
|
23
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { formatCurrency } from "../../components/utils/currency";
|
|
2
|
+
import { BillingFormatOptions, BillingTranslator } from "../../components/utils/format-options";
|
|
3
|
+
import { StripeCouponDiscountType, StripeCouponDurationType } from "../data/stripe-coupon.interface";
|
|
4
|
+
|
|
5
|
+
export type CouponDiscountShape = {
|
|
6
|
+
discountType: StripeCouponDiscountType;
|
|
7
|
+
percentOff?: number;
|
|
8
|
+
amountOff?: number; // minor units
|
|
9
|
+
currency?: string;
|
|
10
|
+
duration: StripeCouponDurationType;
|
|
11
|
+
durationInMonths?: number;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** "20% off forever", "€5.00 off for 3 months", "€5.00 off once". */
|
|
15
|
+
export function formatCouponDiscount(
|
|
16
|
+
coupon: CouponDiscountShape,
|
|
17
|
+
t: BillingTranslator,
|
|
18
|
+
fmt: BillingFormatOptions = {},
|
|
19
|
+
): string {
|
|
20
|
+
const amount =
|
|
21
|
+
coupon.discountType === "percent_off"
|
|
22
|
+
? t("billing.admin.coupons.discount.percent", { value: String(coupon.percentOff ?? 0) })
|
|
23
|
+
: t("billing.admin.coupons.discount.amount", {
|
|
24
|
+
amount: formatCurrency(coupon.amountOff, coupon.currency ?? "eur", fmt),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// Literal keys (no template) so the message validator sees every one in use.
|
|
28
|
+
const duration =
|
|
29
|
+
coupon.duration === "repeating"
|
|
30
|
+
? t("billing.admin.coupons.discount.repeating", { months: String(coupon.durationInMonths ?? 0) })
|
|
31
|
+
: coupon.duration === "once"
|
|
32
|
+
? t("billing.admin.coupons.discount.once")
|
|
33
|
+
: t("billing.admin.coupons.discount.forever");
|
|
34
|
+
|
|
35
|
+
return `${amount} ${duration}`;
|
|
36
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { PromotionCodeValidationResult } from "../../stripe-promotion-code/data/stripe-promotion-code.interface";
|
|
2
|
+
import { StripeCouponCodeInterface } from "../data/stripe-coupon-code.interface";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The automatic code as the wizard's promotion-code state, exactly as if the
|
|
6
|
+
* GM had typed and validated it.
|
|
7
|
+
*/
|
|
8
|
+
export function toPromotionCodeResult(code: StripeCouponCodeInterface): PromotionCodeValidationResult {
|
|
9
|
+
const coupon = code.coupon;
|
|
10
|
+
const discountType = coupon?.discountType;
|
|
11
|
+
|
|
12
|
+
return {
|
|
13
|
+
valid: true,
|
|
14
|
+
automatic: true,
|
|
15
|
+
promotionCodeId: code.stripePromotionCodeId,
|
|
16
|
+
code: code.code,
|
|
17
|
+
discountType,
|
|
18
|
+
discountValue: discountType === "amount_off" ? coupon?.amountOff : coupon?.percentOff,
|
|
19
|
+
currency: discountType === "amount_off" ? coupon?.currency : undefined,
|
|
20
|
+
duration: coupon?.duration,
|
|
21
|
+
durationInMonths: coupon?.durationInMonths,
|
|
22
|
+
};
|
|
23
|
+
}
|