@cosmicdrift/kumiko-bundled-features 0.387.0 → 0.389.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/index.d.ts +1 -1
- package/dist/agent-tools/index.js +1 -1
- package/dist/agent-tools/tool-dispatch.d.ts +12 -7
- package/dist/agent-tools/tool-dispatch.js +42 -31
- package/dist/audit/feature.js +1 -0
- package/dist/audit/handlers/audit-events.js +1 -1
- package/dist/auth-email-password/handlers/request-account-unlock.write.d.ts +1 -0
- package/dist/auth-email-password/handlers/request-account-unlock.write.js +1 -0
- package/dist/auth-email-password/handlers/request-email-verification.write.d.ts +1 -0
- package/dist/auth-email-password/handlers/request-email-verification.write.js +1 -0
- package/dist/auth-email-password/handlers/request-password-reset.write.d.ts +1 -0
- package/dist/auth-email-password/handlers/request-password-reset.write.js +1 -0
- package/dist/auth-email-password/handlers/signup-request.write.d.ts +1 -0
- package/dist/auth-email-password/handlers/signup-request.write.js +4 -0
- package/dist/auth-email-password/handlers/token-request-handler.d.ts +2 -0
- package/dist/auth-email-password/handlers/token-request-handler.js +5 -0
- package/dist/auth-email-password/magic-link-mail.d.ts +2 -0
- package/dist/auth-email-password/magic-link-mail.js +7 -3
- package/dist/auth-email-password/signup-activation.d.ts +1 -0
- package/dist/auth-email-password/signup-activation.js +1 -0
- package/dist/auth-email-password/web/auth-client.d.ts +2 -2
- package/dist/auth-email-password/web/auth-client.js +8 -4
- package/dist/auth-email-password/web/auth-redirect.d.ts +5 -1
- package/dist/auth-email-password/web/auth-redirect.js +20 -25
- package/dist/auth-email-password/web/default-auth-pages-gate.js +6 -3
- package/dist/auth-email-password/web/forgot-password-screen.d.ts +4 -1
- package/dist/auth-email-password/web/forgot-password-screen.js +7 -2
- package/dist/auth-email-password/web/index.d.ts +1 -0
- package/dist/auth-email-password/web/index.js +1 -0
- package/dist/auth-email-password/web/login-screen.js +11 -6
- package/dist/auth-email-password/web/reset-password-screen.js +7 -1
- package/dist/auth-email-password/web/return-path.d.ts +4 -0
- package/dist/auth-email-password/web/return-path.js +34 -0
- package/dist/auth-email-password/web/session.js +6 -1
- package/dist/auth-email-password/web/signup-complete-screen.js +11 -3
- package/dist/auth-email-password/web/signup-screen.d.ts +4 -1
- package/dist/auth-email-password/web/signup-screen.js +8 -3
- package/dist/auth-mfa/config.d.ts +1 -1
- package/dist/auth-mfa/config.js +3 -7
- package/dist/auth-mfa/feature-exports.d.ts +2 -2
- package/dist/auth-mfa/feature.js +8 -0
- package/dist/auth-mfa/mfa-status-checker.js +5 -3
- 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 +20 -7
- package/dist/cap-counter/enforce-cap.js +2 -1
- package/dist/cap-counter/feature.js +1 -0
- package/dist/cap-counter/stock-cap-guard.js +7 -1
- package/dist/cap-overview/feature.js +1 -0
- 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/set.write.js +12 -2
- 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 +14 -11
- package/dist/custom-fields/lib/parse-serialized-field.js +1 -1
- 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/feature.js +1 -0
- 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/index.d.ts +1 -0
- package/dist/delivery/index.js +1 -0
- package/dist/delivery/system-channel-context.d.ts +15 -0
- package/dist/delivery/system-channel-context.js +5 -0
- 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/document-ingest-foundation/append-missing-extract-event.d.ts +21 -0
- package/dist/document-ingest-foundation/append-missing-extract-event.js +42 -0
- package/dist/document-ingest-foundation/events.d.ts +11 -0
- package/dist/document-ingest-foundation/events.js +11 -0
- package/dist/document-ingest-foundation/index.d.ts +2 -1
- package/dist/document-ingest-foundation/index.js +2 -1
- package/dist/feature-toggles/feature.js +1 -0
- 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/host-routing/validate-options.js +3 -0
- package/dist/inbound-mail-foundation/feature.js +1 -0
- 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 +9 -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/ledger/web/index.js +1 -1
- package/dist/legal-pages/seed.d.ts +3 -1
- package/dist/legal-pages/seed.js +2 -2
- package/dist/mail-foundation/feature.d.ts +3 -3
- package/dist/mail-foundation/feature.js +3 -0
- package/dist/mail-transport-smtp/config-keys.js +1 -1
- package/dist/mail-transport-smtp/feature.js +6 -5
- package/dist/managed-pages/constants.d.ts +1 -0
- package/dist/managed-pages/constants.js +1 -0
- package/dist/managed-pages/feature.js +3 -1
- package/dist/managed-pages/index.d.ts +1 -0
- package/dist/managed-pages/index.js +1 -0
- package/dist/notes-history/handlers/add-note.write.js +1 -1
- package/dist/personal-access-tokens/feature.d.ts +1 -1
- package/dist/personal-access-tokens/feature.js +5 -0
- package/dist/renderer-pdf/render-table-pdf.d.ts +1 -1
- package/dist/renderer-pdf/render-table-pdf.js +22 -10
- package/dist/secrets/write-gate.js +8 -2
- package/dist/sessions/feature.d.ts +2 -2
- package/dist/sessions/feature.js +5 -0
- package/dist/sessions/index.d.ts +1 -1
- package/dist/sessions/index.js +1 -1
- package/dist/sessions/session-callbacks.d.ts +0 -2
- package/dist/sessions/session-callbacks.js +1 -3
- 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/shared/purge-files-reaching-subject.d.ts +1 -0
- package/dist/shared/purge-files-reaching-subject.js +19 -2
- package/dist/shared/run-in-sub-transaction.js +5 -5
- package/dist/step-dispatcher/deliver-dispatch-request.js +16 -3
- package/dist/step-dispatcher/feature.js +1 -0
- package/dist/step-dispatcher/mail-runner.d.ts +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/tags/feature.js +1 -0
- 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/document-ops.d.ts +5 -0
- package/dist/template-resolver/document-ops.js +18 -2
- package/dist/template-resolver/feature.js +4 -4
- 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/layout-source.query.d.ts +4 -0
- package/dist/template-resolver/handlers/layout-source.query.js +26 -6
- 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 +21 -10
- package/dist/template-resolver/handlers/patch-draft.write.js +1 -1
- package/dist/template-resolver/handlers/send-test-status.query.d.ts +2 -1
- package/dist/template-resolver/handlers/send-test-status.query.js +18 -15
- 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.d.ts +1 -0
- package/dist/template-resolver/seeding.js +31 -12
- package/dist/template-resolver/store-template-resource.d.ts +1 -0
- package/dist/template-resolver/store-template-resource.js +65 -10
- 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/i18n.js +3 -0
- 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/template-resolver/web/qualified-names.d.ts +1 -0
- package/dist/template-resolver/web/qualified-names.js +1 -0
- package/dist/tenant/feature.js +1 -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/team-list.query.js +3 -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/tenant-lifecycle/stages.js +22 -1
- 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/feature.js +1 -0
- package/dist/user-data-rights/feature.js +1 -0
- package/dist/user-data-rights/index.d.ts +1 -1
- package/dist/user-data-rights/index.js +1 -1
- package/dist/user-data-rights/run-export-jobs.js +3 -4
- package/dist/user-data-rights/run-forget-cleanup.js +2 -1
- package/dist/user-data-rights/web/constants.d.ts +6 -0
- package/dist/user-data-rights/web/constants.js +6 -0
- package/dist/user-data-rights/web/index.d.ts +1 -0
- package/dist/user-data-rights/web/index.js +1 -0
- package/dist/waitlist/feature.js +1 -0
- package/dist/waitlist/handlers/export-purge.write.js +2 -1
- package/dist/waitlist/handlers/submit.write.js +14 -10
- package/dist/waitlist/import.d.ts +3 -1
- package/dist/waitlist/import.js +26 -2
- package/dist/waitlist/legacy-migrate.js +18 -7
- package/dist/waitlist/link-on-signup.js +18 -6
- package/dist/waitlist/options.d.ts +5 -1
- package/dist/workflow-runner/handlers/resume-run.write.js +1 -1
- package/package.json +19 -9
- package/src/agent-tools/changes.json +12 -0
- package/src/audit/changes.json +12 -0
- package/src/auth-email-password/changes.json +12 -0
- package/src/auth-mfa/changes.json +12 -0
- package/src/billing-foundation/changes.json +55 -0
- package/src/cap-counter/changes.json +30 -0
- package/src/channel-email/changes.json +6 -0
- package/src/config/changes.json +18 -0
- package/src/crypto-shredding/changes.json +18 -0
- package/src/delivery/changes.json +42 -0
- package/src/document-ingest-foundation/changes.json +6 -0
- package/src/form-draft/changes.json +6 -0
- package/src/host-routing/changes.json +12 -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/mail-transport-smtp/changes.json +6 -0
- package/src/managed-pages/changes.json +6 -0
- package/src/renderer-pdf/changes.json +6 -0
- package/src/secrets/changes.json +6 -0
- package/src/sessions/changes.json +7 -0
- package/src/step-dispatcher/changes.json +12 -0
- package/src/subscription-dodo/changes.json +14 -0
- package/src/subscription-stripe/changes.json +12 -0
- package/src/template-resolver/changes.json +84 -0
- package/src/tenant/changes.json +6 -0
- package/src/tenant-lifecycle/changes.json +12 -0
- package/src/tier-engine/changes.json +6 -0
- package/src/user-data-rights/changes.json +12 -0
- package/src/waitlist/changes.json +36 -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
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Pre-checks shared by switch-plan and plan-switch-preview: both must agree on
|
|
2
|
+
// which subscription, provider and target price a plan switch means.
|
|
3
|
+
// Internal — not re-exported from index.ts.
|
|
4
|
+
import { ConflictError, UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
|
|
5
|
+
import { assertBillingEnabled, findProviderPlugin, listCatalogProviders, } from "./checkout-core.js";
|
|
6
|
+
import { getSubscriptionForTenant } from "./get-subscription-for-tenant.js";
|
|
7
|
+
import { resolvePlanPrices } from "./plan-catalog.js";
|
|
8
|
+
import { canSwitchPlans } from "./types.js";
|
|
9
|
+
import { isSwitchableSubscriptionStatus } from "./web/constants.js";
|
|
10
|
+
export async function preparePlanSwitch(ctx, catalog, tier, handlerName) {
|
|
11
|
+
const subscription = await getSubscriptionForTenant(ctx, ctx.user.tenantId);
|
|
12
|
+
if (!subscription || !isSwitchableSubscriptionStatus(subscription.status)) {
|
|
13
|
+
throw new ConflictError({
|
|
14
|
+
i18nKey: "billing-foundation.errors.noActiveSubscription",
|
|
15
|
+
message: "tenant has no switchable subscription; use billing-foundation:write:start-plan-checkout to start one",
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
// providerSubscriptionId only exists at the subscription's own provider; a catalog that
|
|
19
|
+
// no longer sells through that provider cannot switch it.
|
|
20
|
+
const provider = findProviderPlugin(ctx, subscription.providerName);
|
|
21
|
+
const isCatalogProvider = provider !== null &&
|
|
22
|
+
listCatalogProviders(ctx, catalog).some((candidate) => candidate.name === provider.name);
|
|
23
|
+
if (!provider || !isCatalogProvider) {
|
|
24
|
+
throw new ConflictError({
|
|
25
|
+
i18nKey: "billing-foundation.errors.providerMismatch",
|
|
26
|
+
message: `tenant's active subscription is on provider "${subscription.providerName}", which the plan catalog does not sell through — switch-plan cannot switch across providers`,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
await assertBillingEnabled(ctx, provider.plugin, handlerName);
|
|
30
|
+
if (subscription.cancelAt !== null) {
|
|
31
|
+
throw new ConflictError({
|
|
32
|
+
i18nKey: "billing-foundation.errors.cancellationScheduled",
|
|
33
|
+
message: "subscription has a scheduled cancellation; reactivate it via billing-foundation:write:create-portal-session before switching plans",
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
if (tier === subscription.tier) {
|
|
37
|
+
throw new ConflictError({
|
|
38
|
+
i18nKey: "billing-foundation.errors.alreadyOnPlan",
|
|
39
|
+
message: `tenant is already on plan "${tier}"`,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
if (!canSwitchPlans(provider.plugin)) {
|
|
43
|
+
throw new UnprocessableError("plan_switch_not_supported", {
|
|
44
|
+
i18nKey: "billing-foundation.errors.planSwitchNotSupported",
|
|
45
|
+
message: `subscription-foundation: provider "${subscription.providerName}" supports neither createPlanSwitchSession nor previewPlanSwitch + applyPlanSwitch`,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
const prices = await resolvePlanPrices(ctx, provider.plugin, catalog);
|
|
49
|
+
const target = prices.get(tier);
|
|
50
|
+
if (!target) {
|
|
51
|
+
throw new UnprocessableError("price_unavailable", {
|
|
52
|
+
i18nKey: "billing-foundation.errors.priceUnavailable",
|
|
53
|
+
message: `subscription-foundation: no live price resolved for tier "${tier}"`,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
const allowedPriceIds = [...prices.values()]
|
|
57
|
+
.filter((p) => p !== null)
|
|
58
|
+
.map((p) => p.priceId);
|
|
59
|
+
return { provider, subscription, target, allowedPriceIds };
|
|
60
|
+
}
|
|
@@ -75,6 +75,8 @@ export type PaymentEvent = {
|
|
|
75
75
|
readonly priceId: string;
|
|
76
76
|
};
|
|
77
77
|
export type SubscriptionProviderPlugin = {
|
|
78
|
+
/** Human-readable provider name for the plan panel's provider switch. */
|
|
79
|
+
readonly displayName?: string;
|
|
78
80
|
/**
|
|
79
81
|
* Verify webhook signature + parse provider-event into normalized
|
|
80
82
|
* form. **Pre-tenant-resolution** — no HandlerContext, because at
|
|
@@ -228,6 +230,24 @@ export type SubscriptionProviderPlugin = {
|
|
|
228
230
|
}) => Promise<{
|
|
229
231
|
readonly url: string;
|
|
230
232
|
}>;
|
|
233
|
+
/**
|
|
234
|
+
* In-app plan switch, step 1: what the switch would cost right now. Together
|
|
235
|
+
* with `applyPlanSwitch` an alternative to the hosted `createPlanSwitchSession`.
|
|
236
|
+
*/
|
|
237
|
+
readonly previewPlanSwitch?: (ctx: HandlerContext, options: {
|
|
238
|
+
readonly providerSubscriptionId: string;
|
|
239
|
+
readonly targetPriceId: string;
|
|
240
|
+
}) => Promise<PlanSwitchPreview>;
|
|
241
|
+
/**
|
|
242
|
+
* In-app plan switch, step 2: performs the switch at the provider. The
|
|
243
|
+
* state change itself arrives later through the webhook.
|
|
244
|
+
*/
|
|
245
|
+
readonly applyPlanSwitch?: (ctx: HandlerContext, options: {
|
|
246
|
+
readonly providerSubscriptionId: string;
|
|
247
|
+
readonly targetPriceId: string;
|
|
248
|
+
readonly tenantId: string;
|
|
249
|
+
readonly consentId?: string;
|
|
250
|
+
}) => Promise<void>;
|
|
231
251
|
/**
|
|
232
252
|
* Fetches the live provider-side state of one subscription, for the
|
|
233
253
|
* `sync-subscriptions` backfill job — catches drift (e.g. a `cancel_at`
|
|
@@ -241,6 +261,17 @@ export type SubscriptionProviderPlugin = {
|
|
|
241
261
|
*/
|
|
242
262
|
readonly retrieveSubscription?: (ctx: HandlerContext, providerSubscriptionId: string) => Promise<ProviderSubscriptionSnapshot | null>;
|
|
243
263
|
};
|
|
264
|
+
export type PlanSwitchPreview = {
|
|
265
|
+
/** Charged immediately, smallest currency unit, tax included. */
|
|
266
|
+
readonly amountDueNow: number;
|
|
267
|
+
/** Tax part of `amountDueNow`; null when the provider reports none. */
|
|
268
|
+
readonly tax: number | null;
|
|
269
|
+
/** Lower-case ISO currency code. */
|
|
270
|
+
readonly currency: string;
|
|
271
|
+
/** Credit for the unused time on the old plan, smallest currency unit. */
|
|
272
|
+
readonly credit: number;
|
|
273
|
+
readonly effectiveAt: string;
|
|
274
|
+
};
|
|
244
275
|
export type ProviderSubscriptionSnapshot = {
|
|
245
276
|
readonly providerCustomerId: string;
|
|
246
277
|
readonly providerSubscriptionId: string;
|
|
@@ -296,7 +327,8 @@ export type BillingPlanCatalog<TTier extends string = string> = {
|
|
|
296
327
|
* `baseUrl` itself when unset) and by `switch-plan` after a plan switch
|
|
297
328
|
* (defaults to `successPath` there instead). */
|
|
298
329
|
readonly returnPath?: string;
|
|
299
|
-
/**
|
|
330
|
+
/** Restricts the catalog to one provider; defaults to every registered
|
|
331
|
+
* provider exposing `priceToTier`. */
|
|
300
332
|
readonly providerName?: string;
|
|
301
333
|
/** Rendered as the last card, no price, visible to `viewRoles` regardless of `purchaseRoles`. */
|
|
302
334
|
readonly contact?: BillingPlanContactCard;
|
|
@@ -319,6 +351,14 @@ export type ConsumerProtectionOptions = {
|
|
|
319
351
|
readonly termsTextBlock: string;
|
|
320
352
|
/** Per-locale VAT note; must carry at least `de` and `en`. */
|
|
321
353
|
readonly vatNote: Readonly<Record<string, string>>;
|
|
354
|
+
/** Per-provider texts keyed by provider name, e.g. for a merchant-of-record
|
|
355
|
+
* provider that issues the invoice and VAT itself. `vatNote` here replaces
|
|
356
|
+
* the global one in that provider's confirmation mail; `checkoutNote` is
|
|
357
|
+
* shown under the provider choice. de and en are required for each text. */
|
|
358
|
+
readonly providerTexts?: Readonly<Record<string, {
|
|
359
|
+
readonly vatNote?: Readonly<Record<string, string>>;
|
|
360
|
+
readonly checkoutNote?: Readonly<Record<string, string>>;
|
|
361
|
+
}>>;
|
|
322
362
|
readonly operatorEmail: string;
|
|
323
363
|
/** Where the public § 312k declaration is reachable. `"tenant-host"`
|
|
324
364
|
* (default): on a host that resolves a tenant. `"platform"`: also on a
|
|
@@ -372,6 +412,15 @@ export type BillingPlanView = {
|
|
|
372
412
|
readonly isCurrent: boolean;
|
|
373
413
|
readonly action: BillingPlanAction;
|
|
374
414
|
};
|
|
415
|
+
export type BillingProviderView = {
|
|
416
|
+
readonly name: string;
|
|
417
|
+
readonly displayName: string;
|
|
418
|
+
readonly note?: Readonly<Record<string, string>>;
|
|
419
|
+
readonly plans: readonly BillingPlanView[];
|
|
420
|
+
};
|
|
421
|
+
export declare function canSwitchPlansInApp(plugin: SubscriptionProviderPlugin): boolean;
|
|
422
|
+
/** A provider switches plans via a hosted session or via in-app preview + apply. */
|
|
423
|
+
export declare function canSwitchPlans(plugin: SubscriptionProviderPlugin | null): boolean;
|
|
375
424
|
export type BillingPlansResult = {
|
|
376
425
|
readonly enabled: boolean;
|
|
377
426
|
readonly currentTier: {
|
|
@@ -388,6 +437,13 @@ export type BillingPlansResult = {
|
|
|
388
437
|
} | null;
|
|
389
438
|
readonly canPurchase: boolean;
|
|
390
439
|
readonly plans: readonly BillingPlanView[];
|
|
440
|
+
/** Present when more than one provider is offered, or the single offered
|
|
441
|
+
* provider has a `providerTexts` checkout note. `plans` above are the first
|
|
442
|
+
* entry's plans. */
|
|
443
|
+
readonly providers?: readonly BillingProviderView[];
|
|
444
|
+
/** True when the subscription's provider switches plans in-app (preview +
|
|
445
|
+
* confirm) instead of on a hosted page. */
|
|
446
|
+
readonly inAppPlanSwitch?: true;
|
|
391
447
|
/** Present only when `catalog.contact` is configured. */
|
|
392
448
|
readonly contact?: BillingPlanContactCard;
|
|
393
449
|
/** Present only when `consumerProtection` is configured. */
|
|
@@ -33,3 +33,12 @@ export const KNOWN_RECURRING_INTERVALS = ["day", "week", "month", "year"];
|
|
|
33
33
|
export function isLegalLinkSet(links) {
|
|
34
34
|
return "terms" in links;
|
|
35
35
|
}
|
|
36
|
+
export function canSwitchPlansInApp(plugin) {
|
|
37
|
+
return plugin.previewPlanSwitch !== undefined && plugin.applyPlanSwitch !== undefined;
|
|
38
|
+
}
|
|
39
|
+
/** A provider switches plans via a hosted session or via in-app preview + apply. */
|
|
40
|
+
export function canSwitchPlans(plugin) {
|
|
41
|
+
if (plugin === null)
|
|
42
|
+
return false;
|
|
43
|
+
return plugin.createPlanSwitchSession !== undefined || canSwitchPlansInApp(plugin);
|
|
44
|
+
}
|
|
@@ -20,6 +20,22 @@ function isRootRelativeOrHttpsUrl(link) {
|
|
|
20
20
|
return false;
|
|
21
21
|
}
|
|
22
22
|
}
|
|
23
|
+
function validateProviderTexts(cp) {
|
|
24
|
+
for (const [providerName, texts] of Object.entries(cp.providerTexts ?? {})) {
|
|
25
|
+
for (const [textName, text] of [
|
|
26
|
+
["vatNote", texts.vatNote],
|
|
27
|
+
["checkoutNote", texts.checkoutNote],
|
|
28
|
+
]) {
|
|
29
|
+
if (text === undefined)
|
|
30
|
+
continue;
|
|
31
|
+
for (const locale of ["de", "en"]) {
|
|
32
|
+
if (!text[locale]?.trim()) {
|
|
33
|
+
throw new Error(`createBillingFoundationFeature: consumerProtection.providerTexts.${providerName}.${textName} needs a non-empty "${locale}" entry.`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
23
39
|
function validateConsumerProtection(cp) {
|
|
24
40
|
if (cp.termsTextBlock.trim() === "") {
|
|
25
41
|
throw new Error("createBillingFoundationFeature: consumerProtection.termsTextBlock is empty.");
|
|
@@ -29,6 +45,7 @@ function validateConsumerProtection(cp) {
|
|
|
29
45
|
throw new Error(`createBillingFoundationFeature: consumerProtection.vatNote needs a non-empty "${locale}" entry.`);
|
|
30
46
|
}
|
|
31
47
|
}
|
|
48
|
+
validateProviderTexts(cp);
|
|
32
49
|
if (!z.email().safeParse(cp.operatorEmail).success) {
|
|
33
50
|
throw new Error(`createBillingFoundationFeature: consumerProtection.operatorEmail "${cp.operatorEmail}" is not a valid email address.`);
|
|
34
51
|
}
|
|
@@ -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 {};
|