@cosmicdrift/kumiko-bundled-features 0.387.0 → 0.388.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/agent-tools/tool-dispatch.d.ts +7 -7
- package/dist/agent-tools/tool-dispatch.js +9 -9
- package/dist/audit/handlers/audit-events.js +1 -1
- package/dist/auth-mfa/feature-exports.d.ts +2 -2
- package/dist/auth-mfa/feature.js +8 -0
- package/dist/auth-mfa/web/i18n.js +5 -1
- package/dist/auth-mfa/web/mfa-code-or-recovery-field.d.ts +11 -0
- package/dist/auth-mfa/web/mfa-code-or-recovery-field.js +12 -0
- package/dist/auth-mfa/web/mfa-disable-dialog.js +14 -3
- package/dist/auth-mfa/web/mfa-regenerate-recovery-dialog.js +14 -3
- package/dist/auth-mfa/web/mfa-setup-preauth-screen.js +10 -5
- package/dist/auth-mfa/web/mfa-verify-screen.js +26 -5
- package/dist/billing-foundation/checkout-core.d.ts +9 -14
- package/dist/billing-foundation/checkout-core.js +24 -39
- package/dist/billing-foundation/consumer-protection/confirmation-mail.js +8 -2
- package/dist/billing-foundation/consumer-protection/consent-gate.d.ts +1 -0
- package/dist/billing-foundation/consumer-protection/consent-gate.js +1 -0
- package/dist/billing-foundation/consumer-protection/issue-confirmation.js +9 -3
- package/dist/billing-foundation/consumer-protection/mail-notifications.js +3 -1
- package/dist/billing-foundation/consumer-protection/termination-receipt-limit.d.ts +4 -2
- package/dist/billing-foundation/consumer-protection/termination-receipt-limit.js +6 -5
- package/dist/billing-foundation/consumer-protection/termination-record.d.ts +5 -1
- package/dist/billing-foundation/consumer-protection/termination-record.js +27 -17
- package/dist/billing-foundation/consumer-protection/termination-request.js +12 -7
- package/dist/billing-foundation/consumer-protection/termination-revoke.js +12 -7
- package/dist/billing-foundation/consumer-protection/termination-texts.d.ts +1 -1
- package/dist/billing-foundation/consumer-protection/termination-texts.js +3 -0
- package/dist/billing-foundation/consumer-protection/withdrawal-period.d.ts +5 -2
- package/dist/billing-foundation/consumer-protection/withdrawal-period.js +8 -9
- package/dist/billing-foundation/events.d.ts +1 -0
- package/dist/billing-foundation/events.js +1 -0
- package/dist/billing-foundation/feature.js +3 -1
- package/dist/billing-foundation/handlers/billing-plans.query.js +35 -5
- package/dist/billing-foundation/handlers/create-checkout-session.write.js +1 -0
- package/dist/billing-foundation/handlers/plan-switch-preview.query.d.ts +13 -0
- package/dist/billing-foundation/handlers/plan-switch-preview.query.js +48 -0
- package/dist/billing-foundation/handlers/process-event.write.d.ts +6 -0
- package/dist/billing-foundation/handlers/process-event.write.js +8 -2
- package/dist/billing-foundation/handlers/start-plan-checkout.write.js +32 -5
- package/dist/billing-foundation/handlers/switch-plan.write.d.ts +2 -2
- package/dist/billing-foundation/handlers/switch-plan.write.js +56 -64
- package/dist/billing-foundation/handlers/sync-subscription.write.js +12 -1
- package/dist/billing-foundation/i18n.js +22 -0
- package/dist/billing-foundation/index.d.ts +1 -1
- package/dist/billing-foundation/plan-catalog.d.ts +21 -5
- package/dist/billing-foundation/plan-catalog.js +25 -16
- package/dist/billing-foundation/plan-switch.d.ts +12 -0
- package/dist/billing-foundation/plan-switch.js +60 -0
- package/dist/billing-foundation/types.d.ts +57 -1
- package/dist/billing-foundation/types.js +9 -0
- package/dist/billing-foundation/validate-options.js +17 -0
- package/dist/billing-foundation/web/billing-plans-panel.js +78 -32
- package/dist/billing-foundation/web/checkout-consent-dialog.d.ts +17 -3
- package/dist/billing-foundation/web/checkout-consent-dialog.js +14 -10
- package/dist/billing-foundation/web/constants.d.ts +4 -0
- package/dist/billing-foundation/web/constants.js +4 -0
- package/dist/billing-foundation/web/plan-price.d.ts +15 -0
- package/dist/billing-foundation/web/plan-price.js +32 -0
- package/dist/billing-foundation/web/plan-switch-confirm-dialog.d.ts +15 -0
- package/dist/billing-foundation/web/plan-switch-confirm-dialog.js +66 -0
- package/dist/cap-counter/cap-reservation.js +5 -1
- package/dist/cap-counter/enforce-cap.js +2 -1
- package/dist/cap-counter/stock-cap-guard.js +7 -1
- package/dist/channel-email/feature.js +1 -1
- package/dist/config/handlers/cascade.query.d.ts +5 -0
- package/dist/config/handlers/cascade.query.js +4 -3
- package/dist/config/handlers/values.query.d.ts +5 -0
- package/dist/config/handlers/values.query.js +4 -3
- package/dist/config/write-helpers.d.ts +1 -0
- package/dist/config/write-helpers.js +18 -9
- package/dist/crypto-shredding/handlers/forget-subject.write.js +13 -10
- package/dist/delivery/channel-context.js +6 -3
- package/dist/delivery/db/queries/preferences.js +2 -2
- package/dist/delivery/delivery-service.js +22 -7
- package/dist/delivery/handlers/preferences.query.d.ts +1 -1
- package/dist/delivery/handlers/preferences.query.js +20 -1
- package/dist/delivery/handlers/unsubscribe-page-chrome.query.js +1 -1
- package/dist/delivery/unsubscribe-link.d.ts +3 -1
- package/dist/delivery/unsubscribe-page-strings.d.ts +1 -0
- package/dist/delivery/unsubscribe-page-strings.js +1 -0
- package/dist/delivery/unsubscribe-page.d.ts +2 -0
- package/dist/delivery/unsubscribe-page.js +5 -0
- package/dist/delivery/unsubscribe.js +25 -7
- package/dist/file-foundation/index.d.ts +1 -1
- package/dist/file-foundation/index.js +1 -1
- package/dist/file-foundation/read-file-ref-content.d.ts +3 -0
- package/dist/file-foundation/read-file-ref-content.js +6 -1
- package/dist/file-provider-s3/env-named-feature.d.ts +0 -2
- package/dist/form-draft/db/queries/cleanup.js +2 -2
- package/dist/form-draft/decrypt-draft.js +5 -1
- package/dist/host-routing/dispatch.js +11 -20
- package/dist/inbound-mail-foundation/handlers/update-account.write.d.ts +2 -2
- package/dist/inbound-mail-foundation/handlers/update-account.write.js +12 -4
- package/dist/inbound-mail-foundation/i18n.js +0 -1
- package/dist/inbound-mail-foundation/screens.js +0 -3
- package/dist/inbound-mail-foundation/types.d.ts +4 -3
- package/dist/inbound-mail-foundation/watch-supervisor.js +28 -11
- package/dist/jobs/feature.d.ts +1 -0
- package/dist/jobs/feature.js +8 -4
- package/dist/jobs/handlers/tenant-failures.query.d.ts +1 -1
- package/dist/jobs/handlers/tenant-failures.query.js +45 -41
- package/dist/jobs/handlers/tenant-runs.query.d.ts +1 -1
- package/dist/jobs/handlers/tenant-runs.query.js +54 -50
- package/dist/jobs/job-run-logger.js +5 -4
- package/dist/mail-foundation/feature.d.ts +3 -3
- package/dist/mail-foundation/feature.js +3 -0
- package/dist/personal-access-tokens/feature.d.ts +1 -1
- package/dist/personal-access-tokens/feature.js +4 -0
- package/dist/renderer-pdf/render-table-pdf.d.ts +1 -1
- package/dist/renderer-pdf/render-table-pdf.js +22 -10
- package/dist/sessions/feature.d.ts +2 -2
- package/dist/sessions/feature.js +4 -0
- package/dist/shared/decrypt-stored-pii.d.ts +1 -0
- package/dist/shared/decrypt-stored-pii.js +6 -1
- package/dist/shared/index.d.ts +1 -1
- package/dist/shared/index.js +1 -1
- package/dist/shared/mail-template-body.d.ts +6 -2
- package/dist/shared/mail-template-body.js +7 -4
- package/dist/step-dispatcher/feature.js +1 -0
- package/dist/subscription-dodo/client.d.ts +17 -0
- package/dist/subscription-dodo/client.js +31 -0
- package/dist/subscription-dodo/constants.d.ts +12 -0
- package/dist/subscription-dodo/constants.js +19 -0
- package/dist/subscription-dodo/feature.d.ts +37 -0
- package/dist/subscription-dodo/feature.js +144 -0
- package/dist/subscription-dodo/index.d.ts +2 -0
- package/dist/subscription-dodo/index.js +4 -0
- package/dist/subscription-dodo/plugin-methods.d.ts +23 -0
- package/dist/subscription-dodo/plugin-methods.js +260 -0
- package/dist/subscription-dodo/runtime.d.ts +27 -0
- package/dist/subscription-dodo/runtime.js +89 -0
- package/dist/subscription-dodo/verify-webhook.d.ts +37 -0
- package/dist/subscription-dodo/verify-webhook.js +189 -0
- package/dist/subscription-stripe/feature.js +1 -0
- package/dist/subscription-stripe/runtime.js +7 -6
- package/dist/template-resolver/api.d.ts +3 -2
- package/dist/template-resolver/api.js +24 -2
- package/dist/template-resolver/compile-document-body.js +1 -0
- package/dist/template-resolver/feature.js +2 -2
- package/dist/template-resolver/handlers/bundle-import.js +3 -3
- package/dist/template-resolver/handlers/copy-locale.write.d.ts +5 -1
- package/dist/template-resolver/handlers/copy-locale.write.js +22 -7
- package/dist/template-resolver/handlers/diff-to-system.query.js +3 -2
- package/dist/template-resolver/handlers/document-base.js +3 -2
- package/dist/template-resolver/handlers/find-by-id.query.js +3 -2
- package/dist/template-resolver/handlers/list.query.js +5 -6
- package/dist/template-resolver/handlers/overview.query.d.ts +3 -0
- package/dist/template-resolver/handlers/overview.query.js +6 -3
- package/dist/template-resolver/handlers/tenant-override-counts.query.js +1 -3
- package/dist/template-resolver/handlers/tenant-override-selection.d.ts +2 -1
- package/dist/template-resolver/handlers/tenant-overrides.query.js +28 -5
- package/dist/template-resolver/handlers/usage.query.d.ts +2 -1
- package/dist/template-resolver/handlers/usage.query.js +26 -16
- package/dist/template-resolver/handlers/validate.query.js +7 -2
- package/dist/template-resolver/seeding.js +26 -10
- package/dist/template-resolver/store-template-resource.d.ts +1 -0
- package/dist/template-resolver/store-template-resource.js +19 -4
- package/dist/template-resolver/template-delivery-policy.d.ts +0 -5
- package/dist/template-resolver/template-delivery-policy.js +0 -6
- package/dist/template-resolver/template-meta.js +7 -4
- package/dist/template-resolver/template-preview.js +4 -3
- package/dist/template-resolver/web/constants.d.ts +2 -2
- package/dist/template-resolver/web/constants.js +3 -3
- package/dist/template-resolver/web/index.d.ts +1 -0
- package/dist/template-resolver/web/index.js +1 -0
- package/dist/template-resolver/web/mail-layout-constants.d.ts +5 -0
- package/dist/template-resolver/web/mail-layout-constants.js +7 -0
- package/dist/tenant/handlers/member-directory.query.d.ts +1 -0
- package/dist/tenant/handlers/member-directory.query.js +10 -3
- package/dist/tenant/handlers/tenant-directory.query.d.ts +1 -0
- package/dist/tenant/handlers/tenant-directory.query.js +8 -4
- package/dist/tenant-lifecycle/constants.d.ts +1 -0
- package/dist/tenant-lifecycle/constants.js +4 -0
- package/dist/tenant-lifecycle/run-tenant-destroy.js +2 -2
- package/dist/tier-engine/handlers/set-tenant-tier.write.d.ts +2 -3
- package/dist/tier-engine/handlers/set-tenant-tier.write.js +4 -1
- package/dist/tier-engine/web/constants.js +2 -2
- package/dist/user-data-rights/run-export-jobs.js +3 -4
- package/dist/user-data-rights/run-forget-cleanup.js +2 -1
- package/dist/waitlist/handlers/export-purge.write.js +2 -1
- package/dist/waitlist/handlers/submit.write.js +5 -5
- package/dist/waitlist/import.d.ts +1 -0
- package/dist/waitlist/import.js +4 -0
- package/dist/waitlist/legacy-migrate.js +17 -6
- package/dist/waitlist/link-on-signup.js +18 -6
- package/dist/waitlist/options.d.ts +3 -0
- package/package.json +19 -9
- package/src/agent-tools/changes.json +6 -0
- package/src/audit/changes.json +12 -0
- package/src/auth-mfa/changes.json +6 -0
- package/src/billing-foundation/changes.json +55 -0
- package/src/cap-counter/changes.json +18 -0
- package/src/channel-email/changes.json +6 -0
- package/src/config/changes.json +12 -0
- package/src/crypto-shredding/changes.json +6 -0
- package/src/delivery/changes.json +30 -0
- package/src/form-draft/changes.json +6 -0
- package/src/host-routing/changes.json +6 -0
- package/src/inbound-mail-foundation/changes.json +18 -0
- package/src/jobs/changes.json +13 -0
- package/src/mail-foundation/changes.json +6 -0
- package/src/renderer-pdf/changes.json +6 -0
- package/src/step-dispatcher/changes.json +6 -0
- package/src/subscription-dodo/changes.json +14 -0
- package/src/subscription-stripe/changes.json +12 -0
- package/src/template-resolver/changes.json +48 -0
- package/src/tenant/changes.json +6 -0
- package/src/tenant-lifecycle/changes.json +6 -0
- package/src/tier-engine/changes.json +6 -0
- package/src/waitlist/changes.json +18 -0
- package/dist/billing-foundation/consumer-protection/stored-user-email.d.ts +0 -1
- package/dist/billing-foundation/consumer-protection/stored-user-email.js +0 -8
|
@@ -7,21 +7,10 @@ import { BillingContactPlan } from "./billing-contact-plan.js";
|
|
|
7
7
|
import { CancelContractDialog } from "./cancel-contract-dialog.js";
|
|
8
8
|
import { CheckoutConsentDialog } from "./checkout-consent-dialog.js";
|
|
9
9
|
import { BillingPlanActions, SubscriptionFoundationHandlers, SubscriptionFoundationQueries, SubscriptionStatuses, } from "./constants.js";
|
|
10
|
+
import { resolveConsentLocale } from "./consumer-protection/consent-locale.js";
|
|
10
11
|
import { localizedParams } from "./localized-params.js";
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
return undefined;
|
|
14
|
-
return price.intervalCount !== null && price.intervalCount > 1
|
|
15
|
-
? `billing-foundation.plans.everyInterval.${price.interval}`
|
|
16
|
-
: `billing-foundation.plans.perInterval.${price.interval}`;
|
|
17
|
-
}
|
|
18
|
-
function renewalKey(price) {
|
|
19
|
-
if (price === null || price.interval === null)
|
|
20
|
-
return undefined;
|
|
21
|
-
return price.intervalCount !== null && price.intervalCount > 1
|
|
22
|
-
? `billing-foundation.consent.renewsEvery.${price.interval}`
|
|
23
|
-
: `billing-foundation.consent.renews.${price.interval}`;
|
|
24
|
-
}
|
|
12
|
+
import { cheapestPrice, intervalKey, pricesDiffer, renewalKey, tierPrices } from "./plan-price.js";
|
|
13
|
+
import { PlanSwitchConfirmDialog } from "./plan-switch-confirm-dialog.js";
|
|
25
14
|
/** Which action the plan "would" offer if it weren't blocked — mirrors
|
|
26
15
|
* plan-catalog.ts's own decision tree, since `unavailable` collapses both
|
|
27
16
|
* "would checkout" and "would switch" into one action and the disabled CTA
|
|
@@ -86,8 +75,20 @@ export function BillingPlansPanel(_props) {
|
|
|
86
75
|
const [redirecting, setRedirecting] = useState(false);
|
|
87
76
|
const [consentTier, setConsentTier] = useState(null);
|
|
88
77
|
const [cancelOpen, setCancelOpen] = useState(false);
|
|
78
|
+
const [chosenProvider, setChosenProvider] = useState(null);
|
|
79
|
+
const [switchTier, setSwitchTier] = useState(null);
|
|
80
|
+
const [switchRequested, setSwitchRequested] = useState(false);
|
|
81
|
+
// Only present when the tenant can choose (several offered providers) or
|
|
82
|
+
// the single offered provider has a note; absent means the server decides.
|
|
83
|
+
const providers = query.data?.providers;
|
|
84
|
+
const activeProvider = providers?.find((p) => p.name === chosenProvider) ?? providers?.[0];
|
|
85
|
+
const providerArg = activeProvider ? { providerName: activeProvider.name } : {};
|
|
89
86
|
async function orderWithConsent(tier, consent) {
|
|
90
|
-
const result = await checkoutMutation.mutate({
|
|
87
|
+
const result = await checkoutMutation.mutate({
|
|
88
|
+
tier,
|
|
89
|
+
...(consent !== undefined && { consent }),
|
|
90
|
+
...providerArg,
|
|
91
|
+
});
|
|
91
92
|
if (result.isSuccess) {
|
|
92
93
|
setRedirecting(true);
|
|
93
94
|
window.location.assign(result.data.url);
|
|
@@ -95,13 +96,20 @@ export function BillingPlansPanel(_props) {
|
|
|
95
96
|
return result;
|
|
96
97
|
}
|
|
97
98
|
async function handleAction(tier, kind) {
|
|
98
|
-
if (kind === "checkout" &&
|
|
99
|
+
if (kind === "checkout" &&
|
|
100
|
+
(query.data?.consumerProtection !== undefined || (providers?.length ?? 0) > 1)) {
|
|
101
|
+
setChosenProvider(null);
|
|
99
102
|
setConsentTier(tier);
|
|
100
103
|
return;
|
|
101
104
|
}
|
|
105
|
+
if (kind === "switch" && query.data?.inAppPlanSwitch) {
|
|
106
|
+
setSwitchTier(tier);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
102
109
|
setRedirecting(true);
|
|
103
|
-
const
|
|
104
|
-
|
|
110
|
+
const result = kind === "checkout"
|
|
111
|
+
? await checkoutMutation.mutate({ tier, ...providerArg })
|
|
112
|
+
: await switchMutation.mutate({ tier });
|
|
105
113
|
if (result.isSuccess) {
|
|
106
114
|
window.location.assign(result.data.url);
|
|
107
115
|
}
|
|
@@ -128,13 +136,19 @@ export function BillingPlansPanel(_props) {
|
|
|
128
136
|
if (!query.data)
|
|
129
137
|
return null;
|
|
130
138
|
const result = query.data;
|
|
139
|
+
// Cards show the first provider's price; the dialog then follows the chosen one.
|
|
140
|
+
const shownPlans = providers?.[0]?.plans ?? result.plans;
|
|
141
|
+
const dialogPlans = activeProvider?.plans ?? result.plans;
|
|
131
142
|
// The consent dialog shows the checkout error itself.
|
|
132
143
|
const mutationError = (consentTier === null ? checkoutMutation.error : null) ??
|
|
133
144
|
switchMutation.error ??
|
|
134
145
|
portalMutation.error;
|
|
135
146
|
const canManage = result.subscription !== null && !result.subscription.terminal && result.canPurchase;
|
|
136
147
|
function toCardProps(plan) {
|
|
137
|
-
const
|
|
148
|
+
const comparable = providers !== undefined && providers.length > 1 ? tierPrices(providers, plan.tier) : [];
|
|
149
|
+
const fromPrice = pricesDiffer(comparable) ? cheapestPrice(comparable) : undefined;
|
|
150
|
+
const shownPrice = fromPrice ?? plan.price;
|
|
151
|
+
const key = intervalKey(shownPrice);
|
|
138
152
|
return {
|
|
139
153
|
title: t(plan.labelKey),
|
|
140
154
|
description: plan.action === BillingPlanActions.paymentPending
|
|
@@ -147,13 +161,14 @@ export function BillingPlansPanel(_props) {
|
|
|
147
161
|
// provider lookup failure.
|
|
148
162
|
price: !result.enabled && plan.isCurrent
|
|
149
163
|
? undefined
|
|
150
|
-
:
|
|
164
|
+
: shownPrice === null
|
|
151
165
|
? null
|
|
152
166
|
: {
|
|
153
|
-
amount: formatMoney(
|
|
167
|
+
amount: formatMoney(shownPrice.unitAmount, shownPrice.currency, locale),
|
|
168
|
+
...(fromPrice !== undefined && { prefix: t("billing-foundation.plans.priceFrom") }),
|
|
154
169
|
...(key !== undefined && {
|
|
155
170
|
period: t(key, {
|
|
156
|
-
count: new Intl.NumberFormat(locale).format(
|
|
171
|
+
count: new Intl.NumberFormat(locale).format(shownPrice.intervalCount ?? 1),
|
|
157
172
|
}),
|
|
158
173
|
}),
|
|
159
174
|
},
|
|
@@ -168,26 +183,57 @@ export function BillingPlansPanel(_props) {
|
|
|
168
183
|
// (a legacy/free/pilot tier outside `catalog.plans`) — when none of the
|
|
169
184
|
// rendered plans is the current one, a synthetic current-tier card (no
|
|
170
185
|
// price, no cta) keeps it visible regardless of enabled/disabled.
|
|
171
|
-
const consentPlan = consentTier !== null ?
|
|
186
|
+
const consentPlan = consentTier !== null ? dialogPlans.find((plan) => plan.tier === consentTier) : undefined;
|
|
187
|
+
const switchPlan = switchTier !== null ? shownPlans.find((plan) => plan.tier === switchTier) : undefined;
|
|
172
188
|
const consumerProtection = result.consumerProtection;
|
|
173
|
-
const
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
189
|
+
const noteLocale = resolveConsentLocale(locale);
|
|
190
|
+
const noteOf = (provider) => provider.note?.[noteLocale] ?? provider.note?.["en"];
|
|
191
|
+
const singleProviderNote = providers !== undefined && providers.length === 1 && providers[0] !== undefined
|
|
192
|
+
? noteOf(providers[0])
|
|
193
|
+
: undefined;
|
|
194
|
+
function providerChoiceFor(tier) {
|
|
195
|
+
if (providers === undefined || providers.length < 2 || activeProvider === undefined) {
|
|
196
|
+
return undefined;
|
|
197
|
+
}
|
|
198
|
+
const prices = tierPrices(providers, tier);
|
|
199
|
+
const showPrices = pricesDiffer(prices);
|
|
200
|
+
return {
|
|
201
|
+
value: activeProvider.name,
|
|
202
|
+
onChange: setChosenProvider,
|
|
203
|
+
options: providers.map((provider) => {
|
|
204
|
+
const price = provider.plans.find((plan) => plan.tier === tier)?.price;
|
|
205
|
+
const note = noteOf(provider);
|
|
206
|
+
return {
|
|
207
|
+
value: provider.name,
|
|
208
|
+
title: provider.displayName,
|
|
209
|
+
...(note !== undefined && { description: note }),
|
|
210
|
+
...(showPrices &&
|
|
211
|
+
price && { aside: formatMoney(price.unitAmount, price.currency, locale) }),
|
|
212
|
+
};
|
|
213
|
+
}),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
const currentTierInPlans = shownPlans.some((plan) => plan.isCurrent);
|
|
217
|
+
const visiblePlans = result.enabled ? shownPlans : shownPlans.filter((plan) => plan.isCurrent);
|
|
218
|
+
return (_jsxs("div", { className: "flex flex-col gap-4", "data-testid": "billing-plans-panel", children: [!result.enabled && (_jsx(Banner, { variant: "info", testId: "billing-plans-panel-disabled", children: t("billing-foundation.plans.billingDisabled") })), !result.canPurchase && (_jsx(Banner, { variant: "warning", testId: "billing-plans-panel-readonly", children: t("billing-foundation.plans.purchaseNotAllowed") })), switchRequested && (_jsx(Banner, { variant: "info", testId: "billing-plans-panel-switch-requested", children: t("billing-foundation.plans.planSwitchRequested") })), mutationError !== null && (_jsx(Banner, { variant: "error", testId: "billing-plans-panel-mutation-error", children: t(mutationError.i18nKey, mutationError.i18nParams) })), result.subscription?.status === SubscriptionStatuses.pastDue && (_jsx(Banner, { variant: "warning", testId: "billing-plans-panel-past-due", children: t("billing-foundation.plans.pastDue") })), result.subscription !== null &&
|
|
178
219
|
!result.subscription.terminal &&
|
|
179
220
|
result.subscription.cancelAt != null && (_jsxs(Banner, { variant: "info", testId: "billing-plans-panel-cancel-scheduled", children: [t("billing-foundation.plans.cancelScheduled", {
|
|
180
221
|
date: toInstant(result.subscription.cancelAt).toLocaleString(locale, {
|
|
181
222
|
dateStyle: "medium",
|
|
182
223
|
}),
|
|
183
|
-
}), " ", t("billing-foundation.plans.switchRequiresReactivation")] })), consumerProtection !== undefined && canManage && result.subscription?.cancelAt == null && (_jsx("div", { children: _jsx(Button, { variant: "secondary", onClick: () => setCancelOpen(true), testId: "billing-plans-cancel-contract", children: t("billing-foundation.cancel.open") }) })), _jsxs(PlanGrid, { testId: "billing-plans-grid", children: [!currentTierInPlans && (_jsx(PlanCard, { title: t(result.currentTier.labelKey), current: true, testId: "billing-plan-card-current", features: result.currentTier.benefits.map((benefit) => t(benefit.labelKey, localizedParams(benefit.params, locale))), secondaryAction: manageOrReactivateAction(canManage, result.subscription, t, handleManage, redirecting) })), visiblePlans.map((plan) => (_jsx(PlanCard, { testId: `billing-plan-card-${plan.tier}`, ...toCardProps(plan) }, plan.tier))), result.contact !== undefined && _jsx(BillingContactPlan, { contact: result.contact })] }),
|
|
184
|
-
consentPlan !== undefined &&
|
|
185
|
-
consentPlan.price !== null && (_jsx(CheckoutConsentDialog, { open: true, onOpenChange: (open) => {
|
|
224
|
+
}), " ", t("billing-foundation.plans.switchRequiresReactivation")] })), consumerProtection !== undefined && canManage && result.subscription?.cancelAt == null && (_jsx("div", { children: _jsx(Button, { variant: "secondary", onClick: () => setCancelOpen(true), testId: "billing-plans-cancel-contract", children: t("billing-foundation.cancel.open") }) })), _jsxs(PlanGrid, { testId: "billing-plans-grid", children: [!currentTierInPlans && (_jsx(PlanCard, { title: t(result.currentTier.labelKey), current: true, testId: "billing-plan-card-current", features: result.currentTier.benefits.map((benefit) => t(benefit.labelKey, localizedParams(benefit.params, locale))), secondaryAction: manageOrReactivateAction(canManage, result.subscription, t, handleManage, redirecting) })), visiblePlans.map((plan) => (_jsx(PlanCard, { testId: `billing-plan-card-${plan.tier}`, ...toCardProps(plan) }, plan.tier))), result.contact !== undefined && _jsx(BillingContactPlan, { contact: result.contact })] }), consentPlan !== undefined && consentPlan.price !== null && (_jsx(CheckoutConsentDialog, { open: true, onOpenChange: (open) => {
|
|
186
225
|
if (!open)
|
|
187
226
|
setConsentTier(null);
|
|
188
227
|
}, planName: t(consentPlan.labelKey), price: {
|
|
189
228
|
amount: formatMoney(consentPlan.price.unitAmount, consentPlan.price.currency, locale),
|
|
190
229
|
renewalKey: renewalKey(consentPlan.price),
|
|
191
230
|
count: new Intl.NumberFormat(locale).format(consentPlan.price.intervalCount ?? 1),
|
|
192
|
-
}, consumerProtection: consumerProtection, onOrder: (consent) => orderWithConsent(consentPlan.tier, consent), onConsentTextOutdated: () => void query.refetch() })),
|
|
231
|
+
}, consumerProtection: consumerProtection, providerChoice: providerChoiceFor(consentPlan.tier), note: singleProviderNote, onOrder: (consent) => orderWithConsent(consentPlan.tier, consent), onConsentTextOutdated: () => void query.refetch() })), switchPlan !== undefined && (_jsx(PlanSwitchConfirmDialog, { open: true, onOpenChange: (open) => {
|
|
232
|
+
if (!open)
|
|
233
|
+
setSwitchTier(null);
|
|
234
|
+
}, tier: switchPlan.tier, fromPlanName: t(result.currentTier.labelKey), toPlanName: t(switchPlan.labelKey), consumerProtection: consumerProtection, onApplied: () => {
|
|
235
|
+
setSwitchTier(null);
|
|
236
|
+
setSwitchRequested(true);
|
|
237
|
+
void query.refetch();
|
|
238
|
+
}, onConsentTextOutdated: () => void query.refetch() })), consumerProtection !== undefined && canManage && (_jsx(CancelContractDialog, { open: cancelOpen, onOpenChange: setCancelOpen, onTerminated: () => void query.refetch() }))] }));
|
|
193
239
|
}
|
|
@@ -18,11 +18,25 @@ export type CheckoutConsentDialogProps = {
|
|
|
18
18
|
readonly renewalKey?: string;
|
|
19
19
|
readonly count?: string;
|
|
20
20
|
};
|
|
21
|
-
|
|
22
|
-
readonly
|
|
21
|
+
/** Without it the dialog shows no legal links and no consent checkboxes and orders without a consent. */
|
|
22
|
+
readonly consumerProtection?: ConsumerProtectionView;
|
|
23
|
+
/** Several providers to pick from; the parent follows `value` for price and order. */
|
|
24
|
+
readonly providerChoice?: {
|
|
25
|
+
readonly options: readonly {
|
|
26
|
+
readonly value: string;
|
|
27
|
+
readonly title: string;
|
|
28
|
+
readonly description?: string;
|
|
29
|
+
readonly aside?: string;
|
|
30
|
+
}[];
|
|
31
|
+
readonly value: string;
|
|
32
|
+
readonly onChange: (value: string) => void;
|
|
33
|
+
};
|
|
34
|
+
/** Provider note under the price line when there is no provider choice. */
|
|
35
|
+
readonly note?: string;
|
|
36
|
+
readonly onOrder: (consent: CheckoutConsentPayload | undefined) => Promise<WriteResult<{
|
|
23
37
|
url: string;
|
|
24
38
|
}>>;
|
|
25
39
|
readonly onConsentTextOutdated: () => void;
|
|
26
40
|
};
|
|
27
|
-
export declare function CheckoutConsentDialog({ open, onOpenChange, planName, price, consumerProtection, onOrder, onConsentTextOutdated, }: CheckoutConsentDialogProps): ReactNode;
|
|
41
|
+
export declare function CheckoutConsentDialog({ open, onOpenChange, planName, price, consumerProtection, providerChoice, note, onOrder, onConsentTextOutdated, }: CheckoutConsentDialogProps): ReactNode;
|
|
28
42
|
export {};
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useLocale, usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
3
|
+
import { ChoiceCards } from "@cosmicdrift/kumiko-renderer/web";
|
|
3
4
|
import { useState } from "react";
|
|
4
5
|
import { resolveConsentLocale } from "./consumer-protection/consent-locale.js";
|
|
5
6
|
export const CONSENT_TEXT_OUTDATED_CODE = "consent_text_outdated";
|
|
6
7
|
// kumiko-lint-ignore no-custom-primitives Uses usePrimitives().Modal internally, name is a billing domain action, not a primitive reimplementation
|
|
7
|
-
export function CheckoutConsentDialog({ open, onOpenChange, planName, price, consumerProtection, onOrder, onConsentTextOutdated, }) {
|
|
8
|
+
export function CheckoutConsentDialog({ open, onOpenChange, planName, price, consumerProtection, providerChoice, note, onOrder, onConsentTextOutdated, }) {
|
|
8
9
|
const t = useTranslation();
|
|
9
10
|
const uiLocale = useLocale().locale();
|
|
10
11
|
const { Modal, Text, Field, Input, Button, Link, Banner } = usePrimitives();
|
|
@@ -13,17 +14,20 @@ export function CheckoutConsentDialog({ open, onOpenChange, planName, price, con
|
|
|
13
14
|
const [submitting, setSubmitting] = useState(false);
|
|
14
15
|
const [errorText, setErrorText] = useState(null);
|
|
15
16
|
const consentLocale = resolveConsentLocale(uiLocale);
|
|
16
|
-
const texts = consumerProtection
|
|
17
|
-
const legalLinks = consumerProtection
|
|
17
|
+
const texts = consumerProtection?.consentTexts[consentLocale];
|
|
18
|
+
const legalLinks = consumerProtection?.legalLinks[consentLocale];
|
|
19
|
+
const consentGiven = consumerProtection === undefined || (earlyPerformance && withdrawalLoss);
|
|
18
20
|
async function order() {
|
|
19
21
|
setSubmitting(true);
|
|
20
22
|
setErrorText(null);
|
|
21
|
-
const result = await onOrder(
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
const result = await onOrder(texts === undefined
|
|
24
|
+
? undefined
|
|
25
|
+
: {
|
|
26
|
+
earlyPerformanceRequested: true,
|
|
27
|
+
withdrawalLossAcknowledged: true,
|
|
28
|
+
consentTextVersion: texts.consentTextVersion,
|
|
29
|
+
locale: consentLocale,
|
|
30
|
+
});
|
|
27
31
|
if (result.isSuccess)
|
|
28
32
|
return;
|
|
29
33
|
setSubmitting(false);
|
|
@@ -34,5 +38,5 @@ export function CheckoutConsentDialog({ open, onOpenChange, planName, price, con
|
|
|
34
38
|
onConsentTextOutdated();
|
|
35
39
|
}
|
|
36
40
|
}
|
|
37
|
-
return (_jsx(Modal, { open: open, onOpenChange: onOpenChange, title: t("billing-foundation.consent.title"), testId: "checkout-consent-dialog", children: _jsxs("div", { className: "flex flex-col gap-4", children: [_jsxs("div", { className: "flex flex-col gap-1", "data-testid": "checkout-consent-summary", children: [_jsxs(Text, { children: [planName, " \u2014 ", price.amount] }), price.renewalKey !== undefined && (_jsxs(_Fragment, { children: [_jsx(Text, { variant: "muted", children: t(price.renewalKey, price.count !== undefined ? { count: price.count } : undefined) }), _jsx(Text, { variant: "muted", children: t("billing-foundation.consent.cancelAnytime") })] }))] }), _jsxs("div", { className: "flex flex-wrap gap-4", children: [_jsx(Link, { href: legalLinks.terms, target: "_blank", children: t("billing-foundation.consent.link.terms") }), _jsx(Link, { href: legalLinks.withdrawal, target: "_blank", children: t("billing-foundation.consent.link.withdrawal") }), _jsx(Link, { href: legalLinks.privacy, target: "_blank", children: t("billing-foundation.consent.link.privacy") })] }), _jsx(Field, { id: "consent-early-performance", label: texts.earlyPerformance, layout: "inline", children: _jsx(Input, { kind: "boolean", id: "consent-early-performance", name: "consent-early-performance", value: earlyPerformance, onChange: setEarlyPerformance, disabled: submitting }) }), _jsx(Field, { id: "consent-withdrawal-loss", label: texts.withdrawalLoss, layout: "inline", children: _jsx(Input, { kind: "boolean", id: "consent-withdrawal-loss", name: "consent-withdrawal-loss", value: withdrawalLoss, onChange: setWithdrawalLoss, disabled: submitting }) }), errorText !== null && (_jsx(Banner, { variant: "error", testId: "checkout-consent-error", children: errorText })), _jsxs("div", { className: "flex justify-end gap-2", children: [_jsx(Button, { variant: "secondary", disabled: submitting, onClick: () => onOpenChange(false), testId: "checkout-consent-cancel", children: t("billing-foundation.consent.back") }), _jsx(Button, { disabled: !
|
|
41
|
+
return (_jsx(Modal, { open: open, onOpenChange: onOpenChange, title: t("billing-foundation.consent.title"), testId: "checkout-consent-dialog", children: _jsxs("div", { className: "flex flex-col gap-4", children: [providerChoice !== undefined && (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Text, { variant: "muted", children: t("billing-foundation.consent.chooseProvider") }), _jsx(ChoiceCards, { value: providerChoice.value, options: providerChoice.options, onChange: providerChoice.onChange, ariaLabel: t("billing-foundation.consent.chooseProvider"), testId: "checkout-provider-choice" })] })), _jsxs("div", { className: "flex flex-col gap-1", "data-testid": "checkout-consent-summary", children: [_jsxs(Text, { children: [planName, " \u2014 ", price.amount] }), price.renewalKey !== undefined && (_jsxs(_Fragment, { children: [_jsx(Text, { variant: "muted", children: t(price.renewalKey, price.count !== undefined ? { count: price.count } : undefined) }), _jsx(Text, { variant: "muted", children: t("billing-foundation.consent.cancelAnytime") })] })), note !== undefined && (_jsx(Text, { variant: "muted", testId: "checkout-consent-note", children: note }))] }), legalLinks !== undefined && (_jsxs("div", { className: "flex flex-wrap gap-4", children: [_jsx(Link, { href: legalLinks.terms, target: "_blank", children: t("billing-foundation.consent.link.terms") }), _jsx(Link, { href: legalLinks.withdrawal, target: "_blank", children: t("billing-foundation.consent.link.withdrawal") }), _jsx(Link, { href: legalLinks.privacy, target: "_blank", children: t("billing-foundation.consent.link.privacy") })] })), texts !== undefined && (_jsxs(_Fragment, { children: [_jsx(Field, { id: "consent-early-performance", label: texts.earlyPerformance, layout: "inline", children: _jsx(Input, { kind: "boolean", id: "consent-early-performance", name: "consent-early-performance", value: earlyPerformance, onChange: setEarlyPerformance, disabled: submitting }) }), _jsx(Field, { id: "consent-withdrawal-loss", label: texts.withdrawalLoss, layout: "inline", children: _jsx(Input, { kind: "boolean", id: "consent-withdrawal-loss", name: "consent-withdrawal-loss", value: withdrawalLoss, onChange: setWithdrawalLoss, disabled: submitting }) })] })), errorText !== null && (_jsx(Banner, { variant: "error", testId: "checkout-consent-error", children: errorText })), _jsxs("div", { className: "flex justify-end gap-2", children: [_jsx(Button, { variant: "secondary", disabled: submitting, onClick: () => onOpenChange(false), testId: "checkout-consent-cancel", children: t("billing-foundation.consent.back") }), _jsx(Button, { disabled: !consentGiven || submitting, loading: submitting, onClick: order, testId: "checkout-consent-order", children: t("billing-foundation.consent.order") })] })] }) }));
|
|
38
42
|
}
|
|
@@ -78,6 +78,10 @@ export declare const SubscriptionFoundationQueries: {
|
|
|
78
78
|
* current tier and per-plan action. Only registered when a `catalog` is
|
|
79
79
|
* configured. */
|
|
80
80
|
readonly billingPlans: "billing-foundation:query:billing-plans";
|
|
81
|
+
/** What switching to a tier would cost right now, for providers that switch
|
|
82
|
+
* plans in-app (preview + apply). Only registered when a `catalog` is
|
|
83
|
+
* configured. */
|
|
84
|
+
readonly planSwitchPreview: "billing-foundation:query:plan-switch-preview";
|
|
81
85
|
/** System-only probe: does the caller tenant hold a non-terminal
|
|
82
86
|
* subscription (consumerProtection only). */
|
|
83
87
|
readonly terminableSubscription: "billing-foundation:query:terminable-subscription";
|
|
@@ -84,6 +84,10 @@ export const SubscriptionFoundationQueries = {
|
|
|
84
84
|
* current tier and per-plan action. Only registered when a `catalog` is
|
|
85
85
|
* configured. */
|
|
86
86
|
billingPlans: "billing-foundation:query:billing-plans",
|
|
87
|
+
/** What switching to a tier would cost right now, for providers that switch
|
|
88
|
+
* plans in-app (preview + apply). Only registered when a `catalog` is
|
|
89
|
+
* configured. */
|
|
90
|
+
planSwitchPreview: "billing-foundation:query:plan-switch-preview",
|
|
87
91
|
/** System-only probe: does the caller tenant hold a non-terminal
|
|
88
92
|
* subscription (consumerProtection only). */
|
|
89
93
|
terminableSubscription: "billing-foundation:query:terminable-subscription",
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { BillingPlanView } from "../types.ts";
|
|
2
|
+
type Recurrence = {
|
|
3
|
+
readonly interval: string | null;
|
|
4
|
+
readonly intervalCount: number | null;
|
|
5
|
+
};
|
|
6
|
+
export declare function intervalKey(price: Recurrence | null): string | undefined;
|
|
7
|
+
export declare function renewalKey(price: Recurrence | null): string | undefined;
|
|
8
|
+
type PlanPrice = NonNullable<BillingPlanView["price"]>;
|
|
9
|
+
export declare function pricesDiffer(prices: readonly PlanPrice[]): boolean;
|
|
10
|
+
/** Price of `tier` at every provider that offers it, in provider order. */
|
|
11
|
+
export declare function tierPrices(providers: readonly {
|
|
12
|
+
readonly plans: readonly BillingPlanView[];
|
|
13
|
+
}[], tier: string): PlanPrice[];
|
|
14
|
+
export declare function cheapestPrice(prices: readonly PlanPrice[]): PlanPrice | undefined;
|
|
15
|
+
export {};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export function intervalKey(price) {
|
|
2
|
+
if (price === null || price.interval === null)
|
|
3
|
+
return undefined;
|
|
4
|
+
return price.intervalCount !== null && price.intervalCount > 1
|
|
5
|
+
? `billing-foundation.plans.everyInterval.${price.interval}`
|
|
6
|
+
: `billing-foundation.plans.perInterval.${price.interval}`;
|
|
7
|
+
}
|
|
8
|
+
export function renewalKey(price) {
|
|
9
|
+
if (price === null || price.interval === null)
|
|
10
|
+
return undefined;
|
|
11
|
+
return price.intervalCount !== null && price.intervalCount > 1
|
|
12
|
+
? `billing-foundation.consent.renewsEvery.${price.interval}`
|
|
13
|
+
: `billing-foundation.consent.renews.${price.interval}`;
|
|
14
|
+
}
|
|
15
|
+
export function pricesDiffer(prices) {
|
|
16
|
+
const [first, ...rest] = prices;
|
|
17
|
+
return (first !== undefined &&
|
|
18
|
+
rest.some((price) => price.unitAmount !== first.unitAmount ||
|
|
19
|
+
price.currency !== first.currency ||
|
|
20
|
+
price.interval !== first.interval ||
|
|
21
|
+
price.intervalCount !== first.intervalCount));
|
|
22
|
+
}
|
|
23
|
+
/** Price of `tier` at every provider that offers it, in provider order. */
|
|
24
|
+
export function tierPrices(providers, tier) {
|
|
25
|
+
return providers.flatMap((provider) => {
|
|
26
|
+
const price = provider.plans.find((plan) => plan.tier === tier)?.price;
|
|
27
|
+
return price ? [price] : [];
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
export function cheapestPrice(prices) {
|
|
31
|
+
return prices.reduce((lowest, price) => lowest === undefined || price.unitAmount < lowest.unitAmount ? price : lowest, undefined);
|
|
32
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import type { BillingPlansResult } from "../types.ts";
|
|
3
|
+
type ConsumerProtectionView = NonNullable<BillingPlansResult["consumerProtection"]>;
|
|
4
|
+
export type PlanSwitchConfirmDialogProps = {
|
|
5
|
+
readonly open: boolean;
|
|
6
|
+
readonly onOpenChange: (open: boolean) => void;
|
|
7
|
+
readonly tier: string;
|
|
8
|
+
readonly fromPlanName: string;
|
|
9
|
+
readonly toPlanName: string;
|
|
10
|
+
readonly consumerProtection?: ConsumerProtectionView;
|
|
11
|
+
readonly onApplied: () => void;
|
|
12
|
+
readonly onConsentTextOutdated: () => void;
|
|
13
|
+
};
|
|
14
|
+
export declare function PlanSwitchConfirmDialog({ open, onOpenChange, tier, fromPlanName, toPlanName, consumerProtection, onApplied, onConsentTextOutdated, }: PlanSwitchConfirmDialogProps): ReactNode;
|
|
15
|
+
export {};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useLocale, useMutation, usePrimitives, useQuery, useTranslation, } from "@cosmicdrift/kumiko-renderer";
|
|
3
|
+
import { formatMoney } from "@cosmicdrift/kumiko-renderer/web";
|
|
4
|
+
import { useState } from "react";
|
|
5
|
+
import { CONSENT_TEXT_OUTDATED_CODE, } from "./checkout-consent-dialog.js";
|
|
6
|
+
import { SubscriptionFoundationHandlers, SubscriptionFoundationQueries } from "./constants.js";
|
|
7
|
+
import { resolveConsentLocale } from "./consumer-protection/consent-locale.js";
|
|
8
|
+
import { intervalKey } from "./plan-price.js";
|
|
9
|
+
// kumiko-lint-ignore no-custom-primitives Uses usePrimitives().Modal internally, name is a billing domain action, not a primitive reimplementation
|
|
10
|
+
export function PlanSwitchConfirmDialog({ open, onOpenChange, tier, fromPlanName, toPlanName, consumerProtection, onApplied, onConsentTextOutdated, }) {
|
|
11
|
+
const t = useTranslation();
|
|
12
|
+
const locale = useLocale().locale();
|
|
13
|
+
const { Modal, Text, Field, Input, Button, Link, Banner } = usePrimitives();
|
|
14
|
+
const previewQuery = useQuery(SubscriptionFoundationQueries.planSwitchPreview, { tier });
|
|
15
|
+
const switchMutation = useMutation(SubscriptionFoundationHandlers.switchPlan);
|
|
16
|
+
const [earlyPerformance, setEarlyPerformance] = useState(false);
|
|
17
|
+
const [withdrawalLoss, setWithdrawalLoss] = useState(false);
|
|
18
|
+
const [submitting, setSubmitting] = useState(false);
|
|
19
|
+
const [errorText, setErrorText] = useState(null);
|
|
20
|
+
const consentLocale = resolveConsentLocale(locale);
|
|
21
|
+
const texts = consumerProtection?.consentTexts[consentLocale];
|
|
22
|
+
const legalLinks = consumerProtection?.legalLinks[consentLocale];
|
|
23
|
+
const consentGiven = consumerProtection === undefined || (earlyPerformance && withdrawalLoss);
|
|
24
|
+
async function confirm() {
|
|
25
|
+
setSubmitting(true);
|
|
26
|
+
setErrorText(null);
|
|
27
|
+
const consent = texts === undefined
|
|
28
|
+
? undefined
|
|
29
|
+
: {
|
|
30
|
+
earlyPerformanceRequested: true,
|
|
31
|
+
withdrawalLossAcknowledged: true,
|
|
32
|
+
consentTextVersion: texts.consentTextVersion,
|
|
33
|
+
locale: consentLocale,
|
|
34
|
+
};
|
|
35
|
+
const result = await switchMutation.mutate({ tier, ...(consent && { consent }) });
|
|
36
|
+
if (result.isSuccess) {
|
|
37
|
+
onApplied();
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
setSubmitting(false);
|
|
41
|
+
setErrorText(t(result.error.i18nKey, result.error.i18nParams));
|
|
42
|
+
if (result.error.code === CONSENT_TEXT_OUTDATED_CODE) {
|
|
43
|
+
setEarlyPerformance(false);
|
|
44
|
+
setWithdrawalLoss(false);
|
|
45
|
+
onConsentTextOutdated();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const reply = previewQuery.data;
|
|
49
|
+
const money = (amount, currency) => formatMoney(amount, currency, locale);
|
|
50
|
+
const recurringKey = reply === null ? undefined : intervalKey(reply.target);
|
|
51
|
+
const recurringPrice = reply === null
|
|
52
|
+
? ""
|
|
53
|
+
: `${money(reply.target.unitAmount, reply.target.currency)}${recurringKey === undefined
|
|
54
|
+
? ""
|
|
55
|
+
: ` ${t(recurringKey, { count: new Intl.NumberFormat(locale).format(reply.target.intervalCount ?? 1) })}`}`;
|
|
56
|
+
return (_jsx(Modal, { open: open, onOpenChange: onOpenChange, title: t("billing-foundation.planSwitch.title"), testId: "plan-switch-confirm-dialog", children: _jsxs("div", { className: "flex flex-col gap-4", children: [_jsx(Text, { children: t("billing-foundation.planSwitch.summary", { from: fromPlanName, to: toPlanName }) }), previewQuery.error !== null && (_jsx(Banner, { variant: "error", testId: "plan-switch-preview-error", children: t(previewQuery.error.i18nKey, previewQuery.error.i18nParams) })), previewQuery.loading && reply === null && (_jsx(Banner, { variant: "loading", testId: "plan-switch-preview-loading", children: t("kumiko.widget.loading") })), reply !== null && (_jsxs("div", { className: "flex flex-col gap-1", "data-testid": "plan-switch-summary", children: [_jsxs(Text, { children: [t("billing-foundation.planSwitch.dueNow", {
|
|
57
|
+
amount: money(reply.preview.amountDueNow, reply.preview.currency),
|
|
58
|
+
}), reply.preview.tax !== null &&
|
|
59
|
+
` (${t("billing-foundation.planSwitch.includesTax", {
|
|
60
|
+
tax: money(reply.preview.tax, reply.preview.currency),
|
|
61
|
+
})})`] }), _jsx(Text, { variant: "muted", children: t("billing-foundation.planSwitch.credit", {
|
|
62
|
+
amount: money(reply.preview.credit, reply.preview.currency),
|
|
63
|
+
}) }), _jsx(Text, { variant: "muted", children: t("billing-foundation.planSwitch.recurring", {
|
|
64
|
+
amount: recurringPrice,
|
|
65
|
+
}) }), _jsx(Text, { variant: "muted", children: t("billing-foundation.planSwitch.effective") }), _jsx(Text, { variant: "muted", children: t("billing-foundation.planSwitch.pendingNote") })] })), legalLinks !== undefined && (_jsxs("div", { className: "flex flex-wrap gap-4", children: [_jsx(Link, { href: legalLinks.terms, target: "_blank", children: t("billing-foundation.consent.link.terms") }), _jsx(Link, { href: legalLinks.withdrawal, target: "_blank", children: t("billing-foundation.consent.link.withdrawal") }), _jsx(Link, { href: legalLinks.privacy, target: "_blank", children: t("billing-foundation.consent.link.privacy") })] })), texts !== undefined && (_jsxs(_Fragment, { children: [_jsx(Field, { id: "plan-switch-early-performance", label: texts.earlyPerformance, layout: "inline", children: _jsx(Input, { kind: "boolean", id: "plan-switch-early-performance", name: "plan-switch-early-performance", value: earlyPerformance, onChange: setEarlyPerformance, disabled: submitting }) }), _jsx(Field, { id: "plan-switch-withdrawal-loss", label: texts.withdrawalLoss, layout: "inline", children: _jsx(Input, { kind: "boolean", id: "plan-switch-withdrawal-loss", name: "plan-switch-withdrawal-loss", value: withdrawalLoss, onChange: setWithdrawalLoss, disabled: submitting }) })] })), errorText !== null && (_jsx(Banner, { variant: "error", testId: "plan-switch-confirm-error", children: errorText })), _jsxs("div", { className: "flex justify-end gap-2", children: [_jsx(Button, { variant: "secondary", disabled: submitting, onClick: () => onOpenChange(false), testId: "plan-switch-confirm-cancel", children: t("billing-foundation.consent.back") }), _jsx(Button, { disabled: !consentGiven || reply === null || submitting, loading: submitting, onClick: confirm, testId: "plan-switch-confirm-order", children: t("billing-foundation.planSwitch.order") })] })] }) }));
|
|
66
|
+
}
|
|
@@ -269,7 +269,11 @@ async function consumeClaimedRow(txDb, user, claimed, options) {
|
|
|
269
269
|
const booked = await bookCalendarDelta(txDb, user, options, options.units);
|
|
270
270
|
if (!booked.isSuccess)
|
|
271
271
|
return booked;
|
|
272
|
-
await txDb.updateMany(capReservationsTable, {
|
|
272
|
+
await txDb.updateMany(capReservationsTable, {
|
|
273
|
+
consumedAmount: claimed.consumedAmount + options.units,
|
|
274
|
+
// Keep the tombstone alive past its last consume, else a later consume would start a fresh one and double count.
|
|
275
|
+
expiresAt: Temporal.Now.instant().add({ minutes: CAP_SETTLED_TOMBSTONE_TTL_MINUTES }),
|
|
276
|
+
}, ownRow);
|
|
273
277
|
return booked;
|
|
274
278
|
}
|
|
275
279
|
if (options.units > claimed.amount) {
|
|
@@ -16,8 +16,9 @@ export const CAP_TOLERANCES = {
|
|
|
16
16
|
};
|
|
17
17
|
export function assertBelowHardCap(valueSeenByLastUnit, cap) {
|
|
18
18
|
const tolerance = CAP_TOLERANCES[cap.profile];
|
|
19
|
+
// Validated on every call so a misconfigured period fails deterministically, not only at the limit.
|
|
20
|
+
const resetAt = resolvePeriodEndIso(cap.periodStartIso, cap.periodEndIso);
|
|
19
21
|
if (valueSeenByLastUnit >= cap.limit * tolerance.hard) {
|
|
20
|
-
const resetAt = resolvePeriodEndIso(cap.periodStartIso, cap.periodEndIso);
|
|
21
22
|
throw new CapExceededError(cap.capName, cap.limit, valueSeenByLastUnit, tolerance, {
|
|
22
23
|
...(resetAt !== undefined && { resetAt }),
|
|
23
24
|
...(cap.i18nKey !== undefined && { i18nKey: cap.i18nKey }),
|
|
@@ -7,6 +7,12 @@ import { enforceStockCap } from "./enforce-cap.js";
|
|
|
7
7
|
// pg_advisory_xact_lock namespace (int4): 'scap' as ASCII, disjoint from the
|
|
8
8
|
// framework's other fixed advisory-lock keys.
|
|
9
9
|
const STOCK_CAP_LOCK_NAMESPACE = 0x73636170;
|
|
10
|
+
// Lock on the counted set, not on `code`: two handlers capping the same rows under
|
|
11
|
+
// different codes must still serialize against each other.
|
|
12
|
+
function countedSetKey(spec) {
|
|
13
|
+
const where = Object.entries(spec.where ?? {}).sort(([a], [b]) => a.localeCompare(b));
|
|
14
|
+
return `${spec.table.tableName}:${JSON.stringify(where)}`;
|
|
15
|
+
}
|
|
10
16
|
export function createStockCapGuard(resolveTierCaps) {
|
|
11
17
|
async function checkStockCap(db, spec, context = {}) {
|
|
12
18
|
// Lock-free on its own: a direct call outside withStockCap is not atomic.
|
|
@@ -31,7 +37,7 @@ export function createStockCapGuard(resolveTierCaps) {
|
|
|
31
37
|
// ctx.db is the handler's transaction, so the xact lock releases with its commit or
|
|
32
38
|
// rollback. A handler wrapped in several caps must list them in the same order
|
|
33
39
|
// everywhere; Postgres aborts one side of an inverse order with a deadlock error.
|
|
34
|
-
await ctx.db.advisoryXactLock(STOCK_CAP_LOCK_NAMESPACE, spec
|
|
40
|
+
await ctx.db.advisoryXactLock(STOCK_CAP_LOCK_NAMESPACE, countedSetKey(spec));
|
|
35
41
|
const failure = await checkStockCap(ctx.db, spec, { config: ctx.config });
|
|
36
42
|
return failure ?? handler.handler(event, ctx);
|
|
37
43
|
},
|
|
@@ -6,7 +6,7 @@ import { smtpEnvSchema } from "./env.js";
|
|
|
6
6
|
export function createChannelEmailFeature(options) {
|
|
7
7
|
const channel = createEmailChannel(options);
|
|
8
8
|
const feature = defineFeature("channel-email", (r) => {
|
|
9
|
-
r.describe("Wires an `EmailTransport` (typically `mail-transport-smtp` in production, `createInMemoryTransport()` in tests) into the delivery system as the `email` channel. Requires `delivery`; pass an `EmailChannelOptions` with a `transport` and a `resolveEmail` function that maps a user ID to their email address. Mails render from stored templates (`renderer-mail-html`): a notification routed to this channel needs `template` + `meta` on `r.notification`, otherwise its delivery fails. Pass `tenantTransport: tenantMailTransport` (from `mail-foundation`) to send the notifications declared with `tenantTransport: true` through the sending tenant's own configured provider: a broken provider fails the send, a tenant without one uses the app `transport`; every other notification (password reset, sign-in) always goes through the app `transport`. `transport` may be left out when `tenantTransport` is set
|
|
9
|
+
r.describe("Wires an `EmailTransport` (typically `mail-transport-smtp` in production, `createInMemoryTransport()` in tests) into the delivery system as the `email` channel. Requires `delivery`; pass an `EmailChannelOptions` with a `transport` and a `resolveEmail` function that maps a user ID to their email address. Mails render from stored templates (`renderer-mail-html`): a notification routed to this channel needs `template` + `meta` on `r.notification`, otherwise its delivery fails. Pass `tenantTransport: tenantMailTransport` (from `mail-foundation`) to send the notifications declared with `tenantTransport: true` through the sending tenant's own configured provider: a broken provider fails the send, a tenant without one uses the app `transport`; every other notification (password reset, sign-in) always goes through the app `transport`. `transport` may be left out when `tenantTransport` is set, but then only notifications declared with `tenantTransport: true` are delivered: every other notification (password reset, sign-in, invitations) fails with `missing_credentials`, so an app without an app SMTP server must mark every notification that should go out or keep an app `transport`. Notifications declared with `unsubscribe: true` get a signed `data.unsubscribeUrl` from delivery, which turns into the `List-Unsubscribe` headers.");
|
|
10
10
|
r.uiHints({
|
|
11
11
|
displayLabel: "Email Channel",
|
|
12
12
|
category: "notifications",
|
|
@@ -2,4 +2,9 @@ import { type ConfigCascade } from "@cosmicdrift/kumiko-framework/engine";
|
|
|
2
2
|
import * as z from "zod";
|
|
3
3
|
export declare const cascadeQuery: import("@cosmicdrift/kumiko-types/define-handler").QueryHandlerDefinition<"cascade", z.ZodObject<{
|
|
4
4
|
keys: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
5
|
+
scope: z.ZodOptional<z.ZodEnum<{
|
|
6
|
+
system: "system";
|
|
7
|
+
tenant: "tenant";
|
|
8
|
+
user: "user";
|
|
9
|
+
}>>;
|
|
5
10
|
}, z.core.$strip>, Record<string, ConfigCascade>, import("@cosmicdrift/kumiko-types/event-type-map").KumikoEventTypeMap>;
|
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
import { defineQueryHandler, isEncryptedAtRest, } from "@cosmicdrift/kumiko-framework/engine";
|
|
1
|
+
import { ConfigScopes, defineQueryHandler, isEncryptedAtRest, } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import * as z from "zod";
|
|
3
3
|
import { requireConfigResolver, requireSystemDb } from "../feature.js";
|
|
4
4
|
import { MASKED, redactInheritedCascade, shouldRedactInherited } from "../read-redaction.js";
|
|
5
|
-
import { hasConfigAccess } from "../write-helpers.js";
|
|
5
|
+
import { hasConfigAccess, keyDefAtScope } from "../write-helpers.js";
|
|
6
6
|
export const cascadeQuery = defineQueryHandler({
|
|
7
7
|
name: "cascade",
|
|
8
8
|
description: "Shows, per config key the caller may read, the value at every cascade level (user, tenant, system, app override, default) and which level won; use it to explain where a setting's current value comes from.",
|
|
9
9
|
schema: z.object({
|
|
10
10
|
keys: z.array(z.string()).optional(),
|
|
11
|
+
scope: z.enum([ConfigScopes.system, ConfigScopes.tenant, ConfigScopes.user]).optional(),
|
|
11
12
|
}),
|
|
12
13
|
access: {
|
|
13
14
|
openToAll: {
|
|
@@ -34,7 +35,7 @@ export const cascadeQuery = defineQueryHandler({
|
|
|
34
35
|
continue;
|
|
35
36
|
if (!hasConfigAccess(keyDef.access.read, query.user.roles))
|
|
36
37
|
continue;
|
|
37
|
-
keyDefs.set(key, keyDef);
|
|
38
|
+
keyDefs.set(key, keyDefAtScope(keyDef, query.payload.scope, query.user.roles));
|
|
38
39
|
filteredKeys.push(key);
|
|
39
40
|
}
|
|
40
41
|
const cascades = await resolver.getCascadeBatch(filteredKeys, keyDefs, query.user.tenantId, query.user.id, db, ctx.secrets);
|
|
@@ -2,6 +2,11 @@ import { type ConfigScope, type ConfigValueSource } from "@cosmicdrift/kumiko-fr
|
|
|
2
2
|
import * as z from "zod";
|
|
3
3
|
export declare const valuesQuery: import("@cosmicdrift/kumiko-types/define-handler").QueryHandlerDefinition<"values", z.ZodObject<{
|
|
4
4
|
keys: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
5
|
+
scope: z.ZodOptional<z.ZodEnum<{
|
|
6
|
+
system: "system";
|
|
7
|
+
tenant: "tenant";
|
|
8
|
+
user: "user";
|
|
9
|
+
}>>;
|
|
5
10
|
}, z.core.$strip>, Record<string, {
|
|
6
11
|
value: string | number | boolean | undefined;
|
|
7
12
|
scope: ConfigScope;
|
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
import { defineQueryHandler, isEncryptedAtRest, } from "@cosmicdrift/kumiko-framework/engine";
|
|
1
|
+
import { ConfigScopes, defineQueryHandler, isEncryptedAtRest, } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import * as z from "zod";
|
|
3
3
|
import { requireConfigResolver, requireSystemDb } from "../feature.js";
|
|
4
4
|
import { MASKED, redactInheritedCascade, shouldRedactInherited } from "../read-redaction.js";
|
|
5
|
-
import { hasConfigAccess } from "../write-helpers.js";
|
|
5
|
+
import { hasConfigAccess, keyDefAtScope } from "../write-helpers.js";
|
|
6
6
|
export const valuesQuery = defineQueryHandler({
|
|
7
7
|
name: "values",
|
|
8
8
|
description: "Returns the effective value, scope and winning source for every config key the caller may read (optionally narrowed to `keys`), with encrypted and secret-backed values masked; use it to inspect the current settings.",
|
|
9
9
|
schema: z.object({
|
|
10
10
|
keys: z.array(z.string()).optional(),
|
|
11
|
+
scope: z.enum([ConfigScopes.system, ConfigScopes.tenant, ConfigScopes.user]).optional(),
|
|
11
12
|
}),
|
|
12
13
|
// Per-key read access enforced via hasConfigAccess inside the handler.
|
|
13
14
|
access: {
|
|
@@ -34,7 +35,7 @@ export const valuesQuery = defineQueryHandler({
|
|
|
34
35
|
continue;
|
|
35
36
|
if (!hasConfigAccess(keyDef.access.read, query.user.roles))
|
|
36
37
|
continue;
|
|
37
|
-
keyDefs.set(qualifiedKey, keyDef);
|
|
38
|
+
keyDefs.set(qualifiedKey, keyDefAtScope(keyDef, query.payload.scope, query.user.roles));
|
|
38
39
|
filteredKeys.push(qualifiedKey);
|
|
39
40
|
}
|
|
40
41
|
// Resolve through the full cascade (rows → app-override → computed →
|
|
@@ -35,6 +35,7 @@ export declare function checkWriteAccess(keyDef: ConfigKeyDefinition, userRoles:
|
|
|
35
35
|
*/
|
|
36
36
|
export declare function checkScopeWriteAccess(scope: ConfigScope, userRoles: readonly string[]): KumikoError | null;
|
|
37
37
|
export declare function validateScope(requestedScope: ConfigScope, definedScope: ConfigScope, key: string): KumikoError | null;
|
|
38
|
+
export declare function keyDefAtScope(keyDef: ConfigKeyDefinition, readScope: ConfigScope | undefined, roles: readonly string[]): ConfigKeyDefinition;
|
|
38
39
|
export declare function resolveScopeIds(scope: ConfigScope, tenantId: TenantId, userId: string): {
|
|
39
40
|
tenantId: TenantId;
|
|
40
41
|
userId: string | null;
|