@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
|
@@ -10,9 +10,19 @@ type PaymentMethodFormProps = {
|
|
|
10
10
|
onSuccess: () => void;
|
|
11
11
|
onCancel: () => void;
|
|
12
12
|
isLoading?: boolean;
|
|
13
|
+
/**
|
|
14
|
+
* Always make the new card the customer's default and hide the checkbox. The
|
|
15
|
+
* subscription wizard needs this: the next attempt must charge the card just added.
|
|
16
|
+
*/
|
|
17
|
+
alwaysSetDefault?: boolean;
|
|
13
18
|
};
|
|
14
19
|
|
|
15
|
-
export function PaymentMethodForm({
|
|
20
|
+
export function PaymentMethodForm({
|
|
21
|
+
onSuccess,
|
|
22
|
+
onCancel,
|
|
23
|
+
isLoading = false,
|
|
24
|
+
alwaysSetDefault = false,
|
|
25
|
+
}: PaymentMethodFormProps) {
|
|
16
26
|
const t = useTranslations();
|
|
17
27
|
const stripe = useStripe();
|
|
18
28
|
const elements = useElements();
|
|
@@ -75,7 +85,7 @@ export function PaymentMethodForm({ onSuccess, onCancel, isLoading = false }: Pa
|
|
|
75
85
|
}
|
|
76
86
|
|
|
77
87
|
// Set as default if checkbox is checked
|
|
78
|
-
if (setAsDefault && confirmedSetupIntent?.payment_method) {
|
|
88
|
+
if ((alwaysSetDefault || setAsDefault) && confirmedSetupIntent?.payment_method) {
|
|
79
89
|
await StripeCustomerService.setDefaultPaymentMethod({
|
|
80
90
|
paymentMethodId:
|
|
81
91
|
typeof confirmedSetupIntent.payment_method === "string"
|
|
@@ -132,12 +142,18 @@ export function PaymentMethodForm({ onSuccess, onCancel, isLoading = false }: Pa
|
|
|
132
142
|
</div>
|
|
133
143
|
|
|
134
144
|
{/* Set as Default Checkbox */}
|
|
135
|
-
|
|
136
|
-
<
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
145
|
+
{!alwaysSetDefault && (
|
|
146
|
+
<div className="flex items-center gap-x-2">
|
|
147
|
+
<Checkbox
|
|
148
|
+
id="setAsDefault"
|
|
149
|
+
checked={setAsDefault}
|
|
150
|
+
onCheckedChange={(checked) => setSetAsDefault(!!checked)}
|
|
151
|
+
/>
|
|
152
|
+
<Label htmlFor="setAsDefault" className="cursor-pointer font-normal">
|
|
153
|
+
{t("billing.ui.payment_form.set_default")}
|
|
154
|
+
</Label>
|
|
155
|
+
</div>
|
|
156
|
+
)}
|
|
141
157
|
|
|
142
158
|
{/* Error Alert */}
|
|
143
159
|
{error && (
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { CreditCardIcon } from "lucide-react";
|
|
1
2
|
import { ModuleFactory } from "../../../permissions";
|
|
2
3
|
import { StripePrice } from "./data/stripe-price";
|
|
3
4
|
|
|
@@ -11,4 +12,5 @@ export const StripePriceModule = (factory: ModuleFactory) =>
|
|
|
11
12
|
pageUrl: "/administration/prices",
|
|
12
13
|
model: StripePrice,
|
|
13
14
|
moduleId: "a7d3e5f1-8c9b-4a2e-b6d7-3f1c8e9a4b5d",
|
|
15
|
+
icon: CreditCardIcon,
|
|
14
16
|
});
|
|
@@ -1,27 +1,42 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { useTranslations } from "next-intl";
|
|
3
4
|
import { RoundPageContainer } from "../../../../../components";
|
|
4
5
|
import { Modules } from "../../../../../core";
|
|
6
|
+
import { CouponProvider } from "../../../contexts/CouponContext";
|
|
5
7
|
import { ProductProvider } from "../../../contexts/ProductContext";
|
|
8
|
+
import { CouponsList } from "../../../stripe-coupon/components/lists/CouponsList";
|
|
6
9
|
import { ProductsList } from "../lists/ProductsList";
|
|
7
10
|
|
|
8
11
|
/**
|
|
9
|
-
* Page container for the administrative
|
|
12
|
+
* Page container for the administrative products page: two tabs, Products and
|
|
13
|
+
* Codes, with `sectionKey`s so the URL keeps the tab (`?section=codes`).
|
|
10
14
|
*
|
|
11
15
|
* Client component by necessity: `module={Modules.StripeProduct}` is a registry
|
|
12
16
|
* entry carrying an icon component and methods, which a Server Component cannot
|
|
13
17
|
* pass across the boundary. Routes mount this container instead — the same
|
|
14
18
|
* shape as every other list page.
|
|
15
19
|
*
|
|
16
|
-
* No `
|
|
17
|
-
*
|
|
20
|
+
* No `fullWidth`, on the page or the lists: the page keeps its title bar and
|
|
21
|
+
* each ContentListTable keeps its own bordered card in the tab (see ProductContainer). CouponProvider
|
|
22
|
+
* is nested with `publishChrome={false}`: SharedProvider REPLACES its value for
|
|
23
|
+
* the subtree (SharedContext.tsx:35-37), so a nested provider publishing chrome
|
|
24
|
+
* would blank the products page's own title.
|
|
18
25
|
*/
|
|
19
26
|
export function ProductsListContainer() {
|
|
27
|
+
const t = useTranslations();
|
|
28
|
+
|
|
20
29
|
return (
|
|
21
30
|
<ProductProvider>
|
|
22
|
-
<
|
|
23
|
-
<
|
|
24
|
-
|
|
31
|
+
<CouponProvider publishChrome={false}>
|
|
32
|
+
<RoundPageContainer
|
|
33
|
+
module={Modules.StripeProduct}
|
|
34
|
+
tabs={[
|
|
35
|
+
{ sectionKey: "products", label: t("billing.admin.products.tabs.products"), content: <ProductsList /> },
|
|
36
|
+
{ sectionKey: "codes", label: t("billing.admin.products.tabs.codes"), content: <CouponsList /> },
|
|
37
|
+
]}
|
|
38
|
+
/>
|
|
39
|
+
</CouponProvider>
|
|
25
40
|
</ProductProvider>
|
|
26
41
|
);
|
|
27
42
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { CreditCardIcon } from "lucide-react";
|
|
1
2
|
import { ModuleFactory } from "../../../permissions";
|
|
2
3
|
import { StripeProduct } from "./data";
|
|
3
4
|
|
|
@@ -11,4 +12,5 @@ export const StripeProductModule = (factory: ModuleFactory) =>
|
|
|
11
12
|
pageUrl: "/administration/products",
|
|
12
13
|
model: StripeProduct,
|
|
13
14
|
moduleId: "f8c4a1e9-3b2d-4f7a-9e5c-1d8a6b3c9f2e",
|
|
15
|
+
icon: CreditCardIcon,
|
|
14
16
|
});
|
|
@@ -29,6 +29,25 @@ export function PromoCodeInput({
|
|
|
29
29
|
const locale = useI18nLocale();
|
|
30
30
|
const [code, setCode] = useState("");
|
|
31
31
|
|
|
32
|
+
// Clear the typed text whenever a code becomes applied (typed or automatic):
|
|
33
|
+
// the box then reads empty after the applied code is removed, instead of
|
|
34
|
+
// still holding the removed code. Adjusting state during render on a prop
|
|
35
|
+
// change (React's "storing information from previous renders" pattern) —
|
|
36
|
+
// a failed validation keeps the text so the user can fix a typo.
|
|
37
|
+
const appliedKey = appliedCode?.valid
|
|
38
|
+
? `${appliedCode.automatic ? "auto" : "typed"}:${appliedCode.code ?? ""}`
|
|
39
|
+
: null;
|
|
40
|
+
const [previousAppliedKey, setPreviousAppliedKey] = useState(appliedKey);
|
|
41
|
+
if (appliedKey !== previousAppliedKey) {
|
|
42
|
+
setPreviousAppliedKey(appliedKey);
|
|
43
|
+
if (appliedKey !== null) setCode("");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const handleRemove = () => {
|
|
47
|
+
setCode("");
|
|
48
|
+
onRemove();
|
|
49
|
+
};
|
|
50
|
+
|
|
32
51
|
const handleApply = () => {
|
|
33
52
|
if (code.trim()) {
|
|
34
53
|
onApply(code.trim().toUpperCase());
|
|
@@ -67,6 +86,45 @@ export function PromoCodeInput({
|
|
|
67
86
|
}
|
|
68
87
|
};
|
|
69
88
|
|
|
89
|
+
const codeInput = (
|
|
90
|
+
<div className="space-y-2">
|
|
91
|
+
<div className="flex gap-2">
|
|
92
|
+
<Input
|
|
93
|
+
placeholder={t("billing.ui.promo_code.placeholder")}
|
|
94
|
+
value={code}
|
|
95
|
+
onChange={(e) => setCode(e.target.value.toUpperCase())}
|
|
96
|
+
onKeyDown={handleKeyDown}
|
|
97
|
+
disabled={disabled || isValidating}
|
|
98
|
+
className="flex-1"
|
|
99
|
+
/>
|
|
100
|
+
<Button variant="outline" onClick={handleApply} disabled={disabled || isValidating || !code.trim()}>
|
|
101
|
+
{isValidating ? <Loader2 className="h-4 w-4 animate-spin" /> : t("billing.ui.promo_code.apply")}
|
|
102
|
+
</Button>
|
|
103
|
+
</div>
|
|
104
|
+
{error && <p className="text-destructive text-xs/relaxed">{error}</p>}
|
|
105
|
+
</div>
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
// Show the automatic code as applied, with the option to type a different one
|
|
109
|
+
if (appliedCode?.valid && appliedCode.automatic) {
|
|
110
|
+
return (
|
|
111
|
+
<div className="space-y-3">
|
|
112
|
+
<div className="space-y-2">
|
|
113
|
+
<div className="bg-success/15 border-success/30 flex items-center gap-2 rounded-lg border p-3">
|
|
114
|
+
<Check className="text-success h-4 w-4" />
|
|
115
|
+
<span className="text-success font-medium">{t("billing.ui.promo_code.automatic_label")}</span>
|
|
116
|
+
<span className="text-success text-sm">{formatDiscount(appliedCode)}</span>
|
|
117
|
+
</div>
|
|
118
|
+
<p className="text-success text-sm">{formatDuration(appliedCode)}</p>
|
|
119
|
+
</div>
|
|
120
|
+
<div className="space-y-2">
|
|
121
|
+
<p className="text-muted-foreground text-sm">{t("billing.ui.promo_code.use_different")}</p>
|
|
122
|
+
{codeInput}
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
70
128
|
// Show applied code state
|
|
71
129
|
if (appliedCode?.valid) {
|
|
72
130
|
return (
|
|
@@ -80,7 +138,7 @@ export function PromoCodeInput({
|
|
|
80
138
|
<Button
|
|
81
139
|
variant="ghost"
|
|
82
140
|
size="sm"
|
|
83
|
-
onClick={
|
|
141
|
+
onClick={handleRemove}
|
|
84
142
|
disabled={disabled}
|
|
85
143
|
className="text-success hover:bg-success/25 hover:text-success"
|
|
86
144
|
>
|
|
@@ -92,22 +150,5 @@ export function PromoCodeInput({
|
|
|
92
150
|
);
|
|
93
151
|
}
|
|
94
152
|
|
|
95
|
-
return
|
|
96
|
-
<div className="space-y-2">
|
|
97
|
-
<div className="flex gap-2">
|
|
98
|
-
<Input
|
|
99
|
-
placeholder={t("billing.ui.promo_code.placeholder")}
|
|
100
|
-
value={code}
|
|
101
|
-
onChange={(e) => setCode(e.target.value.toUpperCase())}
|
|
102
|
-
onKeyDown={handleKeyDown}
|
|
103
|
-
disabled={disabled || isValidating}
|
|
104
|
-
className="flex-1"
|
|
105
|
-
/>
|
|
106
|
-
<Button variant="outline" onClick={handleApply} disabled={disabled || isValidating || !code.trim()}>
|
|
107
|
-
{isValidating ? <Loader2 className="h-4 w-4 animate-spin" /> : t("billing.ui.promo_code.apply")}
|
|
108
|
-
</Button>
|
|
109
|
-
</div>
|
|
110
|
-
{error && <p className="text-destructive text-xs/relaxed">{error}</p>}
|
|
111
|
-
</div>
|
|
112
|
-
);
|
|
153
|
+
return codeInput;
|
|
113
154
|
}
|
|
@@ -11,4 +11,6 @@ export interface PromotionCodeValidationResult {
|
|
|
11
11
|
duration?: "forever" | "once" | "repeating";
|
|
12
12
|
durationInMonths?: number;
|
|
13
13
|
errorMessage?: string;
|
|
14
|
+
/** True when the server applies this code by itself (no code typed by the GM). */
|
|
15
|
+
automatic?: boolean;
|
|
14
16
|
}
|
package/src/features/billing/stripe-subscription/components/containers/SubscriptionsContainer.tsx
CHANGED
|
@@ -11,14 +11,33 @@ import { SubscriptionsList } from "../lists";
|
|
|
11
11
|
type SubscriptionsContainerProps = {
|
|
12
12
|
onOpenWizard?: (subscription?: StripeSubscriptionInterface) => void;
|
|
13
13
|
hasActiveRecurringSubscription?: boolean;
|
|
14
|
+
/**
|
|
15
|
+
* The list a parent already holds (the billing dashboard). When given, the container
|
|
16
|
+
* shows it instead of loading its own, and a change asks the parent to refresh through
|
|
17
|
+
* `onSubscriptionsChange`, so the dialog and the cards behind it never disagree.
|
|
18
|
+
*/
|
|
19
|
+
subscriptions?: StripeSubscriptionInterface[];
|
|
20
|
+
onSubscriptionsChange?: () => void;
|
|
14
21
|
};
|
|
15
22
|
|
|
16
|
-
export function SubscriptionsContainer({
|
|
23
|
+
export function SubscriptionsContainer({
|
|
24
|
+
onOpenWizard,
|
|
25
|
+
hasActiveRecurringSubscription,
|
|
26
|
+
subscriptions: parentSubscriptions,
|
|
27
|
+
onSubscriptionsChange,
|
|
28
|
+
}: SubscriptionsContainerProps) {
|
|
17
29
|
const t = useTranslations();
|
|
18
|
-
const
|
|
19
|
-
const [
|
|
30
|
+
const isControlled = parentSubscriptions !== undefined;
|
|
31
|
+
const [ownSubscriptions, setSubscriptions] = useState<StripeSubscriptionInterface[]>([]);
|
|
32
|
+
const [ownLoading, setLoading] = useState<boolean>(!isControlled);
|
|
33
|
+
const subscriptions = parentSubscriptions ?? ownSubscriptions;
|
|
34
|
+
const loading = isControlled ? false : ownLoading;
|
|
20
35
|
|
|
21
36
|
const loadSubscriptions = useCallback(async () => {
|
|
37
|
+
if (isControlled) {
|
|
38
|
+
onSubscriptionsChange?.();
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
22
41
|
setLoading(true);
|
|
23
42
|
try {
|
|
24
43
|
const fetchedSubscriptions = await StripeSubscriptionService.listSubscriptions();
|
|
@@ -28,10 +47,10 @@ export function SubscriptionsContainer({ onOpenWizard, hasActiveRecurringSubscri
|
|
|
28
47
|
} finally {
|
|
29
48
|
setLoading(false);
|
|
30
49
|
}
|
|
31
|
-
}, []);
|
|
50
|
+
}, [isControlled, onSubscriptionsChange]);
|
|
32
51
|
|
|
33
52
|
useEffect(() => {
|
|
34
|
-
loadSubscriptions();
|
|
53
|
+
if (!isControlled) loadSubscriptions();
|
|
35
54
|
}, []);
|
|
36
55
|
|
|
37
56
|
// Detect critical subscriptions
|
|
@@ -3,7 +3,13 @@
|
|
|
3
3
|
import { useTranslations } from "next-intl";
|
|
4
4
|
import { useState } from "react";
|
|
5
5
|
import { Button, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../../../../shadcnui";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
applyDiscount,
|
|
8
|
+
formatCurrency,
|
|
9
|
+
formatDate,
|
|
10
|
+
subscriptionDiscount,
|
|
11
|
+
useBillingFormatOptions,
|
|
12
|
+
} from "../../../components/utils";
|
|
7
13
|
import { StripePriceInterface } from "../../../stripe-price/data/stripe-price.interface";
|
|
8
14
|
import { StripeSubscriptionInterface, SubscriptionStatus } from "../../data";
|
|
9
15
|
import { SubscriptionDetails } from "../details/SubscriptionDetails";
|
|
@@ -71,6 +77,10 @@ export function SubscriptionsList({ subscriptions, onSubscriptionsChange, onChan
|
|
|
71
77
|
{subscriptions.map((subscription) => {
|
|
72
78
|
const price = subscription.price;
|
|
73
79
|
const amount = price?.unitAmount ? formatCurrency(price.unitAmount, price.currency, fmt) : "0";
|
|
80
|
+
// What Stripe bills: the price after the discount the subscription carries.
|
|
81
|
+
const discountedAmount = price
|
|
82
|
+
? applyDiscount(price.unitAmount, subscriptionDiscount(subscription))
|
|
83
|
+
: null;
|
|
74
84
|
const period = `${formatDate(subscription.currentPeriodStart, fmt)} - ${formatDate(subscription.currentPeriodEnd, fmt)}`;
|
|
75
85
|
|
|
76
86
|
return (
|
|
@@ -87,7 +97,16 @@ export function SubscriptionsList({ subscriptions, onSubscriptionsChange, onChan
|
|
|
87
97
|
</TableCell>
|
|
88
98
|
<TableCell className="font-medium">{formatPlanName(price, t)}</TableCell>
|
|
89
99
|
<TableCell className="text-muted-foreground text-sm">{period}</TableCell>
|
|
90
|
-
<TableCell className="text-end font-medium">
|
|
100
|
+
<TableCell className="text-end font-medium">
|
|
101
|
+
{discountedAmount !== null && price ? (
|
|
102
|
+
<>
|
|
103
|
+
<span className="line-through text-muted-foreground me-2">{amount}</span>
|
|
104
|
+
<span className="text-success">{formatCurrency(discountedAmount, price.currency, fmt)}</span>
|
|
105
|
+
</>
|
|
106
|
+
) : (
|
|
107
|
+
amount
|
|
108
|
+
)}
|
|
109
|
+
</TableCell>
|
|
91
110
|
<TableCell className="text-end">
|
|
92
111
|
{(subscription.status === SubscriptionStatus.ACTIVE ||
|
|
93
112
|
subscription.status === SubscriptionStatus.TRIALING) &&
|
|
@@ -72,7 +72,7 @@ export function SubscriptionWizard({
|
|
|
72
72
|
|
|
73
73
|
return (
|
|
74
74
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
75
|
-
<DialogContent className="max-w-2xl">
|
|
75
|
+
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
|
76
76
|
<DialogHeader>
|
|
77
77
|
<DialogTitle>{dialogTitle}</DialogTitle>
|
|
78
78
|
<DialogDescription>{dialogDescription}</DialogDescription>
|
package/src/features/billing/stripe-subscription/components/wizards/WizardStepPaymentMethod.tsx
CHANGED
|
@@ -19,7 +19,7 @@ export function WizardStepPaymentMethod({ onBack, onSuccess, isProcessing }: Wiz
|
|
|
19
19
|
<p className="text-sm text-muted-foreground">{t("billing.ui.wizard.payment_description")}</p>
|
|
20
20
|
</div>
|
|
21
21
|
|
|
22
|
-
<PaymentMethodForm onSuccess={onSuccess} onCancel={onBack} isLoading={isProcessing} />
|
|
22
|
+
<PaymentMethodForm onSuccess={onSuccess} onCancel={onBack} isLoading={isProcessing} alwaysSetDefault />
|
|
23
23
|
</div>
|
|
24
24
|
);
|
|
25
25
|
}
|
|
@@ -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
|
+
});
|