@cosmicdrift/kumiko-samples 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/package.json +1 -1
- package/packages/bundled-features/package.json +8 -1
- package/packages/bundled-features/src/agent-tools/changes.json +12 -0
- package/packages/bundled-features/src/agent-tools/index.ts +1 -1
- package/packages/bundled-features/src/agent-tools/tool-dispatch.ts +48 -32
- package/packages/bundled-features/src/audit/changes.json +12 -0
- package/packages/bundled-features/src/audit/feature.ts +1 -0
- package/packages/bundled-features/src/audit/handlers/audit-events.ts +1 -1
- package/packages/bundled-features/src/auth-email-password/changes.json +12 -0
- package/packages/bundled-features/src/auth-email-password/handlers/request-account-unlock.write.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/handlers/request-email-verification.write.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/handlers/request-password-reset.write.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +4 -0
- package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +8 -0
- package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +16 -3
- package/packages/bundled-features/src/auth-email-password/signup-activation.ts +2 -0
- package/packages/bundled-features/src/auth-email-password/web/auth-client.ts +8 -2
- package/packages/bundled-features/src/auth-email-password/web/auth-redirect.ts +23 -21
- package/packages/bundled-features/src/auth-email-password/web/default-auth-pages-gate.tsx +6 -3
- package/packages/bundled-features/src/auth-email-password/web/forgot-password-screen.tsx +11 -1
- package/packages/bundled-features/src/auth-email-password/web/index.ts +5 -0
- package/packages/bundled-features/src/auth-email-password/web/login-screen.tsx +16 -5
- package/packages/bundled-features/src/auth-email-password/web/reset-password-screen.tsx +7 -1
- package/packages/bundled-features/src/auth-email-password/web/return-path.ts +35 -0
- package/packages/bundled-features/src/auth-email-password/web/session.tsx +6 -1
- package/packages/bundled-features/src/auth-email-password/web/signup-complete-screen.tsx +11 -3
- package/packages/bundled-features/src/auth-email-password/web/signup-screen.tsx +12 -2
- package/packages/bundled-features/src/auth-mfa/changes.json +12 -0
- package/packages/bundled-features/src/auth-mfa/config.ts +3 -7
- package/packages/bundled-features/src/auth-mfa/feature-exports.ts +2 -2
- package/packages/bundled-features/src/auth-mfa/feature.ts +6 -0
- package/packages/bundled-features/src/auth-mfa/mfa-status-checker.ts +8 -5
- package/packages/bundled-features/src/auth-mfa/web/i18n.ts +6 -1
- package/packages/bundled-features/src/auth-mfa/web/mfa-code-or-recovery-field.tsx +68 -0
- package/packages/bundled-features/src/auth-mfa/web/mfa-disable-dialog.tsx +23 -13
- package/packages/bundled-features/src/auth-mfa/web/mfa-regenerate-recovery-dialog.tsx +23 -13
- package/packages/bundled-features/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +15 -6
- package/packages/bundled-features/src/auth-mfa/web/mfa-verify-screen.tsx +65 -18
- package/packages/bundled-features/src/billing-foundation/changes.json +55 -0
- package/packages/bundled-features/src/billing-foundation/checkout-core.ts +32 -42
- package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +8 -2
- package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts +2 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/issue-confirmation.ts +14 -3
- package/packages/bundled-features/src/billing-foundation/consumer-protection/mail-notifications.ts +3 -4
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-receipt-limit.ts +12 -6
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +35 -19
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +12 -8
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-revoke.ts +14 -8
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +5 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/withdrawal-period.ts +8 -10
- package/packages/bundled-features/src/billing-foundation/events.ts +1 -0
- package/packages/bundled-features/src/billing-foundation/feature.ts +3 -1
- package/packages/bundled-features/src/billing-foundation/handlers/billing-plans.query.ts +71 -6
- package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +1 -0
- package/packages/bundled-features/src/billing-foundation/handlers/plan-switch-preview.query.ts +65 -0
- package/packages/bundled-features/src/billing-foundation/handlers/process-event.write.ts +9 -2
- package/packages/bundled-features/src/billing-foundation/handlers/start-plan-checkout.write.ts +44 -8
- package/packages/bundled-features/src/billing-foundation/handlers/switch-plan.write.ts +72 -82
- package/packages/bundled-features/src/billing-foundation/handlers/sync-subscription.write.ts +14 -1
- package/packages/bundled-features/src/billing-foundation/i18n.ts +22 -0
- package/packages/bundled-features/src/billing-foundation/index.ts +2 -0
- package/packages/bundled-features/src/billing-foundation/plan-catalog.ts +62 -27
- package/packages/bundled-features/src/billing-foundation/plan-switch.ts +84 -0
- package/packages/bundled-features/src/billing-foundation/types.ts +80 -1
- package/packages/bundled-features/src/billing-foundation/validate-options.ts +19 -0
- package/packages/bundled-features/src/billing-foundation/web/billing-plans-panel.tsx +119 -47
- package/packages/bundled-features/src/billing-foundation/web/checkout-consent-dialog.tsx +89 -42
- package/packages/bundled-features/src/billing-foundation/web/constants.ts +4 -0
- package/packages/bundled-features/src/billing-foundation/web/plan-price.ts +55 -0
- package/packages/bundled-features/src/billing-foundation/web/plan-switch-confirm-dialog.tsx +224 -0
- package/packages/bundled-features/src/cap-counter/cap-reservation.ts +25 -8
- package/packages/bundled-features/src/cap-counter/changes.json +30 -0
- package/packages/bundled-features/src/cap-counter/enforce-cap.ts +2 -1
- package/packages/bundled-features/src/cap-counter/feature.ts +1 -0
- package/packages/bundled-features/src/cap-counter/stock-cap-guard.ts +8 -1
- package/packages/bundled-features/src/cap-overview/feature.ts +1 -0
- package/packages/bundled-features/src/channel-email/changes.json +6 -0
- package/packages/bundled-features/src/channel-email/feature.ts +1 -1
- package/packages/bundled-features/src/config/changes.json +18 -0
- package/packages/bundled-features/src/config/handlers/cascade.query.ts +4 -2
- package/packages/bundled-features/src/config/handlers/set.write.ts +19 -2
- package/packages/bundled-features/src/config/handlers/values.query.ts +4 -2
- package/packages/bundled-features/src/config/write-helpers.ts +27 -8
- package/packages/bundled-features/src/crypto-shredding/changes.json +18 -0
- package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +11 -10
- package/packages/bundled-features/src/custom-fields/lib/parse-serialized-field.ts +1 -1
- package/packages/bundled-features/src/delivery/changes.json +42 -0
- package/packages/bundled-features/src/delivery/channel-context.ts +9 -3
- package/packages/bundled-features/src/delivery/db/queries/preferences.ts +2 -2
- package/packages/bundled-features/src/delivery/delivery-service.ts +35 -5
- package/packages/bundled-features/src/delivery/feature.ts +1 -0
- package/packages/bundled-features/src/delivery/handlers/preferences.query.ts +20 -1
- package/packages/bundled-features/src/delivery/handlers/unsubscribe-page-chrome.query.ts +1 -1
- package/packages/bundled-features/src/delivery/index.ts +5 -0
- package/packages/bundled-features/src/delivery/system-channel-context.ts +33 -0
- package/packages/bundled-features/src/delivery/unsubscribe-link.ts +3 -1
- package/packages/bundled-features/src/delivery/unsubscribe-page-strings.ts +1 -0
- package/packages/bundled-features/src/delivery/unsubscribe-page.ts +6 -0
- package/packages/bundled-features/src/delivery/unsubscribe.ts +36 -15
- package/packages/bundled-features/src/document-ingest-foundation/append-missing-extract-event.ts +84 -0
- package/packages/bundled-features/src/document-ingest-foundation/changes.json +6 -0
- package/packages/bundled-features/src/document-ingest-foundation/events.ts +15 -0
- package/packages/bundled-features/src/document-ingest-foundation/index.ts +11 -0
- package/packages/bundled-features/src/feature-toggles/feature.ts +1 -0
- package/packages/bundled-features/src/file-foundation/index.ts +1 -0
- package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +7 -1
- package/packages/bundled-features/src/file-provider-s3/env-named-feature.ts +0 -2
- package/packages/bundled-features/src/files/README.md +1 -1
- package/packages/bundled-features/src/form-draft/changes.json +6 -0
- package/packages/bundled-features/src/form-draft/db/queries/cleanup.ts +2 -2
- package/packages/bundled-features/src/form-draft/decrypt-draft.ts +4 -1
- package/packages/bundled-features/src/host-routing/README.md +1 -1
- package/packages/bundled-features/src/host-routing/changes.json +12 -0
- package/packages/bundled-features/src/host-routing/dispatch.ts +16 -17
- package/packages/bundled-features/src/host-routing/options.ts +2 -1
- package/packages/bundled-features/src/host-routing/validate-options.ts +5 -0
- package/packages/bundled-features/src/inbound-mail-foundation/changes.json +18 -0
- package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +1 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/update-account.write.ts +17 -5
- package/packages/bundled-features/src/inbound-mail-foundation/i18n.ts +0 -1
- package/packages/bundled-features/src/inbound-mail-foundation/screens.ts +0 -3
- package/packages/bundled-features/src/inbound-mail-foundation/types.ts +4 -3
- package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +32 -16
- package/packages/bundled-features/src/jobs/changes.json +13 -0
- package/packages/bundled-features/src/jobs/feature.ts +15 -4
- package/packages/bundled-features/src/jobs/handlers/tenant-failures.query.ts +45 -40
- package/packages/bundled-features/src/jobs/handlers/tenant-runs.query.ts +63 -59
- package/packages/bundled-features/src/jobs/job-run-logger.ts +5 -4
- package/packages/bundled-features/src/ledger/web/index.ts +1 -1
- package/packages/bundled-features/src/legal-pages/README.md +1 -1
- package/packages/bundled-features/src/legal-pages/seed.ts +5 -2
- package/packages/bundled-features/src/mail-foundation/changes.json +6 -0
- package/packages/bundled-features/src/mail-foundation/feature.ts +5 -3
- package/packages/bundled-features/src/mail-transport-smtp/changes.json +6 -0
- package/packages/bundled-features/src/mail-transport-smtp/config-keys.ts +1 -1
- package/packages/bundled-features/src/mail-transport-smtp/feature.ts +9 -11
- package/packages/bundled-features/src/managed-pages/changes.json +6 -0
- package/packages/bundled-features/src/managed-pages/constants.ts +1 -0
- package/packages/bundled-features/src/managed-pages/feature.ts +3 -1
- package/packages/bundled-features/src/managed-pages/index.ts +1 -0
- package/packages/bundled-features/src/notes-history/handlers/add-note.write.ts +1 -1
- package/packages/bundled-features/src/personal-access-tokens/feature.ts +5 -1
- package/packages/bundled-features/src/renderer-pdf/changes.json +6 -0
- package/packages/bundled-features/src/renderer-pdf/render-table-pdf.ts +27 -10
- package/packages/bundled-features/src/secrets/changes.json +6 -0
- package/packages/bundled-features/src/secrets/write-gate.ts +9 -2
- package/packages/bundled-features/src/sessions/changes.json +7 -0
- package/packages/bundled-features/src/sessions/feature.ts +8 -2
- package/packages/bundled-features/src/sessions/index.ts +1 -1
- package/packages/bundled-features/src/sessions/session-callbacks.ts +1 -9
- package/packages/bundled-features/src/shared/decrypt-stored-pii.ts +10 -0
- package/packages/bundled-features/src/shared/index.ts +1 -1
- package/packages/bundled-features/src/shared/mail-template-body.ts +10 -3
- package/packages/bundled-features/src/shared/purge-files-reaching-subject.ts +24 -2
- package/packages/bundled-features/src/shared/run-in-sub-transaction.ts +5 -5
- package/packages/bundled-features/src/step-dispatcher/changes.json +12 -0
- package/packages/bundled-features/src/step-dispatcher/deliver-dispatch-request.ts +17 -3
- package/packages/bundled-features/src/step-dispatcher/feature.ts +1 -0
- package/packages/bundled-features/src/step-dispatcher/mail-runner.ts +2 -1
- package/packages/bundled-features/src/subscription-dodo/changes.json +14 -0
- package/packages/bundled-features/src/subscription-dodo/client.ts +45 -0
- package/packages/bundled-features/src/subscription-dodo/constants.ts +24 -0
- package/packages/bundled-features/src/subscription-dodo/feature.ts +207 -0
- package/packages/bundled-features/src/subscription-dodo/index.ts +16 -0
- package/packages/bundled-features/src/subscription-dodo/plugin-methods.ts +345 -0
- package/packages/bundled-features/src/subscription-dodo/runtime.ts +126 -0
- package/packages/bundled-features/src/subscription-dodo/verify-webhook.ts +262 -0
- package/packages/bundled-features/src/subscription-stripe/changes.json +12 -0
- package/packages/bundled-features/src/subscription-stripe/feature.ts +1 -0
- package/packages/bundled-features/src/subscription-stripe/runtime.ts +7 -6
- package/packages/bundled-features/src/tags/feature.ts +1 -0
- package/packages/bundled-features/src/template-resolver/api.ts +31 -4
- package/packages/bundled-features/src/template-resolver/changes.json +84 -0
- package/packages/bundled-features/src/template-resolver/compile-document-body.ts +1 -0
- package/packages/bundled-features/src/template-resolver/document-ops.ts +20 -1
- package/packages/bundled-features/src/template-resolver/feature.ts +4 -4
- package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +3 -2
- package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +27 -5
- package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +3 -2
- package/packages/bundled-features/src/template-resolver/handlers/document-base.ts +3 -2
- package/packages/bundled-features/src/template-resolver/handlers/find-by-id.query.ts +3 -2
- package/packages/bundled-features/src/template-resolver/handlers/layout-source.query.ts +36 -11
- package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +18 -19
- package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +45 -10
- package/packages/bundled-features/src/template-resolver/handlers/patch-draft.write.ts +1 -1
- package/packages/bundled-features/src/template-resolver/handlers/send-test-status.query.ts +22 -19
- package/packages/bundled-features/src/template-resolver/handlers/tenant-override-counts.query.ts +9 -5
- package/packages/bundled-features/src/template-resolver/handlers/tenant-override-selection.ts +8 -6
- package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +44 -5
- package/packages/bundled-features/src/template-resolver/handlers/usage.query.ts +37 -26
- package/packages/bundled-features/src/template-resolver/handlers/validate.query.ts +7 -2
- package/packages/bundled-features/src/template-resolver/seeding.ts +42 -17
- package/packages/bundled-features/src/template-resolver/store-template-resource.ts +74 -12
- package/packages/bundled-features/src/template-resolver/template-delivery-policy.ts +0 -11
- package/packages/bundled-features/src/template-resolver/template-meta.ts +12 -4
- package/packages/bundled-features/src/template-resolver/template-preview.ts +11 -3
- package/packages/bundled-features/src/template-resolver/web/constants.ts +7 -5
- package/packages/bundled-features/src/template-resolver/web/i18n.ts +6 -0
- package/packages/bundled-features/src/template-resolver/web/index.ts +7 -0
- package/packages/bundled-features/src/template-resolver/web/mail-layout-constants.ts +8 -0
- package/packages/bundled-features/src/template-resolver/web/qualified-names.ts +1 -0
- package/packages/bundled-features/src/tenant/changes.json +6 -0
- package/packages/bundled-features/src/tenant/feature.ts +1 -0
- package/packages/bundled-features/src/tenant/handlers/member-directory.query.ts +9 -3
- package/packages/bundled-features/src/tenant/handlers/team-list.query.ts +3 -3
- package/packages/bundled-features/src/tenant/handlers/tenant-directory.query.ts +7 -4
- package/packages/bundled-features/src/tenant-lifecycle/changes.json +12 -0
- package/packages/bundled-features/src/tenant-lifecycle/constants.ts +5 -0
- package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +5 -1
- package/packages/bundled-features/src/tenant-lifecycle/stages.ts +35 -1
- package/packages/bundled-features/src/tier-engine/changes.json +6 -0
- package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +4 -1
- package/packages/bundled-features/src/tier-engine/web/constants.ts +3 -3
- package/packages/bundled-features/src/user/feature.ts +1 -0
- package/packages/bundled-features/src/user-data-rights/changes.json +12 -0
- package/packages/bundled-features/src/user-data-rights/feature.ts +1 -0
- package/packages/bundled-features/src/user-data-rights/index.ts +6 -1
- package/packages/bundled-features/src/user-data-rights/run-export-jobs.ts +3 -4
- package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +2 -1
- package/packages/bundled-features/src/user-data-rights/web/constants.ts +6 -0
- package/packages/bundled-features/src/user-data-rights/web/index.ts +1 -0
- package/packages/bundled-features/src/waitlist/changes.json +36 -0
- package/packages/bundled-features/src/waitlist/feature.ts +1 -0
- package/packages/bundled-features/src/waitlist/handlers/export-purge.write.ts +2 -1
- package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +28 -9
- package/packages/bundled-features/src/waitlist/import.ts +28 -3
- package/packages/bundled-features/src/waitlist/legacy-migrate.ts +24 -4
- package/packages/bundled-features/src/waitlist/link-on-signup.ts +20 -10
- package/packages/bundled-features/src/waitlist/options.ts +5 -1
- package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +1 -1
- package/samples/apps/styleguide/src/app/client.tsx +2 -0
- package/samples/apps/styleguide/src/app/seed.ts +6 -4
- package/samples/apps/styleguide/src/app/server.ts +4 -1
- package/samples/apps/styleguide/src/features/widgets/feature.ts +15 -4
- package/samples/apps/styleguide/src/features/widgets/i18n.ts +11 -0
- package/samples/apps/styleguide/src/features/widgets/web/Widgets.tsx +24 -0
- package/samples/apps/styleguide/src/features/workboard/feature.ts +185 -0
- package/samples/apps/styleguide/src/features/workboard/i18n.ts +27 -0
- package/samples/apps/styleguide/src/features/workboard/seed.ts +121 -0
- package/samples/apps/styleguide/src/features/workboard/web.ts +13 -0
- package/samples/apps/ui-walkthrough/src/features/rental/i18n.ts +2 -0
- package/samples/apps/ui-walkthrough/src/features/rental/screens.ts +9 -0
- package/samples/apps/use-all-bundled/bin/main.ts +2 -0
- package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-constants.ts +5 -0
- package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +90 -15
- package/samples/apps/use-all-bundled/e2e-billing-gallery/server.ts +16 -6
- package/samples/apps/use-all-bundled/feature-manifest.json +136 -3
- package/samples/apps/use-all-bundled/kumiko/schema.ts +2 -0
- package/samples/apps/use-all-bundled/kumiko.config.ts +2 -0
- package/samples/apps/use-all-bundled/package.json +2 -1
- package/samples/apps/use-all-bundled/src/app/dev-app-options.ts +2 -0
- package/samples/apps/use-all-bundled/src/app/notes-feature.ts +2 -0
- package/samples/apps/use-all-bundled/src/bundled-lists.ts +15 -0
- package/samples/apps/use-all-bundled/src/run-config.ts +9 -3
- package/samples/recipes/auth-mfa/README.md +5 -0
- package/samples/recipes/auth-mfa/package.json +5 -2
- package/samples/recipes/auth-mfa/src/web/confirmation-code-step.tsx +49 -0
- package/samples/recipes/auth-mfa/tsconfig.json +8 -0
- package/samples/recipes/env-composition/README.md +3 -1
- package/samples/recipes/env-composition/src/env.ts +2 -0
- package/samples/recipes/golden-path/src/feature.ts +2 -1
- package/samples/recipes/legal-pages/README.md +1 -1
- package/samples/recipes/lifecycle-hooks/README.md +4 -0
- package/samples/recipes/lifecycle-hooks/src/feature.ts +26 -1
- package/samples/recipes/list-table-options/README.md +50 -0
- package/samples/recipes/list-table-options/package.json +11 -0
- package/samples/recipes/list-table-options/src/feature.ts +221 -0
- package/samples/recipes/pdf-audit-export/src/feature.ts +2 -1
- package/samples/recipes/rate-limiting/README.md +18 -0
- package/samples/recipes/rate-limiting/src/feature.ts +12 -0
- package/samples/recipes/state-machine/src/events.ts +3 -2
- package/samples/recipes/tenant-isolation/README.md +1 -0
- package/samples/recipes/tenant-isolation/src/feature.ts +36 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/stored-user-email.ts +0 -12
|
@@ -1,109 +1,99 @@
|
|
|
1
1
|
// switch-plan — a tenant-admin/purchase-role switches an existing,
|
|
2
|
-
// non-terminal subscription to a different plan tier
|
|
3
|
-
//
|
|
4
|
-
//
|
|
2
|
+
// non-terminal subscription to a different plan tier. A provider with a hosted
|
|
3
|
+
// switch session answers `{ url }`; a provider that switches in-app (preview +
|
|
4
|
+
// apply) applies the switch right here and answers `{ applied: true }`.
|
|
5
|
+
// Only registered when `createBillingFoundationFeature` gets a `catalog`.
|
|
5
6
|
|
|
6
|
-
import type { WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
-
import {
|
|
7
|
+
import type { HandlerContext, WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
+
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
8
9
|
import * as z from "zod";
|
|
10
|
+
import { assertRedirectOrigins, isNonEmptyStringArray, joinBaseUrl } from "../checkout-core.ts";
|
|
9
11
|
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
} from "../
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import type { BillingFoundationOptions, BillingPlanCatalog } from "../types.ts";
|
|
19
|
-
import { isSwitchableSubscriptionStatus } from "../web/constants.ts";
|
|
12
|
+
type ConsentPayload,
|
|
13
|
+
consentPayloadSchema,
|
|
14
|
+
prepareConsent,
|
|
15
|
+
recordConsent,
|
|
16
|
+
} from "../consumer-protection/consent-gate.ts";
|
|
17
|
+
import { purchaseRolesOf } from "../plan-catalog.ts";
|
|
18
|
+
import { type PreparedPlanSwitch, preparePlanSwitch } from "../plan-switch.ts";
|
|
19
|
+
import type { BillingPlanCatalog, ResolvedBillingFoundationOptions } from "../types.ts";
|
|
20
20
|
|
|
21
21
|
export function createSwitchPlanHandler(
|
|
22
|
-
options:
|
|
22
|
+
options: ResolvedBillingFoundationOptions,
|
|
23
23
|
catalog: BillingPlanCatalog,
|
|
24
24
|
): WriteHandlerDef {
|
|
25
25
|
const plans = catalog.plans;
|
|
26
26
|
if (!isNonEmptyStringArray(plans)) {
|
|
27
27
|
throw new Error("switch-plan: catalog.plans must not be empty");
|
|
28
28
|
}
|
|
29
|
-
const
|
|
29
|
+
const consumerProtection = options.consumerProtection;
|
|
30
|
+
const tierSchema = z.object({ tier: z.enum(plans) });
|
|
31
|
+
const schema = (
|
|
32
|
+
consumerProtection
|
|
33
|
+
? tierSchema.extend({ consent: consentPayloadSchema.optional() })
|
|
34
|
+
: tierSchema
|
|
35
|
+
).strict();
|
|
30
36
|
|
|
31
37
|
return {
|
|
32
38
|
name: "switch-plan",
|
|
33
39
|
description:
|
|
34
|
-
"
|
|
40
|
+
"Switches the existing subscription to another plan tier: opens the provider's confirmation page (answers { url }), or applies the switch in-app for providers that preview and apply themselves (answers { applied: true }; send consent when consumer protection is configured).",
|
|
35
41
|
schema,
|
|
36
42
|
access: { roles: purchaseRolesOf(catalog) },
|
|
37
43
|
handler: async (event, ctx) => {
|
|
38
44
|
// @cast-boundary engine-payload — dispatcher-zod-validated payload
|
|
39
|
-
const payload = event.payload as { tier: string };
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
catalog,
|
|
43
|
-
"switch-plan",
|
|
44
|
-
);
|
|
45
|
-
await assertBillingEnabled(ctx, plugin, "switch-plan");
|
|
45
|
+
const payload = event.payload as { tier: string; consent?: ConsentPayload };
|
|
46
|
+
const prepared = await preparePlanSwitch(ctx, catalog, payload.tier, "switch-plan");
|
|
47
|
+
const { plugin } = prepared.provider;
|
|
46
48
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
throw new ConflictError({
|
|
57
|
-
i18nKey: "billing-foundation.errors.cancellationScheduled",
|
|
58
|
-
message:
|
|
59
|
-
"subscription has a scheduled cancellation; reactivate it via billing-foundation:write:create-portal-session before switching plans",
|
|
60
|
-
});
|
|
61
|
-
}
|
|
62
|
-
if (payload.tier === sub.tier) {
|
|
63
|
-
throw new ConflictError({
|
|
64
|
-
i18nKey: "billing-foundation.errors.alreadyOnPlan",
|
|
65
|
-
message: `tenant is already on plan "${payload.tier}"`,
|
|
66
|
-
});
|
|
67
|
-
}
|
|
68
|
-
// providerSubscriptionId only exists at the subscription's own provider; after a
|
|
69
|
-
// provider migration the catalog provider can differ, which is not a plan switch.
|
|
70
|
-
if (sub.providerName !== catalogProviderName) {
|
|
71
|
-
throw new ConflictError({
|
|
72
|
-
i18nKey: "billing-foundation.errors.providerMismatch",
|
|
73
|
-
message: `tenant's active subscription is on provider "${sub.providerName}", the catalog resolves provider "${catalogProviderName}" — switch-plan cannot switch across providers`,
|
|
74
|
-
});
|
|
75
|
-
}
|
|
76
|
-
if (!plugin.createPlanSwitchSession) {
|
|
77
|
-
throw new UnprocessableError("plan_switch_not_supported", {
|
|
78
|
-
i18nKey: "billing-foundation.errors.planSwitchNotSupported",
|
|
79
|
-
message: `subscription-foundation: provider "${sub.providerName}" has no createPlanSwitchSession-method`,
|
|
49
|
+
if (plugin.createPlanSwitchSession) {
|
|
50
|
+
const baseUrl = options.baseUrl ?? "";
|
|
51
|
+
const returnUrl = joinBaseUrl(baseUrl, catalog.returnPath ?? catalog.successPath);
|
|
52
|
+
assertRedirectOrigins([returnUrl], options.baseUrl);
|
|
53
|
+
const result = await plugin.createPlanSwitchSession(ctx, {
|
|
54
|
+
providerSubscriptionId: prepared.subscription.providerSubscriptionId,
|
|
55
|
+
targetPriceId: prepared.target.priceId,
|
|
56
|
+
allowedPriceIds: prepared.allowedPriceIds,
|
|
57
|
+
returnUrl,
|
|
80
58
|
});
|
|
59
|
+
return { isSuccess: true as const, data: { url: result.url } };
|
|
81
60
|
}
|
|
82
61
|
|
|
83
|
-
|
|
84
|
-
const target = prices.get(payload.tier);
|
|
85
|
-
if (!target) {
|
|
86
|
-
throw new UnprocessableError("price_unavailable", {
|
|
87
|
-
i18nKey: "billing-foundation.errors.priceUnavailable",
|
|
88
|
-
message: `subscription-foundation: no live price resolved for tier "${payload.tier}"`,
|
|
89
|
-
});
|
|
90
|
-
}
|
|
91
|
-
const allowedPriceIds = [...prices.values()]
|
|
92
|
-
.filter((p): p is NonNullable<typeof p> => p !== null)
|
|
93
|
-
.map((p) => p.priceId);
|
|
94
|
-
|
|
95
|
-
const baseUrl = options.baseUrl ?? "";
|
|
96
|
-
const returnUrl = joinBaseUrl(baseUrl, catalog.returnPath ?? catalog.successPath);
|
|
97
|
-
assertRedirectOrigins([returnUrl], options.baseUrl);
|
|
98
|
-
|
|
99
|
-
const result = await plugin.createPlanSwitchSession(ctx, {
|
|
100
|
-
providerSubscriptionId: sub.providerSubscriptionId,
|
|
101
|
-
targetPriceId: target.priceId,
|
|
102
|
-
allowedPriceIds,
|
|
103
|
-
returnUrl,
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
return { isSuccess: true as const, data: { url: result.url } };
|
|
62
|
+
return applyInApp(prepared, payload, ctx, options);
|
|
107
63
|
},
|
|
108
64
|
};
|
|
109
65
|
}
|
|
66
|
+
|
|
67
|
+
// Consent is recorded before the provider call: the apply carries the consentId
|
|
68
|
+
// as provider metadata, and a failing apply rolls the whole handler back.
|
|
69
|
+
async function applyInApp(
|
|
70
|
+
prepared: PreparedPlanSwitch,
|
|
71
|
+
payload: { readonly tier: string; readonly consent?: ConsentPayload },
|
|
72
|
+
ctx: HandlerContext,
|
|
73
|
+
options: ResolvedBillingFoundationOptions,
|
|
74
|
+
) {
|
|
75
|
+
const { plugin, name: providerName } = prepared.provider;
|
|
76
|
+
if (!plugin.applyPlanSwitch) {
|
|
77
|
+
throw new InternalError({
|
|
78
|
+
message: `billing-foundation: provider "${providerName}" has neither createPlanSwitchSession nor applyPlanSwitch`,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
const consent = options.consumerProtection
|
|
82
|
+
? await prepareConsent(ctx, options.consumerProtection, payload.consent, "subscription")
|
|
83
|
+
: undefined;
|
|
84
|
+
if (consent) {
|
|
85
|
+
await recordConsent(ctx, consent, {
|
|
86
|
+
tier: payload.tier,
|
|
87
|
+
priceId: prepared.target.priceId,
|
|
88
|
+
price: prepared.target.price,
|
|
89
|
+
providerName,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
await plugin.applyPlanSwitch(ctx, {
|
|
93
|
+
providerSubscriptionId: prepared.subscription.providerSubscriptionId,
|
|
94
|
+
targetPriceId: prepared.target.priceId,
|
|
95
|
+
tenantId: ctx.user.tenantId,
|
|
96
|
+
...(consent && { consentId: consent.consentId }),
|
|
97
|
+
});
|
|
98
|
+
return { isSuccess: true as const, data: { applied: true as const } };
|
|
99
|
+
}
|
package/packages/bundled-features/src/billing-foundation/handlers/sync-subscription.write.ts
CHANGED
|
@@ -72,6 +72,18 @@ function syncProviderEventId(
|
|
|
72
72
|
return `sync:${createHash("sha256").update(hashInput).digest("hex")}`;
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
// The retrieval time stands in for the provider's event time, so a webhook emitted before
|
|
76
|
+
// this snapshot cannot roll the synced state back later. Provider webhook times are
|
|
77
|
+
// whole-second and come from the provider's clock, so the stamp is floored and shifted back:
|
|
78
|
+
// a genuine webhook right after the sync must not look older than the snapshot.
|
|
79
|
+
const SYNC_SNAPSHOT_EVENT_TIME_MARGIN_SECONDS = 5;
|
|
80
|
+
|
|
81
|
+
function snapshotEventTime(): Temporal.Instant {
|
|
82
|
+
return Temporal.Now.instant()
|
|
83
|
+
.round({ smallestUnit: "second", roundingMode: "floor" })
|
|
84
|
+
.subtract({ seconds: SYNC_SNAPSHOT_EVENT_TIME_MARGIN_SECONDS });
|
|
85
|
+
}
|
|
86
|
+
|
|
75
87
|
export const syncSubscriptionHandler: WriteHandlerDef = {
|
|
76
88
|
name: "sync-subscription",
|
|
77
89
|
agent: { expose: false },
|
|
@@ -143,11 +155,12 @@ export const syncSubscriptionHandler: WriteHandlerDef = {
|
|
|
143
155
|
tier: snapshot.tier,
|
|
144
156
|
currentPeriodEndIso: snapshot.currentPeriodEnd,
|
|
145
157
|
cancelAtIso: snapshot.cancelAt,
|
|
158
|
+
providerEventAtIso: snapshotEventTime().toString(),
|
|
146
159
|
});
|
|
147
160
|
|
|
148
161
|
return {
|
|
149
162
|
isSuccess: true as const,
|
|
150
|
-
data: (appendResult.duplicate
|
|
163
|
+
data: (appendResult.duplicate || appendResult.stale
|
|
151
164
|
? { synced: false, reason: "unchanged" }
|
|
152
165
|
: { synced: true }) satisfies SyncSubscriptionResult,
|
|
153
166
|
};
|
|
@@ -25,6 +25,20 @@ export const BILLING_FOUNDATION_I18N: Readonly<Record<string, LocalizedString>>
|
|
|
25
25
|
"billing-foundation.plans.everyInterval.year": { en: "every {count} years" },
|
|
26
26
|
"billing-foundation.plans.choose": { en: "Choose {plan}" },
|
|
27
27
|
"billing-foundation.plans.switch": { en: "Switch to {plan}" },
|
|
28
|
+
"billing-foundation.plans.planSwitchRequested": {
|
|
29
|
+
en: "Plan switch requested. It becomes active once the payment is confirmed.",
|
|
30
|
+
},
|
|
31
|
+
"billing-foundation.planSwitch.title": { en: "Confirm plan switch" },
|
|
32
|
+
"billing-foundation.planSwitch.summary": { en: "{from} to {to}" },
|
|
33
|
+
"billing-foundation.planSwitch.dueNow": { en: "Due now: {amount}" },
|
|
34
|
+
"billing-foundation.planSwitch.includesTax": { en: "including {tax} VAT" },
|
|
35
|
+
"billing-foundation.planSwitch.credit": { en: "Credit for the unused time: {amount}" },
|
|
36
|
+
"billing-foundation.planSwitch.recurring": { en: "New recurring price: {amount}" },
|
|
37
|
+
"billing-foundation.planSwitch.effective": { en: "Effective immediately" },
|
|
38
|
+
"billing-foundation.planSwitch.pendingNote": {
|
|
39
|
+
en: "The switch becomes active after payment confirmation.",
|
|
40
|
+
},
|
|
41
|
+
"billing-foundation.planSwitch.order": { en: "Switch with obligation to pay" },
|
|
28
42
|
"billing-foundation.plans.manage": { en: "Manage subscription" },
|
|
29
43
|
"billing-foundation.plans.reactivate": { en: "Reactivate subscription" },
|
|
30
44
|
"billing-foundation.plans.cancelScheduled": {
|
|
@@ -70,6 +84,8 @@ export const BILLING_FOUNDATION_I18N: Readonly<Record<string, LocalizedString>>
|
|
|
70
84
|
"billing-foundation.consent.link.withdrawal": { en: "Withdrawal policy" },
|
|
71
85
|
"billing-foundation.consent.link.privacy": { en: "Privacy policy" },
|
|
72
86
|
"billing-foundation.consent.order": { en: "Order with obligation to pay" },
|
|
87
|
+
"billing-foundation.plans.priceFrom": { en: "from" },
|
|
88
|
+
"billing-foundation.consent.chooseProvider": { en: "Payment provider" },
|
|
73
89
|
"billing-foundation.consent.back": { en: "Back" },
|
|
74
90
|
"billing-foundation.cancel.open": { en: "Cancel contract here" },
|
|
75
91
|
"billing-foundation.cancel.title": { en: "Cancel contract" },
|
|
@@ -130,6 +146,12 @@ export const BILLING_FOUNDATION_I18N: Readonly<Record<string, LocalizedString>>
|
|
|
130
146
|
"billing-foundation.errors.providerMismatch": {
|
|
131
147
|
en: "This tenant's subscription is on a different provider than the plan catalog. Contact support to migrate.",
|
|
132
148
|
},
|
|
149
|
+
"billing-foundation.errors.providerNotOffered": {
|
|
150
|
+
en: "This payment provider is not available right now.",
|
|
151
|
+
},
|
|
152
|
+
"billing-foundation.errors.providerRequired": {
|
|
153
|
+
en: "Choose a payment provider to continue.",
|
|
154
|
+
},
|
|
133
155
|
"billing-foundation.errors.priceUnavailable": {
|
|
134
156
|
en: "This plan's price is temporarily unavailable.",
|
|
135
157
|
},
|
|
@@ -62,10 +62,12 @@ export {
|
|
|
62
62
|
type BillingPlanPrice,
|
|
63
63
|
type BillingPlansResult,
|
|
64
64
|
type BillingPlanView,
|
|
65
|
+
type BillingProviderView,
|
|
65
66
|
type ConsumerProtectionOptions,
|
|
66
67
|
KNOWN_RECURRING_INTERVALS,
|
|
67
68
|
type LegalLinkSet,
|
|
68
69
|
type PaymentEvent,
|
|
70
|
+
type PlanSwitchPreview,
|
|
69
71
|
type ProviderPrice,
|
|
70
72
|
type ProviderSubscriptionSnapshot,
|
|
71
73
|
type RecurringInterval,
|
|
@@ -6,12 +6,14 @@
|
|
|
6
6
|
import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
7
7
|
import { isPluginBillingEnabled } from "./checkout-core.ts";
|
|
8
8
|
import { getSubscriptionForTenant } from "./get-subscription-for-tenant.ts";
|
|
9
|
-
import
|
|
10
|
-
BillingPlanCatalog,
|
|
11
|
-
BillingPlansResult,
|
|
12
|
-
BillingPlanView,
|
|
13
|
-
|
|
14
|
-
|
|
9
|
+
import {
|
|
10
|
+
type BillingPlanCatalog,
|
|
11
|
+
type BillingPlansResult,
|
|
12
|
+
type BillingPlanView,
|
|
13
|
+
canSwitchPlans,
|
|
14
|
+
canSwitchPlansInApp,
|
|
15
|
+
type ProviderPrice,
|
|
16
|
+
type SubscriptionProviderPlugin,
|
|
15
17
|
} from "./types.ts";
|
|
16
18
|
import {
|
|
17
19
|
BillingPlanActions,
|
|
@@ -51,7 +53,7 @@ function invertPriceToTier(
|
|
|
51
53
|
return byTier;
|
|
52
54
|
}
|
|
53
55
|
|
|
54
|
-
type ResolvedPlanPrice = { readonly priceId: string; readonly price: ProviderPrice } | null;
|
|
56
|
+
export type ResolvedPlanPrice = { readonly priceId: string; readonly price: ProviderPrice } | null;
|
|
55
57
|
|
|
56
58
|
/** Resolves one active, flat-amount price per plan tier. 0 or >1 active
|
|
57
59
|
* flat-amount prices for a tier is ambiguous — the caller can't tell which
|
|
@@ -120,7 +122,7 @@ function canSwitchToTier(
|
|
|
120
122
|
isSwitchableSubscriptionStatus(subscription.status) &&
|
|
121
123
|
tier !== subscription.tier &&
|
|
122
124
|
subscription.cancelAt === null &&
|
|
123
|
-
plugin
|
|
125
|
+
canSwitchPlans(plugin)
|
|
124
126
|
);
|
|
125
127
|
}
|
|
126
128
|
|
|
@@ -157,37 +159,60 @@ function resolvePlanAction(
|
|
|
157
159
|
: BillingPlanActions.unavailable;
|
|
158
160
|
}
|
|
159
161
|
|
|
160
|
-
/**
|
|
161
|
-
*
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
162
|
+
/** Tenant-level state every provider's plan list is measured against; loaded
|
|
163
|
+
* once so a multi-provider result does not repeat the reads per provider. */
|
|
164
|
+
export type BillingPlansBase = {
|
|
165
|
+
readonly currentTierValue: string;
|
|
166
|
+
readonly subscriptionView: Awaited<ReturnType<typeof getSubscriptionForTenant>>;
|
|
167
|
+
readonly subscription: BillingPlansResult["subscription"];
|
|
168
|
+
readonly blockingSubscription: boolean;
|
|
169
|
+
readonly canPurchase: boolean;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
export async function loadBillingPlansBase(
|
|
165
173
|
ctx: HandlerContext,
|
|
166
|
-
plugin: SubscriptionProviderPlugin | null,
|
|
167
174
|
catalog: BillingPlanCatalog,
|
|
168
175
|
now: () => Temporal.Instant,
|
|
169
|
-
): Promise<
|
|
176
|
+
): Promise<BillingPlansBase> {
|
|
170
177
|
const currentTierValue = await catalog.resolveCurrentTier(ctx.db, ctx.user.tenantId);
|
|
171
|
-
const enabled = plugin !== null && (await isPluginBillingEnabled(ctx, plugin));
|
|
172
|
-
const resolvedPrices =
|
|
173
|
-
enabled && plugin
|
|
174
|
-
? await resolvePlanPrices(ctx, plugin, catalog)
|
|
175
|
-
: new Map<string, ResolvedPlanPrice>(catalog.plans.map((tier) => [tier, null]));
|
|
176
|
-
|
|
177
178
|
const subscriptionView = await getSubscriptionForTenant(ctx, ctx.user.tenantId);
|
|
178
|
-
const
|
|
179
|
+
const blockingSubscription =
|
|
180
|
+
subscriptionView !== null && isSubscriptionBlockingCheckout(subscriptionView, now());
|
|
179
181
|
const subscription = subscriptionView
|
|
180
182
|
? {
|
|
181
183
|
status: subscriptionView.status,
|
|
182
184
|
tier: subscriptionView.tier,
|
|
183
|
-
terminal: !
|
|
185
|
+
terminal: !blockingSubscription,
|
|
184
186
|
currentPeriodEnd: subscriptionView.currentPeriodEnd.toString(),
|
|
185
187
|
cancelAt: subscriptionView.cancelAt?.toString() ?? null,
|
|
186
188
|
}
|
|
187
189
|
: null;
|
|
190
|
+
const canPurchase = userHasAnyRole(ctx.user.roles, purchaseRolesOf(catalog));
|
|
191
|
+
return { currentTierValue, subscriptionView, subscription, blockingSubscription, canPurchase };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export type ProviderPlanViews = {
|
|
195
|
+
readonly enabled: boolean;
|
|
196
|
+
readonly inAppSwitch: boolean;
|
|
197
|
+
readonly plans: readonly BillingPlanView[];
|
|
198
|
+
};
|
|
188
199
|
|
|
189
|
-
|
|
190
|
-
|
|
200
|
+
/** `plugin === null` — no provider is registered/resolvable for this
|
|
201
|
+
* catalog (see checkout-core's `listCatalogProviders`) — collapses onto the
|
|
202
|
+
* same "enabled: false" result a disabled provider produces, rather than
|
|
203
|
+
* a separate error shape the panel would need to special-case. */
|
|
204
|
+
export async function buildProviderPlanViews(
|
|
205
|
+
ctx: HandlerContext,
|
|
206
|
+
plugin: SubscriptionProviderPlugin | null,
|
|
207
|
+
catalog: BillingPlanCatalog,
|
|
208
|
+
base: BillingPlansBase,
|
|
209
|
+
): Promise<ProviderPlanViews> {
|
|
210
|
+
const { currentTierValue, subscription, canPurchase } = base;
|
|
211
|
+
const enabled = plugin !== null && (await isPluginBillingEnabled(ctx, plugin));
|
|
212
|
+
const resolvedPrices =
|
|
213
|
+
enabled && plugin
|
|
214
|
+
? await resolvePlanPrices(ctx, plugin, catalog)
|
|
215
|
+
: new Map<string, ResolvedPlanPrice>(catalog.plans.map((tier) => [tier, null]));
|
|
191
216
|
|
|
192
217
|
const plans: BillingPlanView[] = catalog.plans.map((tier) => {
|
|
193
218
|
const isCurrent = tier === currentTierValue;
|
|
@@ -221,8 +246,17 @@ export async function buildBillingPlans(
|
|
|
221
246
|
};
|
|
222
247
|
});
|
|
223
248
|
|
|
249
|
+
return { enabled, inAppSwitch: plugin !== null && canSwitchPlansInApp(plugin), plans };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function toBillingPlansResult(
|
|
253
|
+
catalog: BillingPlanCatalog,
|
|
254
|
+
base: BillingPlansBase,
|
|
255
|
+
views: ProviderPlanViews,
|
|
256
|
+
): BillingPlansResult {
|
|
257
|
+
const { currentTierValue, subscription, canPurchase } = base;
|
|
224
258
|
return {
|
|
225
|
-
enabled,
|
|
259
|
+
enabled: views.enabled,
|
|
226
260
|
currentTier: {
|
|
227
261
|
tier: currentTierValue,
|
|
228
262
|
labelKey: catalog.tierLabelKey(currentTierValue),
|
|
@@ -230,7 +264,8 @@ export async function buildBillingPlans(
|
|
|
230
264
|
},
|
|
231
265
|
subscription,
|
|
232
266
|
canPurchase,
|
|
233
|
-
plans,
|
|
267
|
+
plans: views.plans,
|
|
268
|
+
...(views.inAppSwitch && { inAppPlanSwitch: true as const }),
|
|
234
269
|
...(catalog.contact !== undefined && { contact: catalog.contact }),
|
|
235
270
|
};
|
|
236
271
|
}
|
|
@@ -0,0 +1,84 @@
|
|
|
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
|
+
|
|
5
|
+
import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
6
|
+
import { ConflictError, UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
|
|
7
|
+
import {
|
|
8
|
+
assertBillingEnabled,
|
|
9
|
+
findProviderPlugin,
|
|
10
|
+
listCatalogProviders,
|
|
11
|
+
type ResolvedProvider,
|
|
12
|
+
} from "./checkout-core.ts";
|
|
13
|
+
import { getSubscriptionForTenant, type SubscriptionView } from "./get-subscription-for-tenant.ts";
|
|
14
|
+
import { type ResolvedPlanPrice, resolvePlanPrices } from "./plan-catalog.ts";
|
|
15
|
+
import { type BillingPlanCatalog, canSwitchPlans } from "./types.ts";
|
|
16
|
+
import { isSwitchableSubscriptionStatus } from "./web/constants.ts";
|
|
17
|
+
|
|
18
|
+
export type PreparedPlanSwitch = {
|
|
19
|
+
readonly provider: ResolvedProvider;
|
|
20
|
+
readonly subscription: SubscriptionView;
|
|
21
|
+
readonly target: NonNullable<ResolvedPlanPrice>;
|
|
22
|
+
readonly allowedPriceIds: readonly string[];
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export async function preparePlanSwitch(
|
|
26
|
+
ctx: HandlerContext,
|
|
27
|
+
catalog: BillingPlanCatalog,
|
|
28
|
+
tier: string,
|
|
29
|
+
handlerName: string,
|
|
30
|
+
): Promise<PreparedPlanSwitch> {
|
|
31
|
+
const subscription = await getSubscriptionForTenant(ctx, ctx.user.tenantId);
|
|
32
|
+
if (!subscription || !isSwitchableSubscriptionStatus(subscription.status)) {
|
|
33
|
+
throw new ConflictError({
|
|
34
|
+
i18nKey: "billing-foundation.errors.noActiveSubscription",
|
|
35
|
+
message:
|
|
36
|
+
"tenant has no switchable subscription; use billing-foundation:write:start-plan-checkout to start one",
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
// providerSubscriptionId only exists at the subscription's own provider; a catalog that
|
|
40
|
+
// no longer sells through that provider cannot switch it.
|
|
41
|
+
const provider = findProviderPlugin(ctx, subscription.providerName);
|
|
42
|
+
const isCatalogProvider =
|
|
43
|
+
provider !== null &&
|
|
44
|
+
listCatalogProviders(ctx, catalog).some((candidate) => candidate.name === provider.name);
|
|
45
|
+
if (!provider || !isCatalogProvider) {
|
|
46
|
+
throw new ConflictError({
|
|
47
|
+
i18nKey: "billing-foundation.errors.providerMismatch",
|
|
48
|
+
message: `tenant's active subscription is on provider "${subscription.providerName}", which the plan catalog does not sell through — switch-plan cannot switch across providers`,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
await assertBillingEnabled(ctx, provider.plugin, handlerName);
|
|
52
|
+
if (subscription.cancelAt !== null) {
|
|
53
|
+
throw new ConflictError({
|
|
54
|
+
i18nKey: "billing-foundation.errors.cancellationScheduled",
|
|
55
|
+
message:
|
|
56
|
+
"subscription has a scheduled cancellation; reactivate it via billing-foundation:write:create-portal-session before switching plans",
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
if (tier === subscription.tier) {
|
|
60
|
+
throw new ConflictError({
|
|
61
|
+
i18nKey: "billing-foundation.errors.alreadyOnPlan",
|
|
62
|
+
message: `tenant is already on plan "${tier}"`,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
if (!canSwitchPlans(provider.plugin)) {
|
|
66
|
+
throw new UnprocessableError("plan_switch_not_supported", {
|
|
67
|
+
i18nKey: "billing-foundation.errors.planSwitchNotSupported",
|
|
68
|
+
message: `subscription-foundation: provider "${subscription.providerName}" supports neither createPlanSwitchSession nor previewPlanSwitch + applyPlanSwitch`,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const prices = await resolvePlanPrices(ctx, provider.plugin, catalog);
|
|
73
|
+
const target = prices.get(tier);
|
|
74
|
+
if (!target) {
|
|
75
|
+
throw new UnprocessableError("price_unavailable", {
|
|
76
|
+
i18nKey: "billing-foundation.errors.priceUnavailable",
|
|
77
|
+
message: `subscription-foundation: no live price resolved for tier "${tier}"`,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
const allowedPriceIds = [...prices.values()]
|
|
81
|
+
.filter((p): p is NonNullable<typeof p> => p !== null)
|
|
82
|
+
.map((p) => p.priceId);
|
|
83
|
+
return { provider, subscription, target, allowedPriceIds };
|
|
84
|
+
}
|
|
@@ -136,6 +136,9 @@ export type PaymentEvent = {
|
|
|
136
136
|
// =============================================================================
|
|
137
137
|
|
|
138
138
|
export type SubscriptionProviderPlugin = {
|
|
139
|
+
/** Human-readable provider name for the plan panel's provider switch. */
|
|
140
|
+
readonly displayName?: string;
|
|
141
|
+
|
|
139
142
|
/**
|
|
140
143
|
* Verify webhook signature + parse provider-event into normalized
|
|
141
144
|
* form. **Pre-tenant-resolution** — no HandlerContext, because at
|
|
@@ -313,6 +316,32 @@ export type SubscriptionProviderPlugin = {
|
|
|
313
316
|
},
|
|
314
317
|
) => Promise<{ readonly url: string }>;
|
|
315
318
|
|
|
319
|
+
/**
|
|
320
|
+
* In-app plan switch, step 1: what the switch would cost right now. Together
|
|
321
|
+
* with `applyPlanSwitch` an alternative to the hosted `createPlanSwitchSession`.
|
|
322
|
+
*/
|
|
323
|
+
readonly previewPlanSwitch?: (
|
|
324
|
+
ctx: HandlerContext,
|
|
325
|
+
options: {
|
|
326
|
+
readonly providerSubscriptionId: string;
|
|
327
|
+
readonly targetPriceId: string;
|
|
328
|
+
},
|
|
329
|
+
) => Promise<PlanSwitchPreview>;
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* In-app plan switch, step 2: performs the switch at the provider. The
|
|
333
|
+
* state change itself arrives later through the webhook.
|
|
334
|
+
*/
|
|
335
|
+
readonly applyPlanSwitch?: (
|
|
336
|
+
ctx: HandlerContext,
|
|
337
|
+
options: {
|
|
338
|
+
readonly providerSubscriptionId: string;
|
|
339
|
+
readonly targetPriceId: string;
|
|
340
|
+
readonly tenantId: string;
|
|
341
|
+
readonly consentId?: string;
|
|
342
|
+
},
|
|
343
|
+
) => Promise<void>;
|
|
344
|
+
|
|
316
345
|
/**
|
|
317
346
|
* Fetches the live provider-side state of one subscription, for the
|
|
318
347
|
* `sync-subscriptions` backfill job — catches drift (e.g. a `cancel_at`
|
|
@@ -330,6 +359,18 @@ export type SubscriptionProviderPlugin = {
|
|
|
330
359
|
) => Promise<ProviderSubscriptionSnapshot | null>;
|
|
331
360
|
};
|
|
332
361
|
|
|
362
|
+
export type PlanSwitchPreview = {
|
|
363
|
+
/** Charged immediately, smallest currency unit, tax included. */
|
|
364
|
+
readonly amountDueNow: number;
|
|
365
|
+
/** Tax part of `amountDueNow`; null when the provider reports none. */
|
|
366
|
+
readonly tax: number | null;
|
|
367
|
+
/** Lower-case ISO currency code. */
|
|
368
|
+
readonly currency: string;
|
|
369
|
+
/** Credit for the unused time on the old plan, smallest currency unit. */
|
|
370
|
+
readonly credit: number;
|
|
371
|
+
readonly effectiveAt: string;
|
|
372
|
+
};
|
|
373
|
+
|
|
333
374
|
export type ProviderSubscriptionSnapshot = {
|
|
334
375
|
readonly providerCustomerId: string;
|
|
335
376
|
readonly providerSubscriptionId: string;
|
|
@@ -399,7 +440,8 @@ export type BillingPlanCatalog<TTier extends string = string> = {
|
|
|
399
440
|
* `baseUrl` itself when unset) and by `switch-plan` after a plan switch
|
|
400
441
|
* (defaults to `successPath` there instead). */
|
|
401
442
|
readonly returnPath?: string;
|
|
402
|
-
/**
|
|
443
|
+
/** Restricts the catalog to one provider; defaults to every registered
|
|
444
|
+
* provider exposing `priceToTier`. */
|
|
403
445
|
readonly providerName?: string;
|
|
404
446
|
/** Rendered as the last card, no price, visible to `viewRoles` regardless of `purchaseRoles`. */
|
|
405
447
|
readonly contact?: BillingPlanContactCard;
|
|
@@ -429,6 +471,19 @@ export type ConsumerProtectionOptions = {
|
|
|
429
471
|
readonly termsTextBlock: string;
|
|
430
472
|
/** Per-locale VAT note; must carry at least `de` and `en`. */
|
|
431
473
|
readonly vatNote: Readonly<Record<string, string>>;
|
|
474
|
+
/** Per-provider texts keyed by provider name, e.g. for a merchant-of-record
|
|
475
|
+
* provider that issues the invoice and VAT itself. `vatNote` here replaces
|
|
476
|
+
* the global one in that provider's confirmation mail; `checkoutNote` is
|
|
477
|
+
* shown under the provider choice. de and en are required for each text. */
|
|
478
|
+
readonly providerTexts?: Readonly<
|
|
479
|
+
Record<
|
|
480
|
+
string,
|
|
481
|
+
{
|
|
482
|
+
readonly vatNote?: Readonly<Record<string, string>>;
|
|
483
|
+
readonly checkoutNote?: Readonly<Record<string, string>>;
|
|
484
|
+
}
|
|
485
|
+
>
|
|
486
|
+
>;
|
|
432
487
|
readonly operatorEmail: string;
|
|
433
488
|
/** Where the public § 312k declaration is reachable. `"tenant-host"`
|
|
434
489
|
* (default): on a host that resolves a tenant. `"platform"`: also on a
|
|
@@ -490,6 +545,23 @@ export type BillingPlanView = {
|
|
|
490
545
|
readonly action: BillingPlanAction;
|
|
491
546
|
};
|
|
492
547
|
|
|
548
|
+
export type BillingProviderView = {
|
|
549
|
+
readonly name: string;
|
|
550
|
+
readonly displayName: string;
|
|
551
|
+
readonly note?: Readonly<Record<string, string>>;
|
|
552
|
+
readonly plans: readonly BillingPlanView[];
|
|
553
|
+
};
|
|
554
|
+
|
|
555
|
+
export function canSwitchPlansInApp(plugin: SubscriptionProviderPlugin): boolean {
|
|
556
|
+
return plugin.previewPlanSwitch !== undefined && plugin.applyPlanSwitch !== undefined;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** A provider switches plans via a hosted session or via in-app preview + apply. */
|
|
560
|
+
export function canSwitchPlans(plugin: SubscriptionProviderPlugin | null): boolean {
|
|
561
|
+
if (plugin === null) return false;
|
|
562
|
+
return plugin.createPlanSwitchSession !== undefined || canSwitchPlansInApp(plugin);
|
|
563
|
+
}
|
|
564
|
+
|
|
493
565
|
export type BillingPlansResult = {
|
|
494
566
|
readonly enabled: boolean;
|
|
495
567
|
readonly currentTier: {
|
|
@@ -506,6 +578,13 @@ export type BillingPlansResult = {
|
|
|
506
578
|
} | null;
|
|
507
579
|
readonly canPurchase: boolean;
|
|
508
580
|
readonly plans: readonly BillingPlanView[];
|
|
581
|
+
/** Present when more than one provider is offered, or the single offered
|
|
582
|
+
* provider has a `providerTexts` checkout note. `plans` above are the first
|
|
583
|
+
* entry's plans. */
|
|
584
|
+
readonly providers?: readonly BillingProviderView[];
|
|
585
|
+
/** True when the subscription's provider switches plans in-app (preview +
|
|
586
|
+
* confirm) instead of on a hosted page. */
|
|
587
|
+
readonly inAppPlanSwitch?: true;
|
|
509
588
|
/** Present only when `catalog.contact` is configured. */
|
|
510
589
|
readonly contact?: BillingPlanContactCard;
|
|
511
590
|
/** Present only when `consumerProtection` is configured. */
|
|
@@ -28,6 +28,24 @@ function isRootRelativeOrHttpsUrl(link: string): boolean {
|
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
function validateProviderTexts(cp: ConsumerProtectionOptions): void {
|
|
32
|
+
for (const [providerName, texts] of Object.entries(cp.providerTexts ?? {})) {
|
|
33
|
+
for (const [textName, text] of [
|
|
34
|
+
["vatNote", texts.vatNote],
|
|
35
|
+
["checkoutNote", texts.checkoutNote],
|
|
36
|
+
] as const) {
|
|
37
|
+
if (text === undefined) continue;
|
|
38
|
+
for (const locale of ["de", "en"] as const) {
|
|
39
|
+
if (!text[locale]?.trim()) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
`createBillingFoundationFeature: consumerProtection.providerTexts.${providerName}.${textName} needs a non-empty "${locale}" entry.`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
31
49
|
function validateConsumerProtection(cp: ConsumerProtectionOptions): void {
|
|
32
50
|
if (cp.termsTextBlock.trim() === "") {
|
|
33
51
|
throw new Error("createBillingFoundationFeature: consumerProtection.termsTextBlock is empty.");
|
|
@@ -39,6 +57,7 @@ function validateConsumerProtection(cp: ConsumerProtectionOptions): void {
|
|
|
39
57
|
);
|
|
40
58
|
}
|
|
41
59
|
}
|
|
60
|
+
validateProviderTexts(cp);
|
|
42
61
|
if (!z.email().safeParse(cp.operatorEmail).success) {
|
|
43
62
|
throw new Error(
|
|
44
63
|
`createBillingFoundationFeature: consumerProtection.operatorEmail "${cp.operatorEmail}" is not a valid email address.`,
|