@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
|
@@ -7,9 +7,24 @@ import { useI18nLocale } from "../../../../i18n";
|
|
|
7
7
|
import { StripeCustomerService } from "../../stripe-customer/data/stripe-customer.service";
|
|
8
8
|
import { ProrationPreviewInterface } from "../../stripe-invoice/data/stripe-invoice.interface";
|
|
9
9
|
import { StripePriceInterface } from "../../stripe-price/data/stripe-price.interface";
|
|
10
|
+
import { StripeCouponCodeService } from "../../stripe-coupon/data/stripe-coupon-code.service";
|
|
11
|
+
import { toPromotionCodeResult } from "../../stripe-coupon/utils/to-promotion-code-result";
|
|
10
12
|
import { PromotionCodeValidationResult, StripePromotionCodeService } from "../../stripe-promotion-code";
|
|
11
13
|
import { BillingInterval } from "../components/widgets/IntervalToggle";
|
|
12
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
StripeSubscriptionCreateResponse,
|
|
16
|
+
StripeSubscriptionInterface,
|
|
17
|
+
StripeSubscriptionService,
|
|
18
|
+
SubscriptionStatus,
|
|
19
|
+
} from "../data";
|
|
20
|
+
import {
|
|
21
|
+
isDiscountSoldOut,
|
|
22
|
+
isDiscountUnavailable,
|
|
23
|
+
promotionCodeToSend,
|
|
24
|
+
shouldLookUpAutomatic,
|
|
25
|
+
} from "./wizard-promotion";
|
|
26
|
+
import { useConfirmSubscriptionPayment } from "./useConfirmSubscriptionPayment";
|
|
27
|
+
import { clientSecretToConfirm, subscriptionIdToSync } from "./wizard-payment";
|
|
13
28
|
|
|
14
29
|
export type WizardStep = "plan-selection" | "review" | "payment-method";
|
|
15
30
|
|
|
@@ -97,6 +112,7 @@ export type UseSubscriptionWizardOptions = {
|
|
|
97
112
|
export function useSubscriptionWizard({ subscription, onSuccess, onClose }: UseSubscriptionWizardOptions) {
|
|
98
113
|
const t = useTranslations();
|
|
99
114
|
const locale = useI18nLocale();
|
|
115
|
+
const { confirmPayment } = useConfirmSubscriptionPayment();
|
|
100
116
|
const [state, dispatch] = useReducer(wizardReducer, {
|
|
101
117
|
...initialState,
|
|
102
118
|
selectedPrice: subscription?.price || null,
|
|
@@ -130,6 +146,42 @@ export function useSubscriptionWizard({ subscription, onSuccess, onClose }: UseS
|
|
|
130
146
|
dispatch({ type: "SET_STEP", step });
|
|
131
147
|
}, []);
|
|
132
148
|
|
|
149
|
+
/**
|
|
150
|
+
* Puts the automatic code for this price (or none) in the wizard state, so the
|
|
151
|
+
* review shows what the server would really charge. Returns false when the
|
|
152
|
+
* lookup itself failed: the caller must then not show a price as the truth.
|
|
153
|
+
*/
|
|
154
|
+
const lookUpAutomaticCode = useCallback(async (priceId: string): Promise<boolean> => {
|
|
155
|
+
try {
|
|
156
|
+
const automaticCode = await StripeCouponCodeService.findAutomatic({ priceId });
|
|
157
|
+
dispatch({
|
|
158
|
+
type: "SET_PROMOTION_CODE",
|
|
159
|
+
code: automaticCode ? toPromotionCodeResult(automaticCode) : null,
|
|
160
|
+
});
|
|
161
|
+
return true;
|
|
162
|
+
} catch (error) {
|
|
163
|
+
console.error("[useSubscriptionWizard] Failed to look up the automatic promotion code:", error);
|
|
164
|
+
dispatch({ type: "SET_PROMOTION_CODE", code: null });
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
}, []);
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Re-reads the automatic code after the review's code changed. When the lookup
|
|
171
|
+
* fails, the review price cannot be trusted: back to plan selection.
|
|
172
|
+
*/
|
|
173
|
+
const refreshAutomaticCodeOrLeaveReview = useCallback(
|
|
174
|
+
async (priceId: string): Promise<void> => {
|
|
175
|
+
const found = await lookUpAutomaticCode(priceId);
|
|
176
|
+
if (!found) {
|
|
177
|
+
// SET_STEP clears the error, so the error goes second
|
|
178
|
+
dispatch({ type: "SET_STEP", step: "plan-selection" });
|
|
179
|
+
dispatch({ type: "SET_ERROR", error: t("billing.ui.wizard.errors.prepare_review") });
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
[lookUpAutomaticCode, t],
|
|
183
|
+
);
|
|
184
|
+
|
|
133
185
|
const goToReview = useCallback(async () => {
|
|
134
186
|
if (!state.selectedPrice) return;
|
|
135
187
|
|
|
@@ -156,6 +208,16 @@ export function useSubscriptionWizard({ subscription, onSuccess, onClose }: UseS
|
|
|
156
208
|
}
|
|
157
209
|
}
|
|
158
210
|
|
|
211
|
+
// Fill the automatic code for this price, unless the GM typed one
|
|
212
|
+
// A failed lookup never shows full price as if nothing applied: stay off the review
|
|
213
|
+
if (shouldLookUpAutomatic(state.promotionCode)) {
|
|
214
|
+
const found = await lookUpAutomaticCode(state.selectedPrice.id);
|
|
215
|
+
if (!found) {
|
|
216
|
+
dispatch({ type: "SET_ERROR", error: t("billing.ui.wizard.errors.prepare_review") });
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
159
221
|
// If editing subscription, get proration preview
|
|
160
222
|
if (subscription && state.selectedPrice.id !== subscription.price?.id) {
|
|
161
223
|
const preview = await StripeSubscriptionService.getProrationPreview({
|
|
@@ -172,7 +234,15 @@ export function useSubscriptionWizard({ subscription, onSuccess, onClose }: UseS
|
|
|
172
234
|
} finally {
|
|
173
235
|
dispatch({ type: "SET_PROCESSING", isProcessing: false });
|
|
174
236
|
}
|
|
175
|
-
}, [
|
|
237
|
+
}, [
|
|
238
|
+
state.selectedPrice,
|
|
239
|
+
state.hasPaymentMethod,
|
|
240
|
+
state.promotionCode,
|
|
241
|
+
subscription,
|
|
242
|
+
checkPaymentMethod,
|
|
243
|
+
lookUpAutomaticCode,
|
|
244
|
+
t,
|
|
245
|
+
]);
|
|
176
246
|
|
|
177
247
|
const confirmSubscription = useCallback(async () => {
|
|
178
248
|
if (!state.selectedPrice) return;
|
|
@@ -181,27 +251,71 @@ export function useSubscriptionWizard({ subscription, onSuccess, onClose }: UseS
|
|
|
181
251
|
dispatch({ type: "SET_ERROR", error: null });
|
|
182
252
|
|
|
183
253
|
try {
|
|
254
|
+
let result: StripeSubscriptionCreateResponse;
|
|
184
255
|
if (subscription) {
|
|
185
256
|
// Change existing subscription
|
|
186
257
|
const changePlanParams = {
|
|
187
258
|
id: subscription.id,
|
|
188
259
|
newPriceId: state.selectedPrice.id,
|
|
189
|
-
promotionCode: state.promotionCode
|
|
260
|
+
promotionCode: promotionCodeToSend(state.promotionCode),
|
|
190
261
|
};
|
|
191
|
-
await StripeSubscriptionService.
|
|
262
|
+
result = await StripeSubscriptionService.changePlanWithMeta(changePlanParams);
|
|
192
263
|
} else {
|
|
193
264
|
// Create new subscription
|
|
194
265
|
const createParams = {
|
|
195
266
|
id: v4(),
|
|
196
267
|
priceId: state.selectedPrice.id,
|
|
197
|
-
promotionCode: state.promotionCode
|
|
268
|
+
promotionCode: promotionCodeToSend(state.promotionCode),
|
|
198
269
|
};
|
|
199
|
-
await StripeSubscriptionService.createSubscription(createParams);
|
|
270
|
+
result = await StripeSubscriptionService.createSubscription(createParams);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// The first payment waits on the browser (confirmation, 3D Secure): until it
|
|
274
|
+
// is confirmed nothing is paid, so the wizard stays open on a failure
|
|
275
|
+
const clientSecret = clientSecretToConfirm(result.meta);
|
|
276
|
+
if (clientSecret) {
|
|
277
|
+
const confirmation = await confirmPayment(clientSecret);
|
|
278
|
+
if (!confirmation.success) {
|
|
279
|
+
console.error("[useSubscriptionWizard] Payment not confirmed:", confirmation.error);
|
|
280
|
+
dispatch({
|
|
281
|
+
type: "SET_ERROR",
|
|
282
|
+
error: t("billing.ui.wizard.errors.payment_not_confirmed", { reason: confirmation.error ?? "" }),
|
|
283
|
+
});
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Our copy is still incomplete until the webhooks land: store what Stripe has now,
|
|
288
|
+
// so the billing page shows the plan at once. A failed sync never blocks success.
|
|
289
|
+
const subscriptionId = subscriptionIdToSync(result.subscription, subscription);
|
|
290
|
+
if (subscriptionId) {
|
|
291
|
+
try {
|
|
292
|
+
await StripeSubscriptionService.syncSubscription({ subscriptionId });
|
|
293
|
+
} catch (syncError) {
|
|
294
|
+
console.error("[useSubscriptionWizard] Subscription sync after payment failed:", syncError);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
200
297
|
}
|
|
201
298
|
|
|
202
299
|
onSuccessRef.current();
|
|
203
300
|
onCloseRef.current();
|
|
204
301
|
} catch (error: any) {
|
|
302
|
+
// 410 discount_sold_out (the automatic discount's last place has just gone) or
|
|
303
|
+
// 422 discount_unavailable (the automatic code shown is no longer valid): nothing
|
|
304
|
+
// was charged. Never retry: drop the code and re-read the automatic one, so the
|
|
305
|
+
// review shows the price that would really be charged now.
|
|
306
|
+
const soldOut = isDiscountSoldOut(error);
|
|
307
|
+
if (soldOut || isDiscountUnavailable(error)) {
|
|
308
|
+
dispatch({
|
|
309
|
+
type: "SET_ERROR",
|
|
310
|
+
error: soldOut
|
|
311
|
+
? t("billing.ui.wizard.errors.discount_sold_out")
|
|
312
|
+
: t("billing.ui.wizard.errors.discount_unavailable"),
|
|
313
|
+
});
|
|
314
|
+
dispatch({ type: "SET_PROMOTION_CODE", code: null });
|
|
315
|
+
await refreshAutomaticCodeOrLeaveReview(state.selectedPrice.id);
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
205
319
|
// Handle 409 Conflict - duplicate recurring subscription
|
|
206
320
|
if (error?.status === 409 || error?.response?.status === 409) {
|
|
207
321
|
dispatch({
|
|
@@ -222,13 +336,14 @@ export function useSubscriptionWizard({ subscription, onSuccess, onClose }: UseS
|
|
|
222
336
|
} finally {
|
|
223
337
|
dispatch({ type: "SET_PROCESSING", isProcessing: false });
|
|
224
338
|
}
|
|
225
|
-
}, [state.selectedPrice, state.promotionCode, subscription, t]);
|
|
339
|
+
}, [state.selectedPrice, state.promotionCode, subscription, refreshAutomaticCodeOrLeaveReview, confirmPayment, t]);
|
|
226
340
|
|
|
227
341
|
const handlePaymentMethodSuccess = useCallback(async () => {
|
|
228
342
|
dispatch({ type: "SET_HAS_PAYMENT_METHOD", hasMethod: true });
|
|
229
|
-
//
|
|
230
|
-
|
|
231
|
-
|
|
343
|
+
// Prepare the review exactly as "Next: Review" does: the trial path sent the GM
|
|
344
|
+
// here before the automatic-code lookup and the proration preview had run
|
|
345
|
+
await goToReview();
|
|
346
|
+
}, [goToReview]);
|
|
232
347
|
|
|
233
348
|
const reset = useCallback(() => {
|
|
234
349
|
dispatch({ type: "RESET" });
|
|
@@ -267,11 +382,22 @@ export function useSubscriptionWizard({ subscription, onSuccess, onClose }: UseS
|
|
|
267
382
|
[state.selectedPrice?.id, t, locale],
|
|
268
383
|
);
|
|
269
384
|
|
|
270
|
-
const clearPromoCode = useCallback(() => {
|
|
385
|
+
const clearPromoCode = useCallback(async () => {
|
|
271
386
|
dispatch({ type: "SET_PROMOTION_CODE", code: null });
|
|
272
387
|
dispatch({ type: "SET_PROMO_CODE_ERROR", error: null });
|
|
273
388
|
dispatch({ type: "SET_ERROR", error: null });
|
|
274
|
-
|
|
389
|
+
|
|
390
|
+
if (!state.selectedPrice) return;
|
|
391
|
+
|
|
392
|
+
// Removing a typed code: the server attaches the automatic code again, so the
|
|
393
|
+
// review must show it again
|
|
394
|
+
dispatch({ type: "SET_PROCESSING", isProcessing: true });
|
|
395
|
+
try {
|
|
396
|
+
await refreshAutomaticCodeOrLeaveReview(state.selectedPrice.id);
|
|
397
|
+
} finally {
|
|
398
|
+
dispatch({ type: "SET_PROCESSING", isProcessing: false });
|
|
399
|
+
}
|
|
400
|
+
}, [state.selectedPrice, refreshAutomaticCodeOrLeaveReview]);
|
|
275
401
|
|
|
276
402
|
const actions = useMemo(
|
|
277
403
|
() => ({
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { StripeSubscriptionCreateMeta } from "../data/stripe-subscription.interface";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The client secret the wizard must confirm in the browser after a subscribe
|
|
5
|
+
* or a trial upgrade, or null when the payment needs nothing more.
|
|
6
|
+
*
|
|
7
|
+
* The API creates a subscription with `payment_behavior: "default_incomplete"`:
|
|
8
|
+
* its first PaymentIntent waits on the browser (`requires_confirmation`, or 3D
|
|
9
|
+
* Secure). Until it is confirmed nothing is paid and the subscription stays
|
|
10
|
+
* `incomplete`, so closing the wizard on the API answer alone is wrong.
|
|
11
|
+
*/
|
|
12
|
+
export function clientSecretToConfirm(meta: Partial<StripeSubscriptionCreateMeta> | null | undefined): string | null {
|
|
13
|
+
if (!meta?.requiresAction) return null;
|
|
14
|
+
return meta.clientSecret || null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The subscription to re-read from Stripe after the browser confirmed its first
|
|
19
|
+
* payment: the one the API just answered with (subscribe), else the one the wizard
|
|
20
|
+
* was opened on (trial upgrade). Null when there is none to sync.
|
|
21
|
+
*
|
|
22
|
+
* Right after the confirmation our copy is still `incomplete` (the webhooks land a
|
|
23
|
+
* second later), so the billing page would show no plan until a reload.
|
|
24
|
+
*/
|
|
25
|
+
export function subscriptionIdToSync(
|
|
26
|
+
answered: { id?: string } | null | undefined,
|
|
27
|
+
opened: { id?: string } | null | undefined,
|
|
28
|
+
): string | null {
|
|
29
|
+
return answered?.id || opened?.id || null;
|
|
30
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { PromotionCodeValidationResult } from "../../stripe-promotion-code/data/stripe-promotion-code.interface";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The promotion code the wizard sends to the API: the one the review shows,
|
|
5
|
+
* typed or automatic. Sending the automatic code too lets the server refuse
|
|
6
|
+
* the payment when that code is no longer valid, instead of charging a price
|
|
7
|
+
* the GM was not shown.
|
|
8
|
+
*/
|
|
9
|
+
export function promotionCodeToSend(code: PromotionCodeValidationResult | null): string | undefined {
|
|
10
|
+
if (!code) return undefined;
|
|
11
|
+
return code.promotionCodeId;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* True when the wizard should ask for the automatic code: no code yet, or the
|
|
16
|
+
* current one is automatic. A code the GM typed always wins.
|
|
17
|
+
*/
|
|
18
|
+
export function shouldLookUpAutomatic(code: PromotionCodeValidationResult | null): boolean {
|
|
19
|
+
return !code || code.automatic === true;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** `value[key]` when `value` is an object, else undefined. */
|
|
23
|
+
function read(value: unknown, key: string): unknown {
|
|
24
|
+
return typeof value === "object" && value !== null ? Reflect.get(value, key) : undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function errorStatus(error: unknown): number | undefined {
|
|
28
|
+
const status = read(error, "status") ?? read(read(error, "response"), "status");
|
|
29
|
+
return typeof status === "number" ? status : undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The `code` of an API error. The nestjs-neo4jsonapi `HttpExceptionFilter`
|
|
34
|
+
* answers with a JSON:API error document, which `AbstractService.callApi`
|
|
35
|
+
* attaches to the thrown error as `body`:
|
|
36
|
+
* `{ message, errors: [{ status, code, title, detail, meta: { context: { code } } }] }`.
|
|
37
|
+
*/
|
|
38
|
+
function errorCode(error: unknown): string | undefined {
|
|
39
|
+
const body = read(error, "body");
|
|
40
|
+
const errors = read(body, "errors");
|
|
41
|
+
const first = Array.isArray(errors) ? errors[0] : undefined;
|
|
42
|
+
const code = read(first, "code") ?? read(read(read(first, "meta"), "context"), "code") ?? read(body, "code");
|
|
43
|
+
return typeof code === "string" ? code : undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* True when the API refused the payment because the automatic discount has no
|
|
48
|
+
* places left (HTTP 410, code `discount_sold_out`). Nothing was charged.
|
|
49
|
+
*/
|
|
50
|
+
export function isDiscountSoldOut(error: unknown): boolean {
|
|
51
|
+
return errorStatus(error) === 410 && errorCode(error) === "discount_sold_out";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* True when the API refused the payment because the automatic code the review
|
|
56
|
+
* showed is no longer valid (HTTP 422, code `discount_unavailable`). Nothing was
|
|
57
|
+
* charged.
|
|
58
|
+
*/
|
|
59
|
+
export function isDiscountUnavailable(error: unknown): boolean {
|
|
60
|
+
return errorStatus(error) === 422 && errorCode(error) === "discount_unavailable";
|
|
61
|
+
}
|
|
@@ -225,6 +225,31 @@ export const CurrentUserProvider = ({ children }: { children: React.ReactNode })
|
|
|
225
225
|
// Track refresh in progress to prevent duplicate API calls
|
|
226
226
|
const isRefreshingRef = useRef(false);
|
|
227
227
|
|
|
228
|
+
// Revalidate the cached user each time the live connection comes up.
|
|
229
|
+
//
|
|
230
|
+
// The cache above is trusted on mount, and changes reach an open page through
|
|
231
|
+
// `company:subscription_updated`. A change made while no page was connected
|
|
232
|
+
// (the browser closed when a Stripe webhook ended the subscription) never
|
|
233
|
+
// arrives, so the cache kept the old company until a token refresh: the sidebar
|
|
234
|
+
// hid "Upgrade" for a company that no longer had a plan, across reloads. The
|
|
235
|
+
// moment the connection comes up (page load, or back from a network drop) is
|
|
236
|
+
// exactly when such an event may have been missed, so re-read the user once.
|
|
237
|
+
const wasConnectedRef = useRef(false);
|
|
238
|
+
useEffect(() => {
|
|
239
|
+
if (!isConnected) {
|
|
240
|
+
wasConnectedRef.current = false;
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
if (wasConnectedRef.current) return;
|
|
244
|
+
wasConnectedRef.current = true;
|
|
245
|
+
|
|
246
|
+
if (!getCookie("token") || isRefreshingRef.current) return;
|
|
247
|
+
isRefreshingRef.current = true;
|
|
248
|
+
refreshUserRef.current({ skipCookieUpdate: true }).finally(() => {
|
|
249
|
+
isRefreshingRef.current = false;
|
|
250
|
+
});
|
|
251
|
+
}, [isConnected]);
|
|
252
|
+
|
|
228
253
|
// Listen for company WebSocket events that trigger user refresh
|
|
229
254
|
useEffect(() => {
|
|
230
255
|
if (!socket || !isConnected || !currentUser?.company?.id) {
|
|
@@ -21,6 +21,8 @@ const permissionsMock = vi.hoisted(() => ({
|
|
|
21
21
|
const socketMock = vi.hoisted(() => {
|
|
22
22
|
const handlers: Record<string, ((data: any) => void)[]> = {};
|
|
23
23
|
return {
|
|
24
|
+
// Read on every render, so a test can drop and restore the live connection
|
|
25
|
+
isConnected: false,
|
|
24
26
|
socket: {
|
|
25
27
|
on: (event: string, handler: (data: any) => void) => {
|
|
26
28
|
(handlers[event] ??= []).push(handler);
|
|
@@ -39,7 +41,7 @@ const socketMock = vi.hoisted(() => {
|
|
|
39
41
|
});
|
|
40
42
|
|
|
41
43
|
vi.mock("../../../../contexts/SocketContext", () => ({
|
|
42
|
-
useSocketContext: () => ({ socket: socketMock.socket, isConnected:
|
|
44
|
+
useSocketContext: () => ({ socket: socketMock.socket, isConnected: socketMock.isConnected }),
|
|
43
45
|
}));
|
|
44
46
|
|
|
45
47
|
vi.mock("../../../../core", () => {
|
|
@@ -113,6 +115,8 @@ describe("CurrentUserProvider", () => {
|
|
|
113
115
|
beforeEach(() => {
|
|
114
116
|
localStorage.clear();
|
|
115
117
|
vi.clearAllMocks();
|
|
118
|
+
socketMock.reset();
|
|
119
|
+
socketMock.isConnected = false;
|
|
116
120
|
});
|
|
117
121
|
|
|
118
122
|
it("keeps user hydrated from localStorage when token cookie is absent", async () => {
|
|
@@ -161,7 +165,7 @@ describe("CurrentUserProvider", () => {
|
|
|
161
165
|
expect(updateToken).not.toHaveBeenCalled();
|
|
162
166
|
});
|
|
163
167
|
|
|
164
|
-
it("does not hit the API when localStorage already holds the user", async () => {
|
|
168
|
+
it("does not hit the API on mount when localStorage already holds the user and no live connection is up", async () => {
|
|
165
169
|
vi.mocked(getCookie).mockImplementation((name: any) => (name === "token" ? "jwt" : undefined));
|
|
166
170
|
localStorage.setItem("user", JSON.stringify({ id: "cached", roles: [], company: null, modules: [] }));
|
|
167
171
|
|
|
@@ -239,6 +243,117 @@ describe("CurrentUserProvider", () => {
|
|
|
239
243
|
});
|
|
240
244
|
});
|
|
241
245
|
|
|
246
|
+
describe("CurrentUserProvider live connection revalidation", () => {
|
|
247
|
+
const ACTIVE = { id: "user-1", roles: [], modules: [], company: { id: "company-1", isActiveSubscription: true } };
|
|
248
|
+
const ENDED = { id: "user-1", roles: [], modules: [], company: { id: "company-1", isActiveSubscription: false } };
|
|
249
|
+
|
|
250
|
+
function SubscriptionConsumer() {
|
|
251
|
+
const { company } = useCurrentUserContext();
|
|
252
|
+
return <div data-testid="active">{company ? String((company as any).isActiveSubscription) : "null"}</div>;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
beforeEach(() => {
|
|
256
|
+
localStorage.clear();
|
|
257
|
+
socketMock.reset();
|
|
258
|
+
vi.clearAllMocks();
|
|
259
|
+
vi.mocked(getCookie).mockImplementation((name: any) => (name === "token" ? "jwt" : undefined));
|
|
260
|
+
vi.mocked(UserService.findFullUser).mockResolvedValue({ ...ENDED, dehydrate: () => ENDED } as any);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("re-reads the user when the live connection comes up, so a change missed while no page was open shows", async () => {
|
|
264
|
+
// The subscription ended (Stripe webhook) while the browser was closed: the cache still says active
|
|
265
|
+
localStorage.setItem("user", JSON.stringify(ACTIVE));
|
|
266
|
+
socketMock.isConnected = true;
|
|
267
|
+
|
|
268
|
+
const { getByTestId } = render(
|
|
269
|
+
<CurrentUserProvider>
|
|
270
|
+
<SubscriptionConsumer />
|
|
271
|
+
</CurrentUserProvider>,
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
await act(async () => {
|
|
275
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
expect(UserService.findFullUser).toHaveBeenCalledTimes(1);
|
|
279
|
+
expect(getByTestId("active").textContent).toBe("false");
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("re-reads once per connection, not on every render", async () => {
|
|
283
|
+
localStorage.setItem("user", JSON.stringify(ACTIVE));
|
|
284
|
+
socketMock.isConnected = true;
|
|
285
|
+
|
|
286
|
+
const { rerender } = render(
|
|
287
|
+
<CurrentUserProvider>
|
|
288
|
+
<SubscriptionConsumer />
|
|
289
|
+
</CurrentUserProvider>,
|
|
290
|
+
);
|
|
291
|
+
await act(async () => {
|
|
292
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
293
|
+
});
|
|
294
|
+
rerender(
|
|
295
|
+
<CurrentUserProvider>
|
|
296
|
+
<SubscriptionConsumer />
|
|
297
|
+
</CurrentUserProvider>,
|
|
298
|
+
);
|
|
299
|
+
await act(async () => {
|
|
300
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
expect(UserService.findFullUser).toHaveBeenCalledTimes(1);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("re-reads again after the connection drops and comes back", async () => {
|
|
307
|
+
localStorage.setItem("user", JSON.stringify(ACTIVE));
|
|
308
|
+
socketMock.isConnected = true;
|
|
309
|
+
|
|
310
|
+
const tree = (
|
|
311
|
+
<CurrentUserProvider>
|
|
312
|
+
<SubscriptionConsumer />
|
|
313
|
+
</CurrentUserProvider>
|
|
314
|
+
);
|
|
315
|
+
const { rerender } = render(tree);
|
|
316
|
+
await act(async () => {
|
|
317
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
socketMock.isConnected = false;
|
|
321
|
+
rerender(
|
|
322
|
+
<CurrentUserProvider>
|
|
323
|
+
<SubscriptionConsumer />
|
|
324
|
+
</CurrentUserProvider>,
|
|
325
|
+
);
|
|
326
|
+
socketMock.isConnected = true;
|
|
327
|
+
rerender(
|
|
328
|
+
<CurrentUserProvider>
|
|
329
|
+
<SubscriptionConsumer />
|
|
330
|
+
</CurrentUserProvider>,
|
|
331
|
+
);
|
|
332
|
+
await act(async () => {
|
|
333
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
expect(UserService.findFullUser).toHaveBeenCalledTimes(2);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it("does not re-read without a session cookie", async () => {
|
|
340
|
+
vi.mocked(getCookie).mockReturnValue(undefined);
|
|
341
|
+
localStorage.setItem("user", JSON.stringify(ACTIVE));
|
|
342
|
+
socketMock.isConnected = true;
|
|
343
|
+
|
|
344
|
+
render(
|
|
345
|
+
<CurrentUserProvider>
|
|
346
|
+
<SubscriptionConsumer />
|
|
347
|
+
</CurrentUserProvider>,
|
|
348
|
+
);
|
|
349
|
+
await act(async () => {
|
|
350
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
expect(UserService.findFullUser).not.toHaveBeenCalled();
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
|
|
242
357
|
describe("CurrentUserProvider token updates", () => {
|
|
243
358
|
const STORED_USER = {
|
|
244
359
|
id: "user-1",
|
|
@@ -265,6 +380,9 @@ describe("CurrentUserProvider token updates", () => {
|
|
|
265
380
|
localStorage.clear();
|
|
266
381
|
socketMock.reset();
|
|
267
382
|
vi.clearAllMocks();
|
|
383
|
+
// Connected, with no session cookie: these tests are about socket events, not the reconnect re-read
|
|
384
|
+
socketMock.isConnected = true;
|
|
385
|
+
vi.mocked(getCookie).mockReturnValue(undefined);
|
|
268
386
|
});
|
|
269
387
|
|
|
270
388
|
it("patches balances from company:credits_updated without refetching the user", async () => {
|
|
@@ -352,6 +470,7 @@ describe("CurrentUserProvider path permissions", () => {
|
|
|
352
470
|
beforeEach(() => {
|
|
353
471
|
localStorage.clear();
|
|
354
472
|
vi.clearAllMocks();
|
|
473
|
+
socketMock.isConnected = false;
|
|
355
474
|
ctx = undefined;
|
|
356
475
|
permissionsMock.checkPermissions.mockImplementation(() => true);
|
|
357
476
|
});
|