@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.
- package/dist/{BlockNoteEditor-GVQCKRMU.mjs → BlockNoteEditor-R3WXUFHX.mjs} +4 -4
- package/dist/{BlockNoteEditor-THBJH2UR.js → BlockNoteEditor-ZHXXFWUJ.js} +19 -19
- package/dist/{BlockNoteEditor-THBJH2UR.js.map → BlockNoteEditor-ZHXXFWUJ.js.map} +1 -1
- package/dist/{BlockNoteViewer-U6E3DS4F.mjs → BlockNoteViewer-BVVN6D3T.mjs} +2 -2
- package/dist/{BlockNoteViewer-VZOVKTFO.js → BlockNoteViewer-SD3ZFIXD.js} +3 -3
- package/dist/{BlockNoteViewer-VZOVKTFO.js.map → BlockNoteViewer-SD3ZFIXD.js.map} +1 -1
- package/dist/billing/index.d.mts +197 -46
- package/dist/billing/index.d.ts +197 -46
- package/dist/billing/index.js +1166 -589
- package/dist/billing/index.js.map +1 -1
- package/dist/billing/index.mjs +803 -226
- package/dist/billing/index.mjs.map +1 -1
- package/dist/{chunk-WPYT4UKJ.js → chunk-2TXE57LE.js} +3 -3
- package/dist/{chunk-WPYT4UKJ.js.map → chunk-2TXE57LE.js.map} +1 -1
- package/dist/{chunk-G6UE6MEP.js → chunk-5TH2DSAH.js} +901 -204
- package/dist/chunk-5TH2DSAH.js.map +1 -0
- package/dist/{chunk-X5WIUUNG.mjs → chunk-CFB6FHIY.mjs} +3342 -2377
- package/dist/chunk-CFB6FHIY.mjs.map +1 -0
- package/dist/{chunk-VC5A3AUZ.mjs → chunk-FK26NX3S.mjs} +701 -4
- package/dist/chunk-FK26NX3S.mjs.map +1 -0
- package/dist/{chunk-YDYVJM4I.js → chunk-IJME5SAM.js} +4 -4
- package/dist/{chunk-YDYVJM4I.js.map → chunk-IJME5SAM.js.map} +1 -1
- package/dist/{chunk-W7F5BDSV.mjs → chunk-OKCV4GPJ.mjs} +2 -2
- package/dist/{chunk-3CGROPF3.mjs → chunk-RGQYFSJF.mjs} +2 -2
- package/dist/{chunk-6SQYXUVP.mjs → chunk-WCHLCOZG.mjs} +2 -2
- package/dist/{chunk-S4YJ5WCW.js → chunk-XOSC3FFP.js} +8 -8
- package/dist/{chunk-S4YJ5WCW.js.map → chunk-XOSC3FFP.js.map} +1 -1
- package/dist/{chunk-TY5FYP3G.js → chunk-ZCOPOO7T.js} +2024 -1059
- package/dist/chunk-ZCOPOO7T.js.map +1 -0
- package/dist/client/index.js +4 -4
- package/dist/client/index.mjs +3 -3
- package/dist/components/index.js +4 -4
- package/dist/components/index.mjs +3 -3
- package/dist/contexts/index.js +4 -4
- package/dist/contexts/index.mjs +3 -3
- package/dist/core/index.d.mts +248 -3
- package/dist/core/index.d.ts +248 -3
- package/dist/core/index.js +42 -2
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +41 -1
- package/dist/{stripe-promotion-code.interface-ClZ7DxS9.d.mts → currency-DT_dveBx.d.mts} +137 -1
- package/dist/{stripe-promotion-code.interface-C3qqh3mi.d.ts → currency-pK9eRYRC.d.ts} +137 -1
- package/dist/email-automations/index.js +92 -92
- package/dist/email-automations/index.mjs +3 -3
- package/dist/features/analytics/index.js +121 -121
- package/dist/features/analytics/index.mjs +4 -4
- package/dist/features/analytics/server.js +4 -4
- package/dist/features/analytics/server.mjs +2 -2
- package/dist/features/blog/index.js +150 -150
- package/dist/features/blog/index.mjs +3 -3
- package/dist/features/blog/server.js +16 -16
- package/dist/features/blog/server.mjs +1 -1
- package/dist/features/help/index.js +37 -37
- package/dist/features/help/index.mjs +3 -3
- package/dist/features/tokenusage/index.js +124 -124
- package/dist/features/tokenusage/index.mjs +4 -4
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +43 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +42 -2
- package/dist/server/index.js +3 -3
- package/dist/server/index.mjs +2 -2
- package/messages/en.json +125 -3
- package/messages/it.json +125 -3
- package/package.json +1 -1
- package/scripts/validate-messages.mjs +1 -0
- package/src/billing/index.ts +1 -0
- package/src/client/index.ts +4 -0
- package/src/core/index.ts +4 -0
- package/src/core/registry/ModuleRegistry.ts +3 -0
- package/src/features/billing/components/cards/InvoicesSummaryCard.tsx +3 -2
- package/src/features/billing/components/cards/SubscriptionSummaryCard.tsx +4 -3
- package/src/features/billing/components/containers/BillingDashboardContainer.tsx +22 -5
- package/src/features/billing/components/utils/__tests__/discount.spec.ts +59 -0
- package/src/features/billing/components/utils/__tests__/url.spec.ts +32 -0
- package/src/features/billing/components/utils/discount.ts +38 -0
- package/src/features/billing/components/utils/index.ts +2 -0
- package/src/features/billing/components/utils/url.ts +12 -0
- package/src/features/billing/contexts/CouponContext.tsx +209 -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 +72 -0
- package/src/features/billing/stripe-coupon/components/details/index.ts +1 -0
- package/src/features/billing/stripe-coupon/components/forms/CouponCodeEditor.tsx +80 -0
- package/src/features/billing/stripe-coupon/components/forms/CouponCodeFields.tsx +214 -0
- package/src/features/billing/stripe-coupon/components/forms/CouponEditor.tsx +425 -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 +81 -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 +55 -0
- package/src/features/billing/stripe-coupon/data/stripe-coupon.service.ts +93 -0
- package/src/features/billing/stripe-coupon/data/stripe-coupon.ts +167 -0
- package/src/features/billing/stripe-coupon/hooks/useStripeCouponCodeTableStructure.tsx +161 -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 +15 -0
- package/src/features/billing/stripe-coupon/stripe-coupon-offer.module.ts +10 -0
- package/src/features/billing/stripe-coupon/stripe-coupon.module.ts +25 -0
- package/src/features/billing/stripe-coupon/utils/__tests__/coupon-places.spec.ts +19 -0
- package/src/features/billing/stripe-coupon/utils/__tests__/to-promotion-code-result.spec.ts +41 -0
- package/src/features/billing/stripe-coupon/utils/coupon-places.ts +23 -0
- package/src/features/billing/stripe-coupon/utils/format-coupon-discount.ts +36 -0
- package/src/features/billing/stripe-coupon/utils/index.ts +3 -0
- package/src/features/billing/stripe-coupon/utils/to-promotion-code-result.ts +23 -0
- package/src/features/billing/stripe-customer/components/forms/PaymentMethodForm.tsx +24 -8
- package/src/features/billing/stripe-product/components/containers/ProductsListContainer.tsx +21 -6
- package/src/features/billing/stripe-promotion-code/components/PromoCodeInput.tsx +60 -19
- package/src/features/billing/stripe-promotion-code/data/stripe-promotion-code.interface.ts +2 -0
- package/src/features/billing/stripe-subscription/components/containers/SubscriptionsContainer.tsx +24 -5
- package/src/features/billing/stripe-subscription/components/lists/SubscriptionsList.tsx +21 -2
- package/src/features/billing/stripe-subscription/components/wizards/SubscriptionWizard.tsx +1 -1
- package/src/features/billing/stripe-subscription/components/wizards/WizardStepPaymentMethod.tsx +1 -1
- package/src/features/billing/stripe-subscription/components/wizards/WizardStepReview.tsx +60 -49
- package/src/features/billing/stripe-subscription/components/wizards/__tests__/review-card.spec.ts +34 -0
- package/src/features/billing/stripe-subscription/components/wizards/review-card.ts +24 -0
- package/src/features/billing/stripe-subscription/data/__tests__/stripe-subscription.discount.spec.ts +67 -0
- package/src/features/billing/stripe-subscription/data/stripe-subscription.interface.ts +14 -0
- package/src/features/billing/stripe-subscription/data/stripe-subscription.service.ts +39 -0
- package/src/features/billing/stripe-subscription/data/stripe-subscription.ts +21 -0
- package/src/features/billing/stripe-subscription/hooks/__tests__/wizard-payment.spec.ts +48 -0
- package/src/features/billing/stripe-subscription/hooks/__tests__/wizard-promotion.spec.ts +132 -0
- package/src/features/billing/stripe-subscription/hooks/useSubscriptionWizard.ts +138 -12
- package/src/features/billing/stripe-subscription/hooks/wizard-payment.ts +30 -0
- package/src/features/billing/stripe-subscription/hooks/wizard-promotion.ts +61 -0
- package/src/features/user/contexts/CurrentUserContext.tsx +25 -0
- package/src/features/user/contexts/__tests__/CurrentUserContext.spec.tsx +121 -2
- package/dist/chunk-G6UE6MEP.js.map +0 -1
- package/dist/chunk-TY5FYP3G.js.map +0 -1
- package/dist/chunk-VC5A3AUZ.mjs.map +0 -1
- package/dist/chunk-X5WIUUNG.mjs.map +0 -1
- /package/dist/{BlockNoteEditor-GVQCKRMU.mjs.map → BlockNoteEditor-R3WXUFHX.mjs.map} +0 -0
- /package/dist/{BlockNoteViewer-U6E3DS4F.mjs.map → BlockNoteViewer-BVVN6D3T.mjs.map} +0 -0
- /package/dist/{chunk-W7F5BDSV.mjs.map → chunk-OKCV4GPJ.mjs.map} +0 -0
- /package/dist/{chunk-3CGROPF3.mjs.map → chunk-RGQYFSJF.mjs.map} +0 -0
- /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 {
|
|
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
|
-
//
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
|
|
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 (!
|
|
112
|
+
if (!discount) return null;
|
|
109
113
|
|
|
110
|
-
if (
|
|
111
|
-
return t("billing.ui.wizard.review.percent_off", { value:
|
|
114
|
+
if (discount.discountType === "percent_off") {
|
|
115
|
+
return t("billing.ui.wizard.review.percent_off", { value: discount.discountValue });
|
|
112
116
|
}
|
|
113
117
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
?
|
|
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
|
-
?
|
|
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
|
-
{
|
|
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>
|
package/src/features/billing/stripe-subscription/components/wizards/__tests__/review-card.spec.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { PaymentMethodInterface } from "../../../../stripe-customer/data/payment-method.interface";
|
|
3
|
+
import { reviewCard } from "../review-card";
|
|
4
|
+
|
|
5
|
+
const card = (id: string, brand: string, last4: string): PaymentMethodInterface => ({
|
|
6
|
+
id,
|
|
7
|
+
type: "card",
|
|
8
|
+
card: { brand, last4, expMonth: 1, expYear: 2030 },
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
describe("reviewCard", () => {
|
|
12
|
+
it("returns null when there is no card", () => {
|
|
13
|
+
expect(reviewCard([], "pm_1")).toBe(null);
|
|
14
|
+
expect(reviewCard(null, null)).toBe(null);
|
|
15
|
+
expect(reviewCard(undefined, undefined)).toBe(null);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("returns the customer's default card", () => {
|
|
19
|
+
expect(reviewCard([card("pm_new", "visa", "4242"), card("pm_old", "mastercard", "0005")], "pm_old")).toEqual({
|
|
20
|
+
brand: "Mastercard",
|
|
21
|
+
last4: "0005",
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("falls back to the first card when no default is set or the default is gone", () => {
|
|
26
|
+
const methods = [card("pm_new", "visa", "4242"), card("pm_old", "mastercard", "0005")];
|
|
27
|
+
expect(reviewCard(methods, null)).toEqual({ brand: "Visa", last4: "4242" });
|
|
28
|
+
expect(reviewCard(methods, "pm_detached")).toEqual({ brand: "Visa", last4: "4242" });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("returns null when the card has no last 4 digits", () => {
|
|
32
|
+
expect(reviewCard([{ id: "pm_1", type: "sepa_debit" }], "pm_1")).toBe(null);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { PaymentMethodInterface } from "../../../stripe-customer/data/payment-method.interface";
|
|
2
|
+
|
|
3
|
+
export type ReviewCard = { brand: string; last4: string };
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The card the review shows as the one on file: the customer's default, or the
|
|
7
|
+
* first card listed when no default is set (or the default is no longer listed).
|
|
8
|
+
* Null when no card with a brand and last 4 digits is known.
|
|
9
|
+
*/
|
|
10
|
+
export function reviewCard(
|
|
11
|
+
methods: PaymentMethodInterface[] | null | undefined,
|
|
12
|
+
defaultPaymentMethodId: string | null | undefined,
|
|
13
|
+
): ReviewCard | null {
|
|
14
|
+
if (!methods || methods.length === 0) return null;
|
|
15
|
+
|
|
16
|
+
const method = (defaultPaymentMethodId && methods.find((m) => m.id === defaultPaymentMethodId)) || methods[0];
|
|
17
|
+
if (!method.card?.last4) return null;
|
|
18
|
+
|
|
19
|
+
const brand = method.card.brand
|
|
20
|
+
? method.card.brand.charAt(0).toUpperCase() + method.card.brand.slice(1)
|
|
21
|
+
: method.type.charAt(0).toUpperCase() + method.type.slice(1);
|
|
22
|
+
|
|
23
|
+
return { brand, last4: method.card.last4 };
|
|
24
|
+
}
|
package/src/features/billing/stripe-subscription/data/__tests__/stripe-subscription.discount.spec.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { beforeAll, describe, expect, it } from "vitest";
|
|
2
|
+
import { ModuleRegistry } from "../../../../../core/registry/ModuleRegistry";
|
|
3
|
+
import { ModuleFactory, ModuleWithPermissions } from "../../../../../permissions/types";
|
|
4
|
+
import { StripePriceModule } from "../../../stripe-price/stripe-price.module";
|
|
5
|
+
import { StripeSubscription } from "../stripe-subscription";
|
|
6
|
+
|
|
7
|
+
// rehydrate reads Modules.StripePrice for the price relationship, so the module must be registered.
|
|
8
|
+
const moduleFactory: ModuleFactory = (params) =>
|
|
9
|
+
({ pageUrl: params.pageUrl, name: params.name, model: params.model }) as ModuleWithPermissions;
|
|
10
|
+
|
|
11
|
+
beforeAll(() => {
|
|
12
|
+
ModuleRegistry.register("StripePrice", StripePriceModule(moduleFactory));
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const subscriptionWith = (attributes: Record<string, unknown>) =>
|
|
16
|
+
new StripeSubscription().rehydrate({
|
|
17
|
+
jsonApi: {
|
|
18
|
+
id: "sub-1",
|
|
19
|
+
type: "stripe-subscriptions",
|
|
20
|
+
attributes: {
|
|
21
|
+
stripeSubscriptionId: "sub_1",
|
|
22
|
+
status: "active",
|
|
23
|
+
currentPeriodStart: "2026-10-01T00:00:00.000Z",
|
|
24
|
+
currentPeriodEnd: "2026-11-01T00:00:00.000Z",
|
|
25
|
+
...attributes,
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
included: [],
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("StripeSubscription discount", () => {
|
|
32
|
+
it("rehydrates a percentage discount that lasts forever", () => {
|
|
33
|
+
const subscription = subscriptionWith({ discountPercentOff: 20, discountDuration: "forever" });
|
|
34
|
+
|
|
35
|
+
expect(subscription.discount).toEqual({
|
|
36
|
+
percentOff: 20,
|
|
37
|
+
amountOff: undefined,
|
|
38
|
+
currency: undefined,
|
|
39
|
+
duration: "forever",
|
|
40
|
+
durationInMonths: undefined,
|
|
41
|
+
end: undefined,
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("rehydrates a repeating amount discount with its end as a Date", () => {
|
|
46
|
+
const subscription = subscriptionWith({
|
|
47
|
+
discountAmountOff: 200,
|
|
48
|
+
discountCurrency: "eur",
|
|
49
|
+
discountDuration: "repeating",
|
|
50
|
+
discountDurationInMonths: 3,
|
|
51
|
+
discountEnd: "2027-01-01T00:00:00.000Z",
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
expect(subscription.discount?.amountOff).toBe(200);
|
|
55
|
+
expect(subscription.discount?.currency).toBe("eur");
|
|
56
|
+
expect(subscription.discount?.durationInMonths).toBe(3);
|
|
57
|
+
expect(subscription.discount?.end).toEqual(new Date("2027-01-01T00:00:00.000Z"));
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("has no discount when the API sends none", () => {
|
|
61
|
+
expect(subscriptionWith({}).discount).toBeUndefined();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("has no discount when the duration comes without an amount", () => {
|
|
65
|
+
expect(subscriptionWith({ discountDuration: "forever" }).discount).toBeUndefined();
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -19,6 +19,19 @@ export enum SubscriptionStatus {
|
|
|
19
19
|
// Subscription Interfaces
|
|
20
20
|
// ============================================================================
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* The discount Stripe applies to the subscription (read-only, synced from Stripe).
|
|
24
|
+
* Exactly one of percentOff / amountOff is set.
|
|
25
|
+
*/
|
|
26
|
+
export type StripeSubscriptionDiscount = {
|
|
27
|
+
percentOff?: number;
|
|
28
|
+
amountOff?: number; // minor units, in currency
|
|
29
|
+
currency?: string;
|
|
30
|
+
duration: "forever" | "once" | "repeating";
|
|
31
|
+
durationInMonths?: number;
|
|
32
|
+
end?: Date; // when a repeating discount stops
|
|
33
|
+
};
|
|
34
|
+
|
|
22
35
|
export interface StripeSubscriptionInterface extends ApiDataInterface {
|
|
23
36
|
get stripeSubscriptionId(): string;
|
|
24
37
|
get status(): SubscriptionStatus;
|
|
@@ -29,6 +42,7 @@ export interface StripeSubscriptionInterface extends ApiDataInterface {
|
|
|
29
42
|
get trialStart(): Date | undefined;
|
|
30
43
|
get trialEnd(): Date | undefined;
|
|
31
44
|
get price(): StripePriceInterface | undefined;
|
|
45
|
+
get discount(): StripeSubscriptionDiscount | undefined;
|
|
32
46
|
}
|
|
33
47
|
|
|
34
48
|
// ============================================================================
|
|
@@ -65,6 +65,8 @@ export class StripeSubscriptionService extends AbstractService {
|
|
|
65
65
|
method: HttpMethod.POST,
|
|
66
66
|
endpoint: endpoint.generate(),
|
|
67
67
|
input: params,
|
|
68
|
+
// The wizard reads 410 / 409 / 402 / 422 itself; a global handler must not swallow them
|
|
69
|
+
suppressGlobalError: true,
|
|
68
70
|
});
|
|
69
71
|
|
|
70
72
|
return {
|
|
@@ -92,9 +94,44 @@ export class StripeSubscriptionService extends AbstractService {
|
|
|
92
94
|
method: HttpMethod.POST,
|
|
93
95
|
endpoint: endpoint.generate(),
|
|
94
96
|
input: params,
|
|
97
|
+
// The wizard reads 410 / 409 / 402 / 422 itself; a global handler must not swallow them
|
|
98
|
+
suppressGlobalError: true,
|
|
95
99
|
});
|
|
96
100
|
}
|
|
97
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Change the plan of an existing subscription, with the payment meta.
|
|
104
|
+
*
|
|
105
|
+
* A trial upgrade charges at once; when the card asks for 3D Secure the API
|
|
106
|
+
* answers with `meta.requiresAction` and the `clientSecret` to confirm in the
|
|
107
|
+
* browser. Without that meta the change needs nothing more.
|
|
108
|
+
*/
|
|
109
|
+
static async changePlanWithMeta(params: StripeSubscriptionInput): Promise<StripeSubscriptionCreateResponse> {
|
|
110
|
+
const endpoint = new EndpointCreator({
|
|
111
|
+
endpoint: Modules.StripeSubscription,
|
|
112
|
+
id: params.id,
|
|
113
|
+
childEndpoint: "change-plan",
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const result = await this.callApiWithMeta<StripeSubscriptionInterface>({
|
|
117
|
+
type: Modules.StripeSubscription,
|
|
118
|
+
method: HttpMethod.POST,
|
|
119
|
+
endpoint: endpoint.generate(),
|
|
120
|
+
input: params,
|
|
121
|
+
// The wizard reads 410 / 409 / 402 / 422 itself; a global handler must not swallow them
|
|
122
|
+
suppressGlobalError: true,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
subscription: result.data,
|
|
127
|
+
meta: (result.meta as StripeSubscriptionCreateMeta) ?? {
|
|
128
|
+
clientSecret: null,
|
|
129
|
+
paymentIntentId: null,
|
|
130
|
+
requiresAction: false,
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
98
135
|
/**
|
|
99
136
|
* Get a proration preview for a plan change
|
|
100
137
|
*/
|
|
@@ -188,6 +225,8 @@ export class StripeSubscriptionService extends AbstractService {
|
|
|
188
225
|
type: Modules.StripeSubscription,
|
|
189
226
|
method: HttpMethod.POST,
|
|
190
227
|
endpoint: endpoint.generate(),
|
|
228
|
+
// The wizard logs a failed sync and carries on (the webhook catches up): no global error after a paid subscription
|
|
229
|
+
suppressGlobalError: true,
|
|
191
230
|
});
|
|
192
231
|
}
|
|
193
232
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { AbstractApiData, JsonApiHydratedDataInterface, Modules } from "../../../../core";
|
|
2
2
|
import { StripePriceInterface } from "../../stripe-price";
|
|
3
3
|
import {
|
|
4
|
+
StripeSubscriptionDiscount,
|
|
4
5
|
StripeSubscriptionInput,
|
|
5
6
|
StripeSubscriptionInterface,
|
|
6
7
|
SubscriptionStatus,
|
|
@@ -16,6 +17,7 @@ export class StripeSubscription extends AbstractApiData implements StripeSubscri
|
|
|
16
17
|
private _trialStart?: Date;
|
|
17
18
|
private _trialEnd?: Date;
|
|
18
19
|
private _price?: StripePriceInterface;
|
|
20
|
+
private _discount?: StripeSubscriptionDiscount;
|
|
19
21
|
|
|
20
22
|
get stripeSubscriptionId(): string {
|
|
21
23
|
if (!this._stripeSubscriptionId) throw new Error("stripeSubscriptionId is not defined");
|
|
@@ -57,6 +59,10 @@ export class StripeSubscription extends AbstractApiData implements StripeSubscri
|
|
|
57
59
|
return this._price;
|
|
58
60
|
}
|
|
59
61
|
|
|
62
|
+
get discount(): StripeSubscriptionDiscount | undefined {
|
|
63
|
+
return this._discount;
|
|
64
|
+
}
|
|
65
|
+
|
|
60
66
|
rehydrate(data: JsonApiHydratedDataInterface): this {
|
|
61
67
|
super.rehydrate(data);
|
|
62
68
|
|
|
@@ -76,6 +82,21 @@ export class StripeSubscription extends AbstractApiData implements StripeSubscri
|
|
|
76
82
|
this._trialStart = data.jsonApi.attributes.trialStart ? new Date(data.jsonApi.attributes.trialStart) : undefined;
|
|
77
83
|
this._trialEnd = data.jsonApi.attributes.trialEnd ? new Date(data.jsonApi.attributes.trialEnd) : undefined;
|
|
78
84
|
|
|
85
|
+
const attributes = data.jsonApi.attributes;
|
|
86
|
+
const percentOff = attributes.discountPercentOff ?? undefined;
|
|
87
|
+
const amountOff = attributes.discountAmountOff ?? undefined;
|
|
88
|
+
this._discount =
|
|
89
|
+
attributes.discountDuration && (percentOff !== undefined || amountOff !== undefined)
|
|
90
|
+
? {
|
|
91
|
+
percentOff,
|
|
92
|
+
amountOff,
|
|
93
|
+
currency: attributes.discountCurrency ?? undefined,
|
|
94
|
+
duration: attributes.discountDuration,
|
|
95
|
+
durationInMonths: attributes.discountDurationInMonths ?? undefined,
|
|
96
|
+
end: attributes.discountEnd ? new Date(attributes.discountEnd) : undefined,
|
|
97
|
+
}
|
|
98
|
+
: undefined;
|
|
99
|
+
|
|
79
100
|
// Hydrate price relationship
|
|
80
101
|
this._price = this._readIncluded(data, "price", Modules.StripePrice) as StripePriceInterface;
|
|
81
102
|
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { clientSecretToConfirm, subscriptionIdToSync } from "../wizard-payment";
|
|
3
|
+
|
|
4
|
+
describe("clientSecretToConfirm", () => {
|
|
5
|
+
it("returns the client secret when the payment waits on the browser", () => {
|
|
6
|
+
expect(clientSecretToConfirm({ requiresAction: true, clientSecret: "pi_1_secret", paymentIntentId: "pi_1" })).toBe(
|
|
7
|
+
"pi_1_secret",
|
|
8
|
+
);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("returns null when no action is required (trial, already paid)", () => {
|
|
12
|
+
expect(clientSecretToConfirm({ requiresAction: false, clientSecret: "pi_1_secret", paymentIntentId: "pi_1" })).toBe(
|
|
13
|
+
null,
|
|
14
|
+
);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("returns null when action is required but there is no client secret", () => {
|
|
18
|
+
expect(clientSecretToConfirm({ requiresAction: true, clientSecret: null, paymentIntentId: null })).toBe(null);
|
|
19
|
+
expect(clientSecretToConfirm({ requiresAction: true, clientSecret: "" })).toBe(null);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("returns null when the answer carries no meta (plan change with nothing to confirm)", () => {
|
|
23
|
+
expect(clientSecretToConfirm(undefined)).toBe(null);
|
|
24
|
+
expect(clientSecretToConfirm(null)).toBe(null);
|
|
25
|
+
expect(clientSecretToConfirm({})).toBe(null);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("subscriptionIdToSync", () => {
|
|
30
|
+
it("syncs the subscription the API just created", () => {
|
|
31
|
+
expect(subscriptionIdToSync({ id: "sub_new" }, undefined)).toBe("sub_new");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("prefers the answered subscription over the one the wizard was opened on", () => {
|
|
35
|
+
expect(subscriptionIdToSync({ id: "sub_answered" }, { id: "sub_opened" })).toBe("sub_answered");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("falls back to the subscription the wizard was opened on (trial upgrade)", () => {
|
|
39
|
+
expect(subscriptionIdToSync(undefined, { id: "sub_trial" })).toBe("sub_trial");
|
|
40
|
+
expect(subscriptionIdToSync({}, { id: "sub_trial" })).toBe("sub_trial");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("returns null when there is no subscription id", () => {
|
|
44
|
+
expect(subscriptionIdToSync(undefined, undefined)).toBe(null);
|
|
45
|
+
expect(subscriptionIdToSync(null, null)).toBe(null);
|
|
46
|
+
expect(subscriptionIdToSync({ id: "" }, {})).toBe(null);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { PromotionCodeValidationResult } from "../../../stripe-promotion-code/data/stripe-promotion-code.interface";
|
|
3
|
+
import {
|
|
4
|
+
isDiscountSoldOut,
|
|
5
|
+
isDiscountUnavailable,
|
|
6
|
+
promotionCodeToSend,
|
|
7
|
+
shouldLookUpAutomatic,
|
|
8
|
+
} from "../wizard-promotion";
|
|
9
|
+
|
|
10
|
+
const typed = { valid: true, promotionCodeId: "promo_typed", code: "SPRING" } as PromotionCodeValidationResult;
|
|
11
|
+
const automatic = {
|
|
12
|
+
valid: true,
|
|
13
|
+
promotionCodeId: "promo_auto",
|
|
14
|
+
code: "FOUNDING",
|
|
15
|
+
automatic: true,
|
|
16
|
+
} as PromotionCodeValidationResult;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The error `AbstractService.callApi` throws for a non-ok response: `status` is the
|
|
20
|
+
* HTTP status and `body` the JSON:API error document the API's HttpExceptionFilter sends.
|
|
21
|
+
*/
|
|
22
|
+
function apiError(status: number, code?: string): any {
|
|
23
|
+
const error = new Error(`${status}:boom`) as any;
|
|
24
|
+
error.status = status;
|
|
25
|
+
error.digest = `HTTP_${status}`;
|
|
26
|
+
error.body = {
|
|
27
|
+
message: "boom",
|
|
28
|
+
errors: [
|
|
29
|
+
{
|
|
30
|
+
status: String(status),
|
|
31
|
+
...(code ? { code } : {}),
|
|
32
|
+
title: "Error",
|
|
33
|
+
detail: "boom",
|
|
34
|
+
source: { pointer: "/stripe-subscriptions" },
|
|
35
|
+
meta: { timestamp: "2026-10-24T00:00:00.000Z", ...(code ? { context: { code } } : {}) },
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
};
|
|
39
|
+
return error;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("promotionCodeToSend", () => {
|
|
43
|
+
it("sends nothing when there is no code", () => {
|
|
44
|
+
expect(promotionCodeToSend(null)).toBeUndefined();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("sends the promotion code id of an automatic code", () => {
|
|
48
|
+
expect(promotionCodeToSend(automatic)).toBe("promo_auto");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("sends the promotion code id of a typed code", () => {
|
|
52
|
+
expect(promotionCodeToSend(typed)).toBe("promo_typed");
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("shouldLookUpAutomatic", () => {
|
|
57
|
+
it("looks up when there is no code", () => {
|
|
58
|
+
expect(shouldLookUpAutomatic(null)).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("looks up again when the current code is automatic", () => {
|
|
62
|
+
expect(shouldLookUpAutomatic(automatic)).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("does not look up when the GM typed a code", () => {
|
|
66
|
+
expect(shouldLookUpAutomatic(typed)).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("isDiscountSoldOut", () => {
|
|
71
|
+
it("is true for a 410 carrying the discount_sold_out code", () => {
|
|
72
|
+
expect(isDiscountSoldOut(apiError(410, "discount_sold_out"))).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("reads the code from meta.context when the top-level code is missing", () => {
|
|
76
|
+
const error = apiError(410, "discount_sold_out");
|
|
77
|
+
delete error.body.errors[0].code;
|
|
78
|
+
expect(isDiscountSoldOut(error)).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("accepts the status on error.response", () => {
|
|
82
|
+
const error = apiError(410, "discount_sold_out");
|
|
83
|
+
delete error.status;
|
|
84
|
+
error.response = { status: 410 };
|
|
85
|
+
expect(isDiscountSoldOut(error)).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("is false for a 410 without the code", () => {
|
|
89
|
+
expect(isDiscountSoldOut(apiError(410))).toBe(false);
|
|
90
|
+
expect(isDiscountSoldOut({ status: 410 })).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("is false for the code on another status", () => {
|
|
94
|
+
expect(isDiscountSoldOut(apiError(422, "discount_sold_out"))).toBe(false);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("is false for other errors", () => {
|
|
98
|
+
expect(isDiscountSoldOut(apiError(409))).toBe(false);
|
|
99
|
+
expect(isDiscountSoldOut(apiError(402))).toBe(false);
|
|
100
|
+
expect(isDiscountSoldOut(apiError(422, "discount_unavailable"))).toBe(false);
|
|
101
|
+
expect(isDiscountSoldOut(new Error("boom"))).toBe(false);
|
|
102
|
+
expect(isDiscountSoldOut(undefined)).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("isDiscountUnavailable", () => {
|
|
107
|
+
it("is true for a 422 carrying the discount_unavailable code", () => {
|
|
108
|
+
expect(isDiscountUnavailable(apiError(422, "discount_unavailable"))).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("reads the code from meta.context when the top-level code is missing", () => {
|
|
112
|
+
const error = apiError(422, "discount_unavailable");
|
|
113
|
+
delete error.body.errors[0].code;
|
|
114
|
+
expect(isDiscountUnavailable(error)).toBe(true);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("is false for a 422 without the code (a typed code's own rejection)", () => {
|
|
118
|
+
expect(isDiscountUnavailable(apiError(422))).toBe(false);
|
|
119
|
+
expect(isDiscountUnavailable({ status: 422 })).toBe(false);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("is false for the code on another status", () => {
|
|
123
|
+
expect(isDiscountUnavailable(apiError(410, "discount_unavailable"))).toBe(false);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("is false for other errors", () => {
|
|
127
|
+
expect(isDiscountUnavailable(apiError(410, "discount_sold_out"))).toBe(false);
|
|
128
|
+
expect(isDiscountUnavailable(apiError(409))).toBe(false);
|
|
129
|
+
expect(isDiscountUnavailable(new Error("boom"))).toBe(false);
|
|
130
|
+
expect(isDiscountUnavailable(undefined)).toBe(false);
|
|
131
|
+
});
|
|
132
|
+
});
|