@cosmicdrift/kumiko-samples 0.387.0 → 0.388.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/packages/bundled-features/package.json +8 -1
- package/packages/bundled-features/src/agent-tools/changes.json +6 -0
- package/packages/bundled-features/src/agent-tools/tool-dispatch.ts +9 -9
- package/packages/bundled-features/src/audit/changes.json +12 -0
- package/packages/bundled-features/src/audit/handlers/audit-events.ts +1 -1
- package/packages/bundled-features/src/auth-mfa/changes.json +6 -0
- 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/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 +5 -1
- package/packages/bundled-features/src/cap-counter/changes.json +18 -0
- package/packages/bundled-features/src/cap-counter/enforce-cap.ts +2 -1
- package/packages/bundled-features/src/cap-counter/stock-cap-guard.ts +8 -1
- 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 +12 -0
- package/packages/bundled-features/src/config/handlers/cascade.query.ts +4 -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 +6 -0
- package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +10 -9
- package/packages/bundled-features/src/delivery/changes.json +30 -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/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/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/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 +6 -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/inbound-mail-foundation/changes.json +18 -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 +14 -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/legal-pages/README.md +1 -1
- 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/personal-access-tokens/feature.ts +4 -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/sessions/feature.ts +7 -2
- 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/step-dispatcher/changes.json +6 -0
- package/packages/bundled-features/src/step-dispatcher/feature.ts +1 -0
- 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/template-resolver/api.ts +31 -4
- package/packages/bundled-features/src/template-resolver/changes.json +48 -0
- package/packages/bundled-features/src/template-resolver/compile-document-body.ts +1 -0
- package/packages/bundled-features/src/template-resolver/feature.ts +2 -2
- 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/list.query.ts +18 -19
- package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +15 -5
- 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 +32 -14
- package/packages/bundled-features/src/template-resolver/store-template-resource.ts +22 -4
- 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/index.ts +7 -0
- package/packages/bundled-features/src/template-resolver/web/mail-layout-constants.ts +8 -0
- package/packages/bundled-features/src/tenant/changes.json +6 -0
- package/packages/bundled-features/src/tenant/handlers/member-directory.query.ts +9 -3
- package/packages/bundled-features/src/tenant/handlers/tenant-directory.query.ts +7 -4
- package/packages/bundled-features/src/tenant-lifecycle/changes.json +6 -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/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-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/waitlist/changes.json +18 -0
- package/packages/bundled-features/src/waitlist/handlers/export-purge.write.ts +2 -1
- package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +5 -5
- package/packages/bundled-features/src/waitlist/import.ts +5 -0
- package/packages/bundled-features/src/waitlist/legacy-migrate.ts +22 -3
- package/packages/bundled-features/src/waitlist/link-on-signup.ts +20 -10
- package/packages/bundled-features/src/waitlist/options.ts +3 -0
- 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/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 +120 -3
- 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/legal-pages/README.md +1 -1
- package/samples/recipes/rate-limiting/README.md +18 -0
- package/samples/recipes/rate-limiting/src/feature.ts +12 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/stored-user-email.ts +0 -12
|
@@ -36,17 +36,22 @@ export type ResolvedProvider = {
|
|
|
36
36
|
readonly plugin: SubscriptionProviderPlugin;
|
|
37
37
|
};
|
|
38
38
|
|
|
39
|
+
/** Every registered provider plugin, in registration order. */
|
|
40
|
+
function listProviderPlugins(ctx: HandlerContext): readonly ResolvedProvider[] {
|
|
41
|
+
return ctx.registry.getExtensionUsages(SUBSCRIPTION_PROVIDER_EXTENSION).map((usage) => ({
|
|
42
|
+
name: usage.entityName,
|
|
43
|
+
// @cast-boundary engine-payload — extension-usage carries unknown options
|
|
44
|
+
plugin: usage.options as SubscriptionProviderPlugin,
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
47
|
+
|
|
39
48
|
/** Non-throwing provider lookup by name — null when no plugin with that
|
|
40
49
|
* entityName is registered. */
|
|
41
50
|
export function findProviderPlugin(
|
|
42
51
|
ctx: HandlerContext,
|
|
43
52
|
providerName: string,
|
|
44
53
|
): ResolvedProvider | null {
|
|
45
|
-
|
|
46
|
-
.getExtensionUsages(SUBSCRIPTION_PROVIDER_EXTENSION)
|
|
47
|
-
.find((u) => u.entityName === providerName);
|
|
48
|
-
// @cast-boundary engine-payload — extension-usage carries unknown options
|
|
49
|
-
return usage ? { name: providerName, plugin: usage.options as SubscriptionProviderPlugin } : null;
|
|
54
|
+
return listProviderPlugins(ctx).find((provider) => provider.name === providerName) ?? null;
|
|
50
55
|
}
|
|
51
56
|
|
|
52
57
|
export function resolveProviderPlugin(ctx: HandlerContext, providerName: string): ResolvedProvider {
|
|
@@ -64,17 +69,15 @@ export function resolveProviderPlugin(ctx: HandlerContext, providerName: string)
|
|
|
64
69
|
return found;
|
|
65
70
|
}
|
|
66
71
|
|
|
67
|
-
/**
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
|
|
73
|
-
* `UnconfiguredError`. */
|
|
74
|
-
export function findCatalogProvider(
|
|
72
|
+
/** The providers a catalog sells through, in registration order: an explicit
|
|
73
|
+
* `catalog.providerName` (only that one), otherwise every registered provider
|
|
74
|
+
* exposing `priceToTier`. Empty when none qualifies, a state `billing-plans`
|
|
75
|
+
* renders as `enabled: false` instead of erroring. An explicit `providerName`
|
|
76
|
+
* that is not registered is a config bug and throws `UnconfiguredError`. */
|
|
77
|
+
export function listCatalogProviders(
|
|
75
78
|
ctx: HandlerContext,
|
|
76
79
|
catalog: BillingPlanCatalog,
|
|
77
|
-
): ResolvedProvider
|
|
80
|
+
): readonly ResolvedProvider[] {
|
|
78
81
|
if (catalog.providerName) {
|
|
79
82
|
const found = findProviderPlugin(ctx, catalog.providerName);
|
|
80
83
|
if (!found) {
|
|
@@ -84,40 +87,27 @@ export function findCatalogProvider(
|
|
|
84
87
|
hint: `provider "${catalog.providerName}" not registered`,
|
|
85
88
|
});
|
|
86
89
|
}
|
|
87
|
-
return found;
|
|
90
|
+
return [found];
|
|
88
91
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
+
return listProviderPlugins(ctx).filter(({ plugin }) => plugin.priceToTier !== undefined);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function filterOfferedProviders(
|
|
96
|
+
ctx: HandlerContext,
|
|
97
|
+
providers: readonly ResolvedProvider[],
|
|
98
|
+
): Promise<readonly ResolvedProvider[]> {
|
|
99
|
+
const enabledFlags = await Promise.all(
|
|
100
|
+
providers.map(({ plugin }) => isPluginBillingEnabled(ctx, plugin)),
|
|
92
101
|
);
|
|
93
|
-
|
|
94
|
-
throw new UnconfiguredError({
|
|
95
|
-
feature: "billing-foundation",
|
|
96
|
-
key: "catalog.providerName",
|
|
97
|
-
hint: `ambiguous — ${withPriceCatalog.length} registered plugins expose priceToTier, set catalog.providerName explicitly`,
|
|
98
|
-
});
|
|
99
|
-
}
|
|
100
|
-
const usage = withPriceCatalog[0];
|
|
101
|
-
if (!usage) return null;
|
|
102
|
-
// @cast-boundary engine-payload — extension-usage carries unknown options
|
|
103
|
-
return { name: usage.entityName, plugin: usage.options as SubscriptionProviderPlugin };
|
|
102
|
+
return providers.filter((_, index) => enabledFlags[index]);
|
|
104
103
|
}
|
|
105
104
|
|
|
106
|
-
/**
|
|
107
|
-
|
|
108
|
-
* `UnconfiguredError`) when none is found: an unconfigured provider is
|
|
109
|
-
* the same "billing isn't live yet" state as `isPluginBillingEnabled`
|
|
110
|
-
* returning false, and the panel already renders both the same way. */
|
|
111
|
-
export function resolveCatalogProvider(
|
|
105
|
+
/** The catalog providers that are live right now: what a buyer can choose from. */
|
|
106
|
+
export async function listOfferedCatalogProviders(
|
|
112
107
|
ctx: HandlerContext,
|
|
113
108
|
catalog: BillingPlanCatalog,
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
const found = findCatalogProvider(ctx, catalog);
|
|
117
|
-
if (!found) {
|
|
118
|
-
throw new FeatureDisabledError(BILLING_FOUNDATION_FEATURE, handlerName);
|
|
119
|
-
}
|
|
120
|
-
return found;
|
|
109
|
+
): Promise<readonly ResolvedProvider[]> {
|
|
110
|
+
return filterOfferedProviders(ctx, listCatalogProviders(ctx, catalog));
|
|
121
111
|
}
|
|
122
112
|
|
|
123
113
|
/** `plugin.isBillingEnabled?.(ctx) ?? true` — the one place this fallback is
|
package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts
CHANGED
|
@@ -166,8 +166,14 @@ inlineHtmlMarkdown.use({
|
|
|
166
166
|
blockquote({ tokens }) {
|
|
167
167
|
return this.parser.parse(tokens);
|
|
168
168
|
},
|
|
169
|
-
list({ items }) {
|
|
170
|
-
|
|
169
|
+
list({ items, ordered, start }) {
|
|
170
|
+
const firstNumber = typeof start === "number" ? start : 1;
|
|
171
|
+
return items
|
|
172
|
+
.map((item, index) => {
|
|
173
|
+
const marker = ordered ? `${firstNumber + index}. ` : "";
|
|
174
|
+
return `${marker}${this.parser.parse(item.tokens)}`;
|
|
175
|
+
})
|
|
176
|
+
.join("");
|
|
171
177
|
},
|
|
172
178
|
hr: () => "",
|
|
173
179
|
space: () => "",
|
package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts
CHANGED
|
@@ -79,6 +79,7 @@ export type ConsentPriceDetails = {
|
|
|
79
79
|
"unitAmount" | "currency" | "interval" | "intervalCount"
|
|
80
80
|
> | null;
|
|
81
81
|
readonly orderItem?: OrderItem;
|
|
82
|
+
readonly providerName?: string;
|
|
82
83
|
};
|
|
83
84
|
|
|
84
85
|
export async function prepareConsent(
|
|
@@ -163,6 +164,7 @@ export async function recordConsent(
|
|
|
163
164
|
termsTemplateVersion: prepared.termsTemplateVersion,
|
|
164
165
|
locale: prepared.locale,
|
|
165
166
|
actorUserId: String(ctx.user.id),
|
|
167
|
+
...(details.providerName !== undefined && { providerName: details.providerName }),
|
|
166
168
|
...(details.orderItem && {
|
|
167
169
|
itemLabelKey: details.orderItem.labelKey,
|
|
168
170
|
...(details.orderItem.params && { itemLabelParams: details.orderItem.params }),
|
package/packages/bundled-features/src/billing-foundation/consumer-protection/issue-confirmation.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { InternalError, UnprocessableError } from "@cosmicdrift/kumiko-framework
|
|
|
12
12
|
import { createI18n } from "@cosmicdrift/kumiko-framework/i18n";
|
|
13
13
|
import { isValidIanaTimeZone } from "@cosmicdrift/kumiko-framework/time";
|
|
14
14
|
import * as z from "zod";
|
|
15
|
+
import { decryptStoredUserEmail } from "../../shared/index.ts";
|
|
15
16
|
import { requireTemplateResolver, TEXT_BLOCK_KIND } from "../../template-resolver/index.ts";
|
|
16
17
|
import { UserQueries } from "../../user/index.ts";
|
|
17
18
|
import { paymentAggregateId, subscriptionAggregateId } from "../aggregate-id.ts";
|
|
@@ -29,7 +30,6 @@ import type { ConsumerProtectionOptions, ResolvedBillingFoundationOptions } from
|
|
|
29
30
|
import { CONTRACT_CONFIRMATION_NOTIFICATION_TYPE } from "../web/constants.ts";
|
|
30
31
|
import { contractConfirmationVariables } from "./confirmation-mail.ts";
|
|
31
32
|
import { resolveConsentLocale } from "./consent-text.ts";
|
|
32
|
-
import { openStoredUserEmail } from "./stored-user-email.ts";
|
|
33
33
|
|
|
34
34
|
export const issueContractConfirmationSchema = z
|
|
35
35
|
.object({
|
|
@@ -58,6 +58,17 @@ function translatedOrUndefined(translation: string, key: string): string | undef
|
|
|
58
58
|
return translation === key ? undefined : translation;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
function vatNoteFor(
|
|
62
|
+
consumerProtection: ConsumerProtectionOptions,
|
|
63
|
+
providerName: string | undefined,
|
|
64
|
+
): Readonly<Record<string, string>> {
|
|
65
|
+
const providerNote =
|
|
66
|
+
providerName === undefined
|
|
67
|
+
? undefined
|
|
68
|
+
: consumerProtection.providerTexts?.[providerName]?.vatNote;
|
|
69
|
+
return providerNote ?? consumerProtection.vatNote;
|
|
70
|
+
}
|
|
71
|
+
|
|
61
72
|
function pickTimeZone(userZone: string | null | undefined, tenantZone: string): string {
|
|
62
73
|
if (userZone && isValidIanaTimeZone(userZone)) return userZone;
|
|
63
74
|
return isValidIanaTimeZone(tenantZone) ? tenantZone : "UTC";
|
|
@@ -145,7 +156,7 @@ export function createIssueContractConfirmationHandler(
|
|
|
145
156
|
);
|
|
146
157
|
const recipientEmail =
|
|
147
158
|
recipient.success && recipient.data.isDeleted !== true
|
|
148
|
-
? await
|
|
159
|
+
? await decryptStoredUserEmail(
|
|
149
160
|
recipient.data.email,
|
|
150
161
|
"billing-foundation:issue-contract-confirmation",
|
|
151
162
|
)
|
|
@@ -203,7 +214,7 @@ export function createIssueContractConfirmationHandler(
|
|
|
203
214
|
...(payload.currentPeriodEndIso !== undefined && {
|
|
204
215
|
currentPeriodEndIso: payload.currentPeriodEndIso,
|
|
205
216
|
}),
|
|
206
|
-
vatNote: consumerProtection.
|
|
217
|
+
vatNote: vatNoteFor(consumerProtection, consent.providerName),
|
|
207
218
|
operatorEmail: consumerProtection.operatorEmail,
|
|
208
219
|
termsContent: terms.content,
|
|
209
220
|
});
|
package/packages/bundled-features/src/billing-foundation/consumer-protection/mail-notifications.ts
CHANGED
|
@@ -69,10 +69,9 @@ export function terminationReceiptTemplate(): NotificationLocalizedTemplate {
|
|
|
69
69
|
mailRepeatedRows("details", "text"),
|
|
70
70
|
mailParagraph(texts.receiptNextSteps),
|
|
71
71
|
mailParagraph(texts.receiptRevoke(OPERATOR_EMAIL_SLOT)),
|
|
72
|
-
mailParagraphWithLink(texts.receiptNotMe, texts.receiptNotMeLink, "revokeUrl"
|
|
73
|
-
"
|
|
74
|
-
|
|
75
|
-
),
|
|
72
|
+
mailParagraphWithLink(texts.receiptNotMe, texts.receiptNotMeLink, "revokeUrl", {
|
|
73
|
+
showIf: "revokeUrl",
|
|
74
|
+
}),
|
|
76
75
|
mailParagraph(`${texts.contact} {operatorEmail}`),
|
|
77
76
|
].join(""),
|
|
78
77
|
};
|
|
@@ -14,28 +14,34 @@ function receiptBucketKey(ctx: HandlerContext, email: string): string | undefine
|
|
|
14
14
|
return digest === undefined ? undefined : `${RECEIPT_BUCKET_PREFIX}:${digest}`;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
export type ReceiptMailVerdict = "allowed" | "limited" | "unavailable";
|
|
18
|
+
|
|
17
19
|
/** Consumes one receipt token for the recipient. Fails closed when the limiter
|
|
18
|
-
* errors or no digest key is wired: the declaration is already
|
|
19
|
-
|
|
20
|
+
* errors or no digest key is wired ("unavailable"): the declaration is already
|
|
21
|
+
* recorded, a missing receipt is the lesser harm. */
|
|
22
|
+
export async function mayMailReceipt(
|
|
23
|
+
ctx: HandlerContext,
|
|
24
|
+
email: string,
|
|
25
|
+
): Promise<ReceiptMailVerdict> {
|
|
20
26
|
// skip: no rate limiter wired, nothing to count against
|
|
21
|
-
if (!ctx.rateLimit) return
|
|
27
|
+
if (!ctx.rateLimit) return "allowed";
|
|
22
28
|
try {
|
|
23
29
|
const bucketKey = receiptBucketKey(ctx, email);
|
|
24
30
|
if (bucketKey === undefined) {
|
|
25
31
|
ctx.log?.warn(
|
|
26
32
|
"[billing-foundation:termination] no rate-limit digest available, receipt mail withheld",
|
|
27
33
|
);
|
|
28
|
-
return
|
|
34
|
+
return "unavailable";
|
|
29
35
|
}
|
|
30
36
|
const decision = await ctx.rateLimit.check(bucketKey, {
|
|
31
37
|
limit: RECEIPT_MAILS_PER_RECIPIENT,
|
|
32
38
|
windowSeconds: RECEIPT_WINDOW_SECONDS,
|
|
33
39
|
});
|
|
34
|
-
return decision.allowed;
|
|
40
|
+
return decision.allowed ? "allowed" : "limited";
|
|
35
41
|
} catch (error) {
|
|
36
42
|
ctx.log?.warn(
|
|
37
43
|
`[billing-foundation:termination] receipt rate limiter failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
38
44
|
);
|
|
39
|
-
return
|
|
45
|
+
return "unavailable";
|
|
40
46
|
}
|
|
41
47
|
}
|
package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts
CHANGED
|
@@ -249,25 +249,8 @@ export function createRecordContractTerminationHandler(
|
|
|
249
249
|
|
|
250
250
|
// The public job runs at least once: a second run must not call the
|
|
251
251
|
// provider or append again.
|
|
252
|
-
const alreadyRecorded = stream.events
|
|
253
|
-
|
|
254
|
-
e.type === CONTRACT_TERMINATION_REQUESTED_EVENT_QN
|
|
255
|
-
? contractTerminationRequestedPayloadSchema.safeParse(e.payload).data
|
|
256
|
-
: undefined,
|
|
257
|
-
)
|
|
258
|
-
.find((recordedPayload) => recordedPayload?.requestId === payload.requestId);
|
|
259
|
-
if (alreadyRecorded) {
|
|
260
|
-
return {
|
|
261
|
-
isSuccess: true as const,
|
|
262
|
-
data: {
|
|
263
|
-
receivedAtIso: alreadyRecorded.receivedAtIso,
|
|
264
|
-
effectiveAtIso: alreadyRecorded.effectiveAtIso,
|
|
265
|
-
providerCancel: alreadyRecorded.providerCancel,
|
|
266
|
-
noticeReason: null,
|
|
267
|
-
withdrawalPeriodExpired: false,
|
|
268
|
-
} satisfies RecordContractTerminationResult,
|
|
269
|
-
};
|
|
270
|
-
}
|
|
252
|
+
const alreadyRecorded = findRecordedTermination(stream.events, payload.requestId);
|
|
253
|
+
if (alreadyRecorded) return recordedTerminationResult(alreadyRecorded);
|
|
271
254
|
|
|
272
255
|
// A revoked public declaration never reaches the provider.
|
|
273
256
|
if (hasRevocation(stream.events, payload.requestId)) {
|
|
@@ -301,6 +284,13 @@ export function createRecordContractTerminationHandler(
|
|
|
301
284
|
aggregateId: subscriptionAggregateId(tenantId),
|
|
302
285
|
aggregateType: SUBSCRIPTION_AGGREGATE_TYPE,
|
|
303
286
|
});
|
|
287
|
+
// A concurrent run with the same requestId may have recorded while we were
|
|
288
|
+
// at the provider; a second append would duplicate the event and the notice.
|
|
289
|
+
const recordedMeanwhile = findRecordedTermination(
|
|
290
|
+
streamAfterProviderCall.events,
|
|
291
|
+
payload.requestId,
|
|
292
|
+
);
|
|
293
|
+
if (recordedMeanwhile) return recordedTerminationResult(recordedMeanwhile);
|
|
304
294
|
// A revocation that landed during the provider call found no cancel to take back.
|
|
305
295
|
if (hasRevocation(streamAfterProviderCall.events, payload.requestId)) {
|
|
306
296
|
await takeBackLateRevokedCancel(ctx, consumerProtection, {
|
|
@@ -348,6 +338,32 @@ export function createRecordContractTerminationHandler(
|
|
|
348
338
|
};
|
|
349
339
|
}
|
|
350
340
|
|
|
341
|
+
export function findRecordedTermination(
|
|
342
|
+
events: readonly { type: string; payload: unknown }[],
|
|
343
|
+
requestId: string,
|
|
344
|
+
): ContractTerminationRequestedPayload | undefined {
|
|
345
|
+
return events
|
|
346
|
+
.map((e) =>
|
|
347
|
+
e.type === CONTRACT_TERMINATION_REQUESTED_EVENT_QN
|
|
348
|
+
? contractTerminationRequestedPayloadSchema.safeParse(e.payload).data
|
|
349
|
+
: undefined,
|
|
350
|
+
)
|
|
351
|
+
.find((recordedPayload) => recordedPayload?.requestId === requestId);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function recordedTerminationResult(recorded: ContractTerminationRequestedPayload) {
|
|
355
|
+
return {
|
|
356
|
+
isSuccess: true as const,
|
|
357
|
+
data: {
|
|
358
|
+
receivedAtIso: recorded.receivedAtIso,
|
|
359
|
+
effectiveAtIso: recorded.effectiveAtIso,
|
|
360
|
+
providerCancel: recorded.providerCancel,
|
|
361
|
+
noticeReason: null,
|
|
362
|
+
withdrawalPeriodExpired: false,
|
|
363
|
+
} satisfies RecordContractTerminationResult,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
351
367
|
export function hasRevocation(
|
|
352
368
|
events: readonly { type: string; payload: unknown }[],
|
|
353
369
|
requestId: string,
|
package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
14
14
|
import { generateId } from "@cosmicdrift/kumiko-framework/utils";
|
|
15
15
|
import * as z from "zod";
|
|
16
|
+
import { decryptStoredUserEmail } from "../../shared/index.ts";
|
|
16
17
|
import { TenantQueries } from "../../tenant/index.ts";
|
|
17
18
|
import { UserQueries } from "../../user/index.ts";
|
|
18
19
|
import {
|
|
@@ -31,10 +32,9 @@ import {
|
|
|
31
32
|
SubscriptionFoundationQueries,
|
|
32
33
|
} from "../web/constants.ts";
|
|
33
34
|
import { resolveConsentLocale } from "./consent-text.ts";
|
|
34
|
-
import { openStoredUserEmail } from "./stored-user-email.ts";
|
|
35
35
|
import { type TerminationDeclaration, terminationReceiptVariables } from "./termination-mail.ts";
|
|
36
36
|
import { notifyOperator, type OperatorNoticePlan, requireNotify } from "./termination-notify.ts";
|
|
37
|
-
import { mayMailReceipt } from "./termination-receipt-limit.ts";
|
|
37
|
+
import { mayMailReceipt, type ReceiptMailVerdict } from "./termination-receipt-limit.ts";
|
|
38
38
|
import type { RecordContractTerminationResult } from "./termination-record.ts";
|
|
39
39
|
import { createRevocationToken, revocationUrl } from "./termination-revoke.ts";
|
|
40
40
|
import type { OperatorNoticeReason } from "./termination-texts.ts";
|
|
@@ -109,13 +109,19 @@ type TerminationMailPlan = OperatorNoticePlan & {
|
|
|
109
109
|
readonly revokeUrl?: string;
|
|
110
110
|
};
|
|
111
111
|
|
|
112
|
+
function receiptWithheldReasons(verdict: ReceiptMailVerdict): readonly OperatorNoticeReason[] {
|
|
113
|
+
if (verdict === "limited") return ["receipt_rate_limited"];
|
|
114
|
+
if (verdict === "unavailable") return ["receipt_not_sent"];
|
|
115
|
+
return [];
|
|
116
|
+
}
|
|
117
|
+
|
|
112
118
|
async function sendTerminationMails(
|
|
113
119
|
ctx: HandlerContext,
|
|
114
120
|
consumerProtection: ConsumerProtectionOptions,
|
|
115
121
|
plan: TerminationMailPlan,
|
|
116
122
|
): Promise<void> {
|
|
117
|
-
const
|
|
118
|
-
if (
|
|
123
|
+
const receiptVerdict = await mayMailReceipt(ctx, plan.declaration.email);
|
|
124
|
+
if (receiptVerdict === "allowed") {
|
|
119
125
|
await requireNotify(ctx)(CONTRACT_TERMINATION_RECEIPT_NOTIFICATION_TYPE, {
|
|
120
126
|
route: { email: plan.declaration.email },
|
|
121
127
|
data: terminationReceiptVariables({
|
|
@@ -133,9 +139,7 @@ async function sendTerminationMails(
|
|
|
133
139
|
priority: "critical",
|
|
134
140
|
});
|
|
135
141
|
}
|
|
136
|
-
const reasons =
|
|
137
|
-
? plan.reasons
|
|
138
|
-
: [...plan.reasons, "receipt_rate_limited" as const];
|
|
142
|
+
const reasons = [...plan.reasons, ...receiptWithheldReasons(receiptVerdict)];
|
|
139
143
|
// skip: no operator notice needed for this declaration
|
|
140
144
|
if (reasons.length === 0) return;
|
|
141
145
|
const { revokeUrl: _revokeUrl, ...noticePlan } = plan;
|
|
@@ -320,7 +324,7 @@ export function createTerminateContractHandler(
|
|
|
320
324
|
);
|
|
321
325
|
const callerEmail =
|
|
322
326
|
caller.success && caller.data.email && caller.data.isDeleted !== true
|
|
323
|
-
? await
|
|
327
|
+
? await decryptStoredUserEmail(caller.data.email, "billing-foundation:terminate-contract")
|
|
324
328
|
: null;
|
|
325
329
|
if (callerEmail === null) {
|
|
326
330
|
throw new InternalError({
|
package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-revoke.ts
CHANGED
|
@@ -14,11 +14,9 @@ import * as z from "zod";
|
|
|
14
14
|
import { subscriptionAggregateId } from "../aggregate-id.ts";
|
|
15
15
|
import {
|
|
16
16
|
CONTRACT_TERMINATION_DECLARED_EVENT_QN,
|
|
17
|
-
CONTRACT_TERMINATION_REQUESTED_EVENT_QN,
|
|
18
17
|
CONTRACT_TERMINATION_REVOKED_EVENT_QN,
|
|
19
18
|
type ContractTerminationRevokedPayload,
|
|
20
19
|
contractTerminationDeclaredPayloadSchema,
|
|
21
|
-
contractTerminationRequestedPayloadSchema,
|
|
22
20
|
SUBSCRIPTION_AGGREGATE_TYPE,
|
|
23
21
|
} from "../events.ts";
|
|
24
22
|
import { getSubscriptionForTenant } from "../get-subscription-for-tenant.ts";
|
|
@@ -26,7 +24,7 @@ import type { ConsumerProtectionOptions, ResolvedBillingFoundationOptions } from
|
|
|
26
24
|
import { SubscriptionFoundationHandlers } from "../web/constants.ts";
|
|
27
25
|
import type { ConsentLocale } from "./consent-text.ts";
|
|
28
26
|
import { notifyOperator } from "./termination-notify.ts";
|
|
29
|
-
import { hasRevocation, resumeAtProvider } from "./termination-record.ts";
|
|
27
|
+
import { findRecordedTermination, hasRevocation, resumeAtProvider } from "./termination-record.ts";
|
|
30
28
|
import type { OperatorNoticeReason } from "./termination-texts.ts";
|
|
31
29
|
|
|
32
30
|
export const REVOCATION_PATHS: Readonly<Record<ConsentLocale, string>> = {
|
|
@@ -152,10 +150,7 @@ export function createRecordContractTerminationRevocationHandler(
|
|
|
152
150
|
}
|
|
153
151
|
if (hasRevocation(stream.events, payload.requestId)) return outcome("already-revoked");
|
|
154
152
|
|
|
155
|
-
const requested = stream.events
|
|
156
|
-
.filter((e) => e.type === CONTRACT_TERMINATION_REQUESTED_EVENT_QN)
|
|
157
|
-
.map((e) => contractTerminationRequestedPayloadSchema.safeParse(e.payload).data)
|
|
158
|
-
.find((candidate) => candidate?.requestId === payload.requestId);
|
|
153
|
+
const requested = findRecordedTermination(stream.events, payload.requestId);
|
|
159
154
|
const subscription = await getSubscriptionForTenant(ctx, tenantId);
|
|
160
155
|
const effectiveAtIso = requested?.effectiveAtIso ?? subscription?.currentPeriodEnd.toString();
|
|
161
156
|
const now = options.now();
|
|
@@ -175,6 +170,17 @@ export function createRecordContractTerminationRevocationHandler(
|
|
|
175
170
|
aggregateId: subscriptionAggregateId(tenantId),
|
|
176
171
|
aggregateType: SUBSCRIPTION_AGGREGATE_TYPE,
|
|
177
172
|
});
|
|
173
|
+
// A cancel recorded between the first fetch and now was never resumed above.
|
|
174
|
+
const requestedMeanwhile =
|
|
175
|
+
requested === undefined
|
|
176
|
+
? findRecordedTermination(streamAfterProviderCall.events, payload.requestId)
|
|
177
|
+
: undefined;
|
|
178
|
+
if (requestedMeanwhile?.providerCancel === "period-end") {
|
|
179
|
+
reasons.push(
|
|
180
|
+
...(await resumeAtProvider(ctx, await getSubscriptionForTenant(ctx, tenantId))),
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
const providerCancel = (requested ?? requestedMeanwhile)?.providerCancel;
|
|
178
184
|
const revoked: ContractTerminationRevokedPayload = {
|
|
179
185
|
requestId: payload.requestId,
|
|
180
186
|
revokedAtIso: now.toString(),
|
|
@@ -194,7 +200,7 @@ export function createRecordContractTerminationRevocationHandler(
|
|
|
194
200
|
},
|
|
195
201
|
reasons,
|
|
196
202
|
tenantId,
|
|
197
|
-
...(
|
|
203
|
+
...(providerCancel !== undefined && { providerCancel }),
|
|
198
204
|
});
|
|
199
205
|
return outcome("revoked");
|
|
200
206
|
},
|
package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts
CHANGED
|
@@ -91,6 +91,7 @@ export const OPERATOR_NOTICE_REASONS = [
|
|
|
91
91
|
"recording_failed",
|
|
92
92
|
"withdrawal_period_expired",
|
|
93
93
|
"receipt_rate_limited",
|
|
94
|
+
"receipt_not_sent",
|
|
94
95
|
"revoked",
|
|
95
96
|
"revocation_resume_manual",
|
|
96
97
|
"revocation_resume_failed",
|
|
@@ -145,6 +146,8 @@ export const TERMINATION_TEXTS: Readonly<Record<ConsentLocale, TerminationTexts>
|
|
|
145
146
|
"Widerruf nach Ablauf der Widerrufsfrist: zum Periodenende gekündigt, Anspruch prüfen",
|
|
146
147
|
receipt_rate_limited:
|
|
147
148
|
"Keine Eingangsbestätigung versendet: Limit pro Empfängeradresse erreicht",
|
|
149
|
+
receipt_not_sent:
|
|
150
|
+
"Keine Eingangsbestätigung versendet: Limit-Prüfung nicht verfügbar (Redis-Fehler oder fehlende Konfiguration)",
|
|
148
151
|
revoked:
|
|
149
152
|
"Die Erklärung wurde vom Postfachinhaber über „Diese Kündigung war ich nicht“ zurückgenommen",
|
|
150
153
|
revocation_resume_manual:
|
|
@@ -256,6 +259,8 @@ export const TERMINATION_TEXTS: Readonly<Record<ConsentLocale, TerminationTexts>
|
|
|
256
259
|
withdrawal_period_expired:
|
|
257
260
|
"Withdrawal after the withdrawal period: cancelled at period end, review the claim",
|
|
258
261
|
receipt_rate_limited: "No receipt mail sent: the per-recipient limit was reached",
|
|
262
|
+
receipt_not_sent:
|
|
263
|
+
"No receipt mail sent: the limit check was unavailable (limiter error or missing configuration)",
|
|
259
264
|
revoked:
|
|
260
265
|
'The declaration was taken back by the mailbox owner ("I did not request this cancellation")',
|
|
261
266
|
revocation_resume_manual:
|
package/packages/bundled-features/src/billing-foundation/consumer-protection/withdrawal-period.ts
CHANGED
|
@@ -1,26 +1,24 @@
|
|
|
1
1
|
import type { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
2
|
-
import {
|
|
2
|
+
import { SUBSCRIPTION_CREATED_EVENT_QN } from "../events.ts";
|
|
3
3
|
|
|
4
4
|
// § 355 BGB: the withdrawal period is 14 days from conclusion of the contract.
|
|
5
5
|
export const WITHDRAWAL_PERIOD_DAYS = 14;
|
|
6
6
|
const HOURS_PER_DAY = 24;
|
|
7
7
|
|
|
8
|
-
const PERIOD_START_EVENT_TYPES: ReadonlySet<string> = new Set([
|
|
9
|
-
CHECKOUT_CONSENT_RECORDED_EVENT_QN,
|
|
10
|
-
SUBSCRIPTION_CREATED_EVENT_QN,
|
|
11
|
-
]);
|
|
12
|
-
|
|
13
8
|
type PeriodStartEvent = { readonly type: string; readonly createdAt: Temporal.Instant };
|
|
14
9
|
|
|
15
|
-
/** The period runs from the latest
|
|
16
|
-
*
|
|
10
|
+
/** The period runs from the latest subscription start on the stream. A checkout
|
|
11
|
+
* consent alone never restarts it: it is written before the buyer reaches the
|
|
12
|
+
* provider, so an abandoned plan-change checkout would otherwise reopen the
|
|
13
|
+
* period of a long-concluded contract. A stream without a subscription start
|
|
14
|
+
* has no provable start and counts as expired. */
|
|
17
15
|
export function isWithinWithdrawalPeriod(
|
|
18
16
|
events: readonly PeriodStartEvent[],
|
|
19
17
|
now: Temporal.Instant,
|
|
20
18
|
): boolean {
|
|
21
19
|
let latestStart: Temporal.Instant | undefined;
|
|
22
20
|
for (const event of events) {
|
|
23
|
-
if (
|
|
21
|
+
if (event.type !== SUBSCRIPTION_CREATED_EVENT_QN) continue;
|
|
24
22
|
if (
|
|
25
23
|
latestStart === undefined ||
|
|
26
24
|
event.createdAt.epochMilliseconds > latestStart.epochMilliseconds
|
|
@@ -28,7 +26,7 @@ export function isWithinWithdrawalPeriod(
|
|
|
28
26
|
latestStart = event.createdAt;
|
|
29
27
|
}
|
|
30
28
|
}
|
|
31
|
-
// skip: no
|
|
29
|
+
// skip: no subscription-created event, nothing proves a running period
|
|
32
30
|
if (latestStart === undefined) return false;
|
|
33
31
|
const periodEnd = latestStart.add({ hours: WITHDRAWAL_PERIOD_DAYS * HOURS_PER_DAY });
|
|
34
32
|
return now.epochMilliseconds <= periodEnd.epochMilliseconds;
|
|
@@ -141,6 +141,7 @@ export const checkoutConsentRecordedPayloadSchema = z.object({
|
|
|
141
141
|
termsTemplateVersion: z.number(),
|
|
142
142
|
locale: z.string().min(2).max(35),
|
|
143
143
|
actorUserId: z.string().min(1),
|
|
144
|
+
providerName: z.string().min(1).max(100).optional(),
|
|
144
145
|
itemLabelKey: z.string().min(1).max(200).optional(),
|
|
145
146
|
itemLabelParams: z.record(z.string(), z.union([z.string().max(100), z.number()])).optional(),
|
|
146
147
|
});
|
|
@@ -79,6 +79,7 @@ import { createBillingPlansQuery } from "./handlers/billing-plans.query.ts";
|
|
|
79
79
|
import { createCheckoutSessionHandler } from "./handlers/create-checkout-session.write.ts";
|
|
80
80
|
import { createPortalSessionHandler } from "./handlers/create-portal-session.write.ts";
|
|
81
81
|
import { listSubscriptionsQuery } from "./handlers/list-subscriptions.query.ts";
|
|
82
|
+
import { createPlanSwitchPreviewQuery } from "./handlers/plan-switch-preview.query.ts";
|
|
82
83
|
import { createProcessEventHandler } from "./handlers/process-event.write.ts";
|
|
83
84
|
import { processPaymentEventHandler } from "./handlers/process-payment-event.write.ts";
|
|
84
85
|
import { createStartPlanCheckoutHandler } from "./handlers/start-plan-checkout.write.ts";
|
|
@@ -137,7 +138,7 @@ export function createBillingFoundationFeature<TTier extends string = string>(
|
|
|
137
138
|
BILLING_FOUNDATION_FEATURE,
|
|
138
139
|
(r) => {
|
|
139
140
|
r.describe(
|
|
140
|
-
"Plugin host for subscription billing
|
|
141
|
+
"Plugin host for subscription billing. Manages the `read_subscriptions` projection table and exposes 5 domain events (subscription created/updated/canceled, invoice paid/failed) appended by the foundation's own `billing-foundation:write:process-event` write-handler after provider plugins verify and normalize each webhook. Also manages a separate `read_payments` projection table (one row per one-off-payment) fed by its own `payment-received` event and `billing-foundation:write:process-payment-event` write-handler. Also ships `billing-foundation:write:create-checkout-session` and `billing-foundation:write:create-portal-session` write-handlers, a `billing-foundation:query:subscription:list` query handler, and a `createSubscriptionWebhookRoute` factory for the `/api/subscription/webhook/:providerName` extraRoute. `createBillingFoundationFeature({ baseUrl, catalog })` additionally derives a `billing-foundation:query:billing-plans` query, `start-plan-checkout`/`switch-plan` write-handlers, a `plan-switch-preview` query and a dormant billing-plans dashboard screen/panel from the catalog. Several providers can be mounted at once: new checkouts are offered by every enabled provider (enabled means keys set and billingLive on, or simply mounted for plugins without `isBillingEnabled`), the customer picks one in the plan panel and `start-plan-checkout` takes a `providerName`. An existing subscription stays with the provider that stored it, so the plan list and every switch follow that provider. A plan switch is either hosted (`createPlanSwitchSession` answers a url) or in-app (`previewPlanSwitch` shows the proration, `applyPlanSwitch` runs after the customer confirms; `switch-plan` then answers `{ applied: true }` and records the consent). `consumerProtection.providerTexts` overrides the checkout note and the VAT note per provider. Also ships `billing-foundation:write:sync-subscription` (pulls a provider plugin's live subscription state via `retrieveSubscription` and appends drift as a `subscription.updated` event, which catches changes made on the provider's own dashboard that never reached us as a webhook) and the `sync-subscriptions` job (manual-trigger + runOnBoot, perTenant) that dispatches it. Low-level building block: use `subscription-stripe`, `subscription-mollie` or `subscription-dodo` unless you are writing a new payment provider.",
|
|
141
142
|
);
|
|
142
143
|
r.uiHints({
|
|
143
144
|
displayLabel: "Billing · Foundation",
|
|
@@ -239,6 +240,7 @@ export function createBillingFoundationFeature<TTier extends string = string>(
|
|
|
239
240
|
if (widened.catalog) {
|
|
240
241
|
const { catalog } = widened;
|
|
241
242
|
r.queryHandler(createBillingPlansQuery(widened, catalog));
|
|
243
|
+
r.queryHandler(createPlanSwitchPreviewQuery(catalog));
|
|
242
244
|
r.writeHandler(createStartPlanCheckoutHandler(widened, catalog));
|
|
243
245
|
r.writeHandler(createSwitchPlanHandler(widened, catalog));
|
|
244
246
|
r.screen(createBillingPlansScreen(catalog.viewRoles));
|