@cosmicdrift/kumiko-samples 0.363.0 → 0.365.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/LICENSE +1 -1
- package/package.json +1 -1
- package/packages/bundled-features/package.json +7 -1
- package/packages/bundled-features/src/audit/changes.json +18 -0
- package/packages/bundled-features/src/audit/feature.ts +1 -1
- package/packages/bundled-features/src/audit/handlers/list.query.ts +10 -5
- package/packages/bundled-features/src/audit/run-escape-hatch-retention.ts +63 -11
- package/packages/bundled-features/src/auth-email-password/bootstrap.ts +30 -11
- package/packages/bundled-features/src/auth-email-password/changes.json +55 -0
- package/packages/bundled-features/src/auth-email-password/email-rate-limits.ts +16 -0
- package/packages/bundled-features/src/auth-email-password/feature.ts +4 -7
- package/packages/bundled-features/src/auth-email-password/handlers/change-password.write.ts +6 -2
- package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -3
- package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +6 -4
- package/packages/bundled-features/src/auth-email-password/handlers/logout.write.ts +7 -2
- package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +2 -1
- package/packages/bundled-features/src/auth-email-password/handlers/switch-tenant-mfa-gate.write.ts +2 -2
- package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +2 -1
- package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +7 -3
- package/packages/bundled-features/src/auth-email-password/mail-i18n.ts +10 -0
- package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +24 -5
- package/packages/bundled-features/src/billing-foundation/changes.json +48 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +57 -5
- package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts +22 -1
- package/packages/bundled-features/src/billing-foundation/consumer-protection/mail-notifications.ts +4 -1
- package/packages/bundled-features/src/billing-foundation/consumer-protection/register-termination.ts +11 -1
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-mail.ts +9 -3
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-receipt-limit.ts +41 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +18 -2
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +35 -19
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +9 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/withdrawal-period.ts +35 -0
- package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +11 -3
- package/packages/bundled-features/src/billing-foundation/handlers/process-event.write.ts +10 -4
- package/packages/bundled-features/src/billing-foundation/i18n.ts +9 -0
- package/packages/bundled-features/src/billing-foundation/subscription-tier-sync.ts +27 -7
- package/packages/bundled-features/src/billing-foundation/types.ts +7 -2
- package/packages/bundled-features/src/cap-counter/book-cap-usage.ts +20 -8
- package/packages/bundled-features/src/cap-counter/calendar-cap-usage-reservation.ts +2 -0
- package/packages/bundled-features/src/cap-counter/cap-reservation.ts +7 -2
- package/packages/bundled-features/src/cap-counter/changes.json +19 -0
- package/packages/bundled-features/src/cap-counter/enforce-cap.ts +75 -5
- package/packages/bundled-features/src/cap-counter/handlers/increment-rolling.write.ts +2 -0
- package/packages/bundled-features/src/cap-counter/index.ts +1 -0
- package/packages/bundled-features/src/cap-counter/with-cap-enforcement.ts +3 -0
- package/packages/bundled-features/src/cap-overview/changes.json +6 -0
- package/packages/bundled-features/src/cap-overview/handlers/tenant-caps-list.query.ts +2 -1
- package/packages/bundled-features/src/cap-overview/handlers/tenant-options.query.ts +2 -1
- package/packages/bundled-features/src/channel-discord/discord-channel.ts +4 -2
- package/packages/bundled-features/src/channel-email/changes.json +18 -0
- package/packages/bundled-features/src/channel-email/email-channel.ts +47 -24
- package/packages/bundled-features/src/channel-email/smtp-transport.ts +7 -1
- package/packages/bundled-features/src/channel-slack/slack-channel.ts +4 -2
- package/packages/bundled-features/src/channel-teams/teams-channel.ts +4 -2
- package/packages/bundled-features/src/config/changes.json +6 -0
- package/packages/bundled-features/src/config/handlers/set.write.ts +2 -2
- package/packages/bundled-features/src/config/resolver.ts +15 -12
- package/packages/bundled-features/src/config/write-helpers.ts +23 -0
- package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
- package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +54 -27
- package/packages/bundled-features/src/custom-fields/changes.json +6 -0
- package/packages/bundled-features/src/custom-fields/db/queries/retention.ts +2 -2
- package/packages/bundled-features/src/custom-fields/handlers/update-tenant-field.write.ts +0 -1
- package/packages/bundled-features/src/data-retention/run-retention-cleanup.ts +4 -5
- package/packages/bundled-features/src/delivery/attachment-ref.ts +39 -1
- package/packages/bundled-features/src/delivery/changes.json +30 -0
- package/packages/bundled-features/src/delivery/channel-context.ts +31 -8
- package/packages/bundled-features/src/delivery/delivery-service.ts +93 -92
- package/packages/bundled-features/src/delivery/index.ts +2 -0
- package/packages/bundled-features/src/delivery/jobs.ts +51 -14
- package/packages/bundled-features/src/delivery/types.ts +7 -1
- package/packages/bundled-features/src/document-ingest-foundation/changes.json +6 -0
- package/packages/bundled-features/src/feature-toggles/changes.json +7 -0
- package/packages/bundled-features/src/feature-toggles/toggle-runtime.ts +26 -12
- package/packages/bundled-features/src/file-derivatives/handlers/public-variant.query.ts +1 -1
- package/packages/bundled-features/src/file-foundation/changes.json +6 -0
- package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +6 -3
- package/packages/bundled-features/src/files-tenant-data/changes.json +6 -0
- package/packages/bundled-features/src/files-tenant-data/hooks.ts +4 -0
- package/packages/bundled-features/src/files-tenant-data/index.ts +3 -1
- package/packages/bundled-features/src/form-draft/db/queries/owned-file-refs.ts +12 -3
- package/packages/bundled-features/src/form-draft/handlers/discard.write.ts +1 -1
- package/packages/bundled-features/src/inbound-mail-foundation/changes.json +6 -0
- package/packages/bundled-features/src/inbound-mail-foundation/oauth-access-token.ts +13 -8
- package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +3 -1
- package/packages/bundled-features/src/jobs/changes.json +31 -0
- package/packages/bundled-features/src/jobs/db/queries/stale-run-sweep.ts +12 -4
- package/packages/bundled-features/src/jobs/feature.ts +7 -4
- package/packages/bundled-features/src/jobs/handlers/list.query.ts +3 -1
- package/packages/bundled-features/src/jobs/handlers/stale-run-sweep.job.ts +5 -2
- package/packages/bundled-features/src/jobs/job-run-logger.ts +56 -18
- package/packages/bundled-features/src/jobs/tenant-job-run-table.ts +7 -2
- package/packages/bundled-features/src/ledger/changes.json +7 -0
- package/packages/bundled-features/src/ledger/entity.ts +3 -1
- package/packages/bundled-features/src/metrics/changes.json +6 -0
- package/packages/bundled-features/src/metrics/filters.ts +28 -12
- package/packages/bundled-features/src/personal-access-tokens/changes.json +6 -0
- package/packages/bundled-features/src/personal-access-tokens/revoke-for-user.ts +7 -8
- package/packages/bundled-features/src/renderer-foundation/types.ts +2 -0
- package/packages/bundled-features/src/renderer-pdf/changes.json +12 -0
- package/packages/bundled-features/src/renderer-pdf/render-table-pdf.ts +162 -82
- package/packages/bundled-features/src/secrets/changes.json +12 -0
- package/packages/bundled-features/src/secrets/feature.ts +4 -3
- package/packages/bundled-features/src/secrets/handlers/delete.write.ts +3 -1
- package/packages/bundled-features/src/secrets/secrets-context.ts +23 -3
- package/packages/bundled-features/src/secrets/write-gate.ts +8 -2
- package/packages/bundled-features/src/shared/changes.json +6 -0
- package/packages/bundled-features/src/shared/chunked-entity-migration.ts +4 -1
- package/packages/bundled-features/src/shared/index.ts +8 -0
- package/packages/bundled-features/src/shared/mail-template-body.ts +17 -0
- package/packages/bundled-features/src/shared/resource-image-formats.ts +31 -0
- package/packages/bundled-features/src/shared/sharp-input-limits.ts +7 -8
- package/packages/bundled-features/src/step-dispatcher/webhook-runner.ts +2 -2
- package/packages/bundled-features/src/subscription-stripe/changes.json +6 -0
- package/packages/bundled-features/src/subscription-stripe/plugin-methods.ts +19 -11
- package/packages/bundled-features/src/subscription-stripe/verify-webhook.ts +3 -1
- package/packages/bundled-features/src/template-resolver/README.md +4 -2
- package/packages/bundled-features/src/template-resolver/api.ts +3 -2
- package/packages/bundled-features/src/template-resolver/changes.json +149 -0
- package/packages/bundled-features/src/template-resolver/compile-document-body.ts +148 -18
- package/packages/bundled-features/src/template-resolver/compile-steps-block.ts +68 -0
- package/packages/bundled-features/src/template-resolver/constants.ts +23 -3
- package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +3 -0
- package/packages/bundled-features/src/template-resolver/document-schema.ts +33 -5
- package/packages/bundled-features/src/template-resolver/document-steps.ts +114 -0
- package/packages/bundled-features/src/template-resolver/feature.ts +16 -1
- package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +81 -39
- package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +4 -3
- package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +121 -53
- package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +6 -44
- package/packages/bundled-features/src/template-resolver/handlers/discard-draft.write.ts +52 -0
- package/packages/bundled-features/src/template-resolver/handlers/export-bundle.query.ts +56 -1
- package/packages/bundled-features/src/template-resolver/handlers/find-by-id.query.ts +6 -3
- package/packages/bundled-features/src/template-resolver/handlers/import-bundle-system.write.ts +25 -15
- package/packages/bundled-features/src/template-resolver/handlers/import-bundle.write.ts +12 -1
- package/packages/bundled-features/src/template-resolver/handlers/layout-source.query.ts +73 -30
- package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +14 -11
- package/packages/bundled-features/src/template-resolver/handlers/override-diff.ts +49 -0
- package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +102 -19
- package/packages/bundled-features/src/template-resolver/handlers/resource-upload.write.ts +49 -10
- package/packages/bundled-features/src/template-resolver/handlers/send-test.write.ts +39 -10
- package/packages/bundled-features/src/template-resolver/handlers/template-gates.ts +14 -2
- package/packages/bundled-features/src/template-resolver/handlers/template-write.ts +54 -4
- package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +96 -14
- package/packages/bundled-features/src/template-resolver/handlers/toggle-status.write.ts +51 -24
- package/packages/bundled-features/src/template-resolver/handlers/upsert-tenant.write.ts +1 -1
- package/packages/bundled-features/src/template-resolver/handlers/usage.query.ts +16 -2
- package/packages/bundled-features/src/template-resolver/handlers/validate.query.ts +4 -1
- package/packages/bundled-features/src/template-resolver/index.ts +18 -2
- package/packages/bundled-features/src/template-resolver/layout-appearance.ts +56 -0
- package/packages/bundled-features/src/template-resolver/mail-constants.ts +4 -0
- package/packages/bundled-features/src/template-resolver/pdf-constants.ts +12 -0
- package/packages/bundled-features/src/template-resolver/qualified-names.ts +4 -0
- package/packages/bundled-features/src/template-resolver/resource-image.ts +9 -16
- package/packages/bundled-features/src/template-resolver/seeding.ts +27 -4
- package/packages/bundled-features/src/template-resolver/store-template-resource.ts +390 -124
- package/packages/bundled-features/src/template-resolver/table.ts +13 -1
- package/packages/bundled-features/src/template-resolver/template-bundle.ts +4 -0
- package/packages/bundled-features/src/template-resolver/template-draft.ts +106 -0
- package/packages/bundled-features/src/template-resolver/template-meta.ts +95 -24
- package/packages/bundled-features/src/template-resolver/template-preview.ts +160 -57
- package/packages/bundled-features/src/template-resolver/template-resources.ts +3 -4
- package/packages/bundled-features/src/template-resolver/template-usage.ts +31 -11
- package/packages/bundled-features/src/template-resolver/template-validation.ts +16 -9
- package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +12 -8
- package/packages/bundled-features/src/template-resolver/web/document-editor-extensions.ts +97 -10
- package/packages/bundled-features/src/template-resolver/web/document-editor.tsx +15 -5
- package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +405 -145
- package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +17 -3
- package/packages/bundled-features/src/template-resolver/web/i18n.ts +32 -4
- package/packages/bundled-features/src/template-resolver/web/index.ts +8 -0
- package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor.tsx +23 -7
- package/packages/bundled-features/src/tenant/changes.json +6 -0
- package/packages/bundled-features/src/tenant/handlers/update-member-roles.write.ts +3 -8
- package/packages/bundled-features/src/tenant/seeding.ts +33 -27
- package/packages/bundled-features/src/tenant-handover/move-entity-graph.ts +14 -3
- package/packages/bundled-features/src/tenant-lifecycle/changes.json +6 -0
- package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +9 -1
- package/packages/bundled-features/src/tenant-lifecycle/stages.ts +3 -2
- package/packages/bundled-features/src/tier-engine/changes.json +18 -0
- package/packages/bundled-features/src/tier-engine/constants.ts +9 -0
- package/packages/bundled-features/src/tier-engine/entity.ts +9 -4
- package/packages/bundled-features/src/tier-engine/feature.ts +11 -3
- package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +8 -5
- package/packages/bundled-features/src/tier-engine/index.ts +2 -0
- package/packages/bundled-features/src/user/changes.json +6 -0
- package/packages/bundled-features/src/user/constants.ts +1 -0
- package/packages/bundled-features/src/user/invited-global-roles.ts +2 -4
- package/packages/bundled-features/src/user/resolve-display-names.ts +8 -15
- package/packages/bundled-features/src/user/seeding.ts +19 -5
- package/packages/bundled-features/src/user-data-rights/changes.json +12 -0
- package/packages/bundled-features/src/user-data-rights/email-templates.ts +0 -1
- package/packages/bundled-features/src/user-data-rights/i18n.ts +5 -0
- package/packages/bundled-features/src/user-data-rights/lib/update-user-lifecycle.ts +13 -2
- package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +7 -1
- package/packages/bundled-features/src/user-profile/changes.json +1 -1
- package/packages/bundled-features/src/waitlist/changes.json +18 -0
- package/packages/bundled-features/src/waitlist/feature.ts +1 -1
- package/packages/bundled-features/src/waitlist/handlers/invite.write.ts +35 -3
- package/packages/bundled-features/src/waitlist/handlers/reject.write.ts +2 -17
- package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +33 -9
- package/packages/bundled-features/src/waitlist/i18n.ts +9 -0
- package/packages/bundled-features/src/waitlist/lib.ts +20 -0
- package/packages/bundled-features/src/waitlist/options.ts +1 -1
- package/packages/bundled-features/src/workflow-runner/changes.json +6 -0
- package/packages/bundled-features/src/workflow-runner/event-trigger.ts +2 -1
- package/packages/bundled-features/src/workflow-runner/feature.ts +1 -1
- package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +7 -1
- package/samples/apps/ui-walkthrough/package.json +9 -0
- package/samples/apps/ui-walkthrough/playwright.config.ts +40 -7
- package/samples/apps/ui-walkthrough/src/app/seed-templates.ts +87 -2
- package/samples/apps/ui-walkthrough/src/app/template-seed-data.ts +246 -30
- package/samples/apps/ui-walkthrough/src/features/billing/feature.ts +4 -0
- package/samples/apps/ui-walkthrough/src/run-config.ts +18 -1
- package/samples/apps/use-all-bundled/README.md +3 -13
- package/samples/apps/use-all-bundled/feature-manifest.json +7 -5
- package/samples/apps/use-all-bundled/scripts/check-coverage.ts +34 -118
- package/samples/apps/use-all-bundled/src/app/notes-feature.ts +1 -1
- package/samples/apps/use-all-bundled/src/run-config.ts +0 -3
- package/samples/recipes/auth-mfa/README.md +2 -1
- package/samples/recipes/auth-mfa/src/feature.ts +1 -0
- package/samples/recipes/custom-handlers/README.md +4 -3
- package/samples/recipes/custom-handlers/src/feature.ts +21 -12
- package/samples/recipes/dashboard-stats/README.md +38 -0
- package/samples/recipes/dashboard-stats/package.json +8 -0
- package/samples/recipes/dashboard-stats/src/feature.ts +104 -0
- package/samples/recipes/legal-pages/README.md +2 -2
- package/samples/recipes/managed-pages/README.md +2 -2
- package/samples/recipes/pdf-audit-export/README.md +2 -1
- package/samples/recipes/pdf-audit-export/package.json +1 -1
- package/samples/recipes/pdf-audit-export/src/feature.ts +68 -48
- package/samples/recipes/workflow-engine/src/db/queries/suspended-runs.ts +2 -2
- package/samples/apps/use-all-bundled/schema/generate.ts +0 -617
- package/samples/apps/use-all-bundled/schema/migrations/meta/0000_snapshot.json +0 -3055
- package/samples/apps/use-all-bundled/schema/migrations/meta/_journal.json +0 -13
- package/samples/apps/use-all-bundled/schema/schema.generated.ts +0 -94
- package/samples/apps/use-all-bundled/schema/schema.ts +0 -9
|
@@ -77,24 +77,28 @@ export async function dispatchMagicLinkMail(
|
|
|
77
77
|
// mailT already root-falls-back; appUrl must use the same negotiated tag
|
|
78
78
|
// or language-in-path apps 404 on e.g. /de-AT/activate.
|
|
79
79
|
const urlLocale = resolveMailLocale(locale);
|
|
80
|
+
// Template, expiry formatting and app-name fallback must share one locale,
|
|
81
|
+
// otherwise an English template shows e.g. French "3 heures".
|
|
82
|
+
const templateLocale = authMailTemplateLocale(spec.notificationType, locale);
|
|
80
83
|
const { validFor, validUntil } = tokenMailExpiry(
|
|
81
84
|
{
|
|
82
85
|
expiresAt: params.expiresAt,
|
|
83
86
|
...(params.issuedAt !== undefined && { issuedAt: params.issuedAt }),
|
|
84
87
|
...(params.timeZone !== undefined && { timeZone: params.timeZone }),
|
|
85
88
|
},
|
|
86
|
-
|
|
89
|
+
templateLocale,
|
|
87
90
|
);
|
|
88
91
|
await notify(spec.notificationType, {
|
|
89
92
|
route: { email: params.email },
|
|
90
93
|
data: {
|
|
91
|
-
appName:
|
|
94
|
+
appName:
|
|
95
|
+
params.appName ?? spec.fallbackAppName ?? mailT(templateLocale, "auth.mail.appNameDefault"),
|
|
92
96
|
actionUrl: appendToken(resolveAppUrl(params.appUrl, urlLocale), params.token),
|
|
93
97
|
validFor,
|
|
94
98
|
validUntil,
|
|
95
99
|
...(params.role !== undefined && { role: params.role }),
|
|
96
100
|
},
|
|
97
|
-
locale:
|
|
101
|
+
locale: templateLocale,
|
|
98
102
|
priority: "critical",
|
|
99
103
|
});
|
|
100
104
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// Server-registered so the document designer names the seeded auth mails.
|
|
3
|
+
export const AUTH_EMAIL_PASSWORD_MAIL_I18N: Readonly<Record<string, { readonly en: string }>> = {
|
|
4
|
+
"auth-email-password:feature.label": { en: "Sign-in" },
|
|
5
|
+
"auth-email-password:notify.password-reset.label": { en: "Password reset" },
|
|
6
|
+
"auth-email-password:notify.account-unlock.label": { en: "Account unlock" },
|
|
7
|
+
"auth-email-password:notify.email-verification.label": { en: "Email verification" },
|
|
8
|
+
"auth-email-password:notify.signup-activation.label": { en: "Sign-up activation" },
|
|
9
|
+
"auth-email-password:notify.invite.label": { en: "Invitation" },
|
|
10
|
+
};
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// Screen rein konfigurieren, ohne den Gate selbst ersetzen zu müssen.
|
|
11
11
|
|
|
12
12
|
import { useLocale } from "@cosmicdrift/kumiko-renderer";
|
|
13
|
-
import { type ComponentType, type ReactNode, useEffect, useState } from "react";
|
|
13
|
+
import { type ComponentType, type ReactNode, useEffect, useMemo, useState } from "react";
|
|
14
14
|
import {
|
|
15
15
|
assertNavigableUrl,
|
|
16
16
|
buildLoginRedirectUrl,
|
|
@@ -204,6 +204,21 @@ export function resolveLoginRedirect(
|
|
|
204
204
|
};
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
+
// A throwing loginUrl builder must not take the whole app tree down in render;
|
|
208
|
+
// null falls through to the built-in login screen.
|
|
209
|
+
function resolveLoginRedirectOrNull(
|
|
210
|
+
loginUrl: string | LoginUrlBuilder,
|
|
211
|
+
locale: string,
|
|
212
|
+
location: Pick<Location, "origin" | "pathname" | "search" | "hash">,
|
|
213
|
+
): LoginRedirect | null {
|
|
214
|
+
try {
|
|
215
|
+
return resolveLoginRedirect(loginUrl, locale, location);
|
|
216
|
+
} catch (error) {
|
|
217
|
+
console.error("Auth gate: loginUrl could not be resolved, showing the built-in login", error);
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
207
222
|
export function makeAuthGate(opts: AuthGateOptions = {}): ComponentType<{
|
|
208
223
|
children: ReactNode;
|
|
209
224
|
}> {
|
|
@@ -213,10 +228,14 @@ export function makeAuthGate(opts: AuthGateOptions = {}): ComponentType<{
|
|
|
213
228
|
function AuthGate({ children }: { readonly children: ReactNode }): ReactNode {
|
|
214
229
|
const { status } = useSession();
|
|
215
230
|
const locale = useLocale().locale();
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
231
|
+
const { origin, pathname, search, hash } = window.location;
|
|
232
|
+
const loginRedirect = useMemo(
|
|
233
|
+
() =>
|
|
234
|
+
loginUrl !== undefined && status === "unauthenticated"
|
|
235
|
+
? resolveLoginRedirectOrNull(loginUrl, locale, { origin, pathname, search, hash })
|
|
236
|
+
: null,
|
|
237
|
+
[status, locale, origin, pathname, search, hash],
|
|
238
|
+
);
|
|
220
239
|
const redirectUrl = loginRedirect?.kind === "redirect" ? loginRedirect.url : null;
|
|
221
240
|
// kumiko-lint-ignore no-raw-hooks Phase-3 conversion tracked in #2312
|
|
222
241
|
useEffect(() => {
|
|
@@ -1,4 +1,52 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.365.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Termination record re-reads the stream after the provider call",
|
|
6
|
+
"detail": "The cancel-at-provider call can take long, so the termination event is appended with a freshly fetched stream version instead of the one read before it."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.365.0",
|
|
10
|
+
"type": "improvement",
|
|
11
|
+
"title": "Contract mails carry designer labels",
|
|
12
|
+
"detail": "Feature and notify labels for contract-confirmation, contract-termination-receipt and contract-termination-operator-notice name the contract mails in the document designer."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.364.0",
|
|
16
|
+
"type": "fix",
|
|
17
|
+
"title": "Stale subscription created events are ignored and manual-grant skips are logged",
|
|
18
|
+
"detail": "A created event for a subscription that is no longer current no longer replaces the active one while the current row blocks checkout, and the tier sync logs its own code when a manual grant keeps the tier."
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"version": "0.364.0",
|
|
22
|
+
"type": "fix",
|
|
23
|
+
"title": "Contract confirmation mail renders the terms' Markdown as formatting",
|
|
24
|
+
"detail": "The terms rows of the contract confirmation mail were plain text, so `**Right to cancel.**` reached the buyer with its asterisks (0.363.0) or lost its emphasis. Each row of `termsParagraphs` is now a richText value: bold, italic and http(s)/mailto/tel links render as HTML, headings become bold rows. Author HTML and images in the terms stay escaped text, and links outside those schemes stay \"label (url)\". Tenant overrides of the template keep working, the variable name and row structure are unchanged."
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"version": "0.364.0",
|
|
28
|
+
"type": "fix",
|
|
29
|
+
"title": "Terms in the contract confirmation mail no longer show raw Markdown",
|
|
30
|
+
"detail": "The terms Markdown is reduced to plain text rows: bold and italic markers are dropped, links become \"label (url)\", list items get their own row, and single line breaks inside a paragraph become spaces."
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"version": "0.364.0",
|
|
34
|
+
"type": "fix",
|
|
35
|
+
"title": "The termination receipt rate limit keys Redis with a keyed, handler-bound digest instead of an unkeyed SHA-256 of the email",
|
|
36
|
+
"detail": "HandlerContext gains an optional rateLimitDigest(value), an HMAC bound to the running handler, so a handler can build rate-limit keys without plaintext identifiers and cannot reproduce the dispatcher's payload-bucket digests. normalizePayloadBucketValue is exported from the pipeline entry point. The receipt limit reuses it, and withholds the receipt mail (the declaration is still recorded) when no digest is wired. Payload-bucket normalization now applies Unicode NFKC first, so fullwidth lookalikes share a bucket. Existing receipt buckets restart empty; they expire after the 24 hour window."
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"version": "0.364.0",
|
|
40
|
+
"type": "fix",
|
|
41
|
+
"title": "Checkout validates the one-off order label and price lookup",
|
|
42
|
+
"detail": "An invalid oneOffItemLabel or a provider without retrievePrices fails with a configuration error before the provider is called."
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"version": "0.364.0",
|
|
46
|
+
"type": "fix",
|
|
47
|
+
"title": "Contract termination downgrades late withdrawals and caps receipt mails",
|
|
48
|
+
"detail": "A withdrawal declared after the 14-day period ends at period end (operator notice withdrawal_period_expired); the declaration is always accepted, receipt mails are capped at 3 per recipient per day and carry only enum fields, request id and time; purchaser roles come from the catalog."
|
|
49
|
+
},
|
|
2
50
|
{
|
|
3
51
|
"version": "0.363.0",
|
|
4
52
|
"type": "breaking",
|
package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
+
import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
|
|
1
2
|
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
3
|
+
import { Marked } from "marked";
|
|
4
|
+
import {
|
|
5
|
+
isAllowedDocumentLinkHref,
|
|
6
|
+
isDocumentPlaceholderHref,
|
|
7
|
+
} from "../../template-resolver/index.js";
|
|
2
8
|
import type { CheckoutConsentRecordedPayload } from "../events.js";
|
|
3
9
|
import { type ConsentLocale, resolveConsentLocale } from "./consent-text.js";
|
|
4
10
|
|
|
@@ -138,13 +144,59 @@ function formatDate(iso: string, locale: ConsentLocale, timeZone: string): strin
|
|
|
138
144
|
}).format(epochMilliseconds);
|
|
139
145
|
}
|
|
140
146
|
|
|
141
|
-
//
|
|
142
|
-
//
|
|
147
|
+
// One richText row of inline HTML per Markdown block. The sanitizer shows a rejected
|
|
148
|
+
// row as escaped text, so author HTML, images and refused links stay plain text.
|
|
149
|
+
const ROW_END = "\n\n";
|
|
150
|
+
|
|
151
|
+
function isMailLinkHref(href: string): boolean {
|
|
152
|
+
return isAllowedDocumentLinkHref(href) && !isDocumentPlaceholderHref(href);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const inlineHtmlMarkdown = new Marked({ gfm: false, breaks: false });
|
|
156
|
+
inlineHtmlMarkdown.use({
|
|
157
|
+
renderer: {
|
|
158
|
+
heading({ tokens }) {
|
|
159
|
+
const headingHtml = this.parser.parseInline(tokens);
|
|
160
|
+
return `<strong>${headingHtml}</strong>${ROW_END}`;
|
|
161
|
+
},
|
|
162
|
+
paragraph({ tokens }) {
|
|
163
|
+
return `${this.parser.parseInline(tokens)}${ROW_END}`;
|
|
164
|
+
},
|
|
165
|
+
code: ({ text }) => `${escapeHtml(text)}${ROW_END}`,
|
|
166
|
+
blockquote({ tokens }) {
|
|
167
|
+
return this.parser.parse(tokens);
|
|
168
|
+
},
|
|
169
|
+
list({ items }) {
|
|
170
|
+
return items.map((item) => this.parser.parse(item.tokens)).join("");
|
|
171
|
+
},
|
|
172
|
+
hr: () => "",
|
|
173
|
+
space: () => "",
|
|
174
|
+
del({ tokens }) {
|
|
175
|
+
return this.parser.parseInline(tokens);
|
|
176
|
+
},
|
|
177
|
+
br: () => " ",
|
|
178
|
+
html: ({ text }) => escapeHtml(text),
|
|
179
|
+
image: ({ text }) => escapeHtml(text),
|
|
180
|
+
text(token) {
|
|
181
|
+
// Only block-level text (a list item's own text) carries inline child tokens.
|
|
182
|
+
return "tokens" in token && token.tokens
|
|
183
|
+
? `${this.parser.parseInline(token.tokens)}${ROW_END}`
|
|
184
|
+
: escapeHtml(token.text);
|
|
185
|
+
},
|
|
186
|
+
link({ href, tokens }) {
|
|
187
|
+
const labelHtml = this.parser.parseInline(tokens);
|
|
188
|
+
if (isMailLinkHref(href)) return `<a href="${escapeHtmlAttr(href)}">${labelHtml}</a>`;
|
|
189
|
+
return labelHtml === escapeHtml(href) ? labelHtml : `${labelHtml} (${escapeHtml(href)})`;
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
|
|
143
194
|
function termsParagraphsOf(markdown: string): readonly TextRow[] {
|
|
144
|
-
return
|
|
195
|
+
return inlineHtmlMarkdown
|
|
196
|
+
.parse(markdown, { async: false })
|
|
145
197
|
.split(/\n\s*\n/)
|
|
146
|
-
.map((
|
|
147
|
-
.filter((
|
|
198
|
+
.map((row) => row.replace(/\s+/g, " ").trim())
|
|
199
|
+
.filter((row) => row.length > 0)
|
|
148
200
|
.map((text) => ({ text }));
|
|
149
201
|
}
|
|
150
202
|
|
package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import { createHash } from "node:crypto";
|
|
5
5
|
import { type HandlerContext, SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
6
|
-
import { UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
|
|
6
|
+
import { InternalError, UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
|
|
7
7
|
import { generateId } from "@cosmicdrift/kumiko-framework/utils";
|
|
8
8
|
import * as z from "zod";
|
|
9
9
|
import {
|
|
@@ -15,6 +15,7 @@ import { paymentAggregateId, subscriptionAggregateId } from "../aggregate-id.js"
|
|
|
15
15
|
import {
|
|
16
16
|
CHECKOUT_CONSENT_RECORDED_EVENT_QN,
|
|
17
17
|
type CheckoutConsentRecordedPayload,
|
|
18
|
+
checkoutConsentRecordedPayloadSchema,
|
|
18
19
|
PAYMENT_AGGREGATE_TYPE,
|
|
19
20
|
SUBSCRIPTION_AGGREGATE_TYPE,
|
|
20
21
|
} from "../events.js";
|
|
@@ -50,6 +51,26 @@ export type OrderItem = {
|
|
|
50
51
|
readonly params?: Readonly<Record<string, string | number>>;
|
|
51
52
|
};
|
|
52
53
|
|
|
54
|
+
// Bounds come from the event schema the label ends up in, so an app label that
|
|
55
|
+
// passes here cannot fail the append after the provider session exists.
|
|
56
|
+
const orderItemSchema = z.object({
|
|
57
|
+
labelKey: checkoutConsentRecordedPayloadSchema.shape.itemLabelKey.unwrap(),
|
|
58
|
+
params: checkoutConsentRecordedPayloadSchema.shape.itemLabelParams,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
/** Throws an InternalError (an app configuration fault, not a buyer error) for a
|
|
62
|
+
* label the consent event could not store. */
|
|
63
|
+
export function assertValidOrderItem(item: OrderItem | undefined, priceId: string): void {
|
|
64
|
+
// skip: subscription checkouts have no one-off order item
|
|
65
|
+
if (item === undefined) return;
|
|
66
|
+
const parsed = orderItemSchema.safeParse(item);
|
|
67
|
+
if (!parsed.success) {
|
|
68
|
+
throw new InternalError({
|
|
69
|
+
message: `billing-foundation: consumerProtection.oneOffItemLabel returned an invalid label for priceId "${priceId}": ${parsed.error.message}`,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
53
74
|
export type ConsentPriceDetails = {
|
|
54
75
|
readonly tier: string | null;
|
|
55
76
|
readonly priceId: string;
|
package/packages/bundled-features/src/billing-foundation/consumer-protection/mail-notifications.ts
CHANGED
|
@@ -32,7 +32,10 @@ export const OPERATOR_NOTICE_VARIABLES: TemplateVariables = {
|
|
|
32
32
|
export const CONTRACT_CONFIRMATION_VARIABLES: TemplateVariables = {
|
|
33
33
|
contractLines: textRows(),
|
|
34
34
|
consentGivenAt: templateVariable.text({ required: true }),
|
|
35
|
-
termsParagraphs:
|
|
35
|
+
termsParagraphs: templateVariable.list(
|
|
36
|
+
{ text: templateVariable.richText({ required: true }) },
|
|
37
|
+
{ required: true },
|
|
38
|
+
),
|
|
36
39
|
operatorEmail: templateVariable.text({ required: true }),
|
|
37
40
|
};
|
|
38
41
|
|
package/packages/bundled-features/src/billing-foundation/consumer-protection/register-termination.ts
CHANGED
|
@@ -52,9 +52,19 @@ export function registerContractTermination(
|
|
|
52
52
|
// request: matched and unmatched requests then do the same work and the
|
|
53
53
|
// response time does not tell whether the email belongs to a customer.
|
|
54
54
|
// The tenant comes from the stored trigger event (the job's system user).
|
|
55
|
+
// Every declaration appends to the same subscription stream, so parallel runs
|
|
56
|
+
// and a declaration arriving mid-run hit version conflicts. Sequential runs
|
|
57
|
+
// rule out the first; a retry re-reads the stream and the requestId check in
|
|
58
|
+
// record-contract-termination keeps it from recording twice.
|
|
55
59
|
r.job(
|
|
56
60
|
"cancel-on-public-termination-declared",
|
|
57
|
-
{
|
|
61
|
+
{
|
|
62
|
+
trigger: { on: CONTRACT_TERMINATION_DECLARED_EVENT_QN },
|
|
63
|
+
runIn: "worker",
|
|
64
|
+
concurrency: "sequential",
|
|
65
|
+
retries: 3,
|
|
66
|
+
backoff: "exponential",
|
|
67
|
+
},
|
|
58
68
|
async (payload, ctx) => {
|
|
59
69
|
const declared = contractTerminationDeclaredPayloadSchema.safeParse(payload);
|
|
60
70
|
// skip: trigger event carries no usable declaration
|
package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-mail.ts
CHANGED
|
@@ -79,16 +79,22 @@ function declarationLines(
|
|
|
79
79
|
return lines.map((text) => ({ text }));
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
/** The receipt carries enum fields only: free text from an anonymous caller
|
|
83
|
+
* must not be echoed to an arbitrary address. */
|
|
84
|
+
export type ReceiptDeclaration = Pick<
|
|
85
|
+
TerminationDeclaration,
|
|
86
|
+
"declarationType" | "terminationKind"
|
|
87
|
+
>;
|
|
88
|
+
|
|
82
89
|
export type TerminationReceiptArgs = {
|
|
83
90
|
readonly locale: ConsentLocale;
|
|
84
91
|
readonly requestId: string;
|
|
85
92
|
readonly receivedAtIso: string;
|
|
86
|
-
readonly declaration:
|
|
93
|
+
readonly declaration: ReceiptDeclaration;
|
|
87
94
|
readonly operatorEmail: string;
|
|
88
95
|
};
|
|
89
96
|
|
|
90
|
-
/**
|
|
91
|
-
* whether (or how) the declaration matched a contract. */
|
|
97
|
+
/** The content must not depend on whether (or how) the declaration matched a contract. */
|
|
92
98
|
export function terminationReceiptVariables(
|
|
93
99
|
args: TerminationReceiptArgs,
|
|
94
100
|
): TerminationReceiptVariables {
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { normalizePayloadBucketValue } from "@cosmicdrift/kumiko-framework/pipeline";
|
|
3
|
+
|
|
4
|
+
// The declaration itself is a statutory duty and is never refused. Only the
|
|
5
|
+
// receipt mail is capped, so the endpoint cannot be used to mail one inbox
|
|
6
|
+
// from the operator's sender domain.
|
|
7
|
+
export const RECEIPT_MAILS_PER_RECIPIENT = 3;
|
|
8
|
+
export const RECEIPT_WINDOW_SECONDS = 24 * 60 * 60;
|
|
9
|
+
|
|
10
|
+
const RECEIPT_BUCKET_PREFIX = "termination-receipt";
|
|
11
|
+
|
|
12
|
+
function receiptBucketKey(ctx: HandlerContext, email: string): string | undefined {
|
|
13
|
+
const digest = ctx.rateLimitDigest?.(normalizePayloadBucketValue(email, "email"));
|
|
14
|
+
return digest === undefined ? undefined : `${RECEIPT_BUCKET_PREFIX}:${digest}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Consumes one receipt token for the recipient. Fails closed when the limiter
|
|
18
|
+
* errors or no digest key is wired: the declaration is already recorded, a missing receipt is the lesser harm. */
|
|
19
|
+
export async function mayMailReceipt(ctx: HandlerContext, email: string): Promise<boolean> {
|
|
20
|
+
// skip: no rate limiter wired, nothing to count against
|
|
21
|
+
if (!ctx.rateLimit) return true;
|
|
22
|
+
try {
|
|
23
|
+
const bucketKey = receiptBucketKey(ctx, email);
|
|
24
|
+
if (bucketKey === undefined) {
|
|
25
|
+
ctx.log?.warn(
|
|
26
|
+
"[billing-foundation:termination] no rate-limit digest available, receipt mail withheld",
|
|
27
|
+
);
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
const decision = await ctx.rateLimit.check(bucketKey, {
|
|
31
|
+
limit: RECEIPT_MAILS_PER_RECIPIENT,
|
|
32
|
+
windowSeconds: RECEIPT_WINDOW_SECONDS,
|
|
33
|
+
});
|
|
34
|
+
return decision.allowed;
|
|
35
|
+
} catch (error) {
|
|
36
|
+
ctx.log?.warn(
|
|
37
|
+
`[billing-foundation:termination] receipt rate limiter failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
38
|
+
);
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts
CHANGED
|
@@ -35,6 +35,7 @@ import type { ConsumerProtectionOptions, ResolvedBillingFoundationOptions } from
|
|
|
35
35
|
import { CONSENT_LOCALES, FALLBACK_CONSENT_LOCALE } from "./consent-locale.js";
|
|
36
36
|
import { notifyOperator } from "./termination-notify.js";
|
|
37
37
|
import type { OperatorNoticeReason } from "./termination-texts.js";
|
|
38
|
+
import { isWithinWithdrawalPeriod } from "./withdrawal-period.js";
|
|
38
39
|
|
|
39
40
|
const REQUESTED_CANCEL_TIMINGS = [
|
|
40
41
|
SubscriptionCancelTimings.periodEnd,
|
|
@@ -74,6 +75,8 @@ export type RecordContractTerminationResult = {
|
|
|
74
75
|
OperatorNoticeReason,
|
|
75
76
|
"provider_cannot_cancel" | "provider_error" | "no_active_subscription"
|
|
76
77
|
> | null;
|
|
78
|
+
/** An account withdrawal arrived after the period: cancelled at period end instead. */
|
|
79
|
+
readonly withdrawalPeriodExpired: boolean;
|
|
77
80
|
};
|
|
78
81
|
|
|
79
82
|
export const recordUnmatchedContractTerminationSchema = z
|
|
@@ -210,14 +213,19 @@ export function createRecordContractTerminationHandler(
|
|
|
210
213
|
effectiveAtIso: alreadyRecorded.effectiveAtIso,
|
|
211
214
|
providerCancel: alreadyRecorded.providerCancel,
|
|
212
215
|
noticeReason: null,
|
|
216
|
+
withdrawalPeriodExpired: false,
|
|
213
217
|
} satisfies RecordContractTerminationResult,
|
|
214
218
|
};
|
|
215
219
|
}
|
|
216
220
|
|
|
221
|
+
const withdrawalPeriodExpired =
|
|
222
|
+
payload.declarationType === "withdrawal" &&
|
|
223
|
+
payload.when === SubscriptionCancelTimings.immediately &&
|
|
224
|
+
!isWithinWithdrawalPeriod(stream.events, options.now());
|
|
217
225
|
const { providerCancel, effectiveAtIso, noticeReason } = await cancelAtProvider(
|
|
218
226
|
ctx,
|
|
219
227
|
tenantId,
|
|
220
|
-
payload.when,
|
|
228
|
+
withdrawalPeriodExpired ? SubscriptionCancelTimings.periodEnd : payload.when,
|
|
221
229
|
receivedAtIso,
|
|
222
230
|
);
|
|
223
231
|
|
|
@@ -230,7 +238,14 @@ export function createRecordContractTerminationHandler(
|
|
|
230
238
|
effectiveAtIso,
|
|
231
239
|
providerCancel,
|
|
232
240
|
};
|
|
233
|
-
|
|
241
|
+
// Re-fetch after the external call: a webhook appended meanwhile would
|
|
242
|
+
// otherwise fail the append on a stale expectedVersion although the
|
|
243
|
+
// provider already cancelled.
|
|
244
|
+
const streamAfterProviderCall = await ctx.fetchForWriting({
|
|
245
|
+
aggregateId: subscriptionAggregateId(tenantId),
|
|
246
|
+
aggregateType: SUBSCRIPTION_AGGREGATE_TYPE,
|
|
247
|
+
});
|
|
248
|
+
await streamAfterProviderCall.appendOne({
|
|
234
249
|
type: CONTRACT_TERMINATION_REQUESTED_EVENT_QN,
|
|
235
250
|
payload: recorded,
|
|
236
251
|
});
|
|
@@ -260,6 +275,7 @@ export function createRecordContractTerminationHandler(
|
|
|
260
275
|
effectiveAtIso,
|
|
261
276
|
providerCancel,
|
|
262
277
|
noticeReason,
|
|
278
|
+
withdrawalPeriodExpired,
|
|
263
279
|
} satisfies RecordContractTerminationResult,
|
|
264
280
|
};
|
|
265
281
|
},
|
package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts
CHANGED
|
@@ -33,10 +33,10 @@ import type { ConsumerProtectionOptions, ResolvedBillingFoundationOptions } from
|
|
|
33
33
|
import { resolveConsentLocale } from "./consent-text.js";
|
|
34
34
|
import { type TerminationDeclaration, terminationReceiptVariables } from "./termination-mail.js";
|
|
35
35
|
import { notifyOperator, type OperatorNoticePlan, requireNotify } from "./termination-notify.js";
|
|
36
|
+
import { mayMailReceipt } from "./termination-receipt-limit.js";
|
|
36
37
|
import type { RecordContractTerminationResult } from "./termination-record.js";
|
|
37
38
|
import type { OperatorNoticeReason } from "./termination-texts.js";
|
|
38
39
|
|
|
39
|
-
const TENANT_ADMIN_ROLE = "TenantAdmin";
|
|
40
40
|
const EXTRAORDINARY_REASON_MESSAGE = "reason is required for an extraordinary termination";
|
|
41
41
|
|
|
42
42
|
const authUserRowSchema = z.object({
|
|
@@ -109,28 +109,38 @@ async function sendTerminationMails(
|
|
|
109
109
|
consumerProtection: ConsumerProtectionOptions,
|
|
110
110
|
plan: TerminationMailPlan,
|
|
111
111
|
): Promise<void> {
|
|
112
|
-
await
|
|
113
|
-
|
|
114
|
-
|
|
112
|
+
const receiptAllowed = await mayMailReceipt(ctx, plan.declaration.email);
|
|
113
|
+
if (receiptAllowed) {
|
|
114
|
+
await requireNotify(ctx)(CONTRACT_TERMINATION_RECEIPT_NOTIFICATION_TYPE, {
|
|
115
|
+
route: { email: plan.declaration.email },
|
|
116
|
+
data: terminationReceiptVariables({
|
|
117
|
+
locale: plan.locale,
|
|
118
|
+
requestId: plan.requestId,
|
|
119
|
+
receivedAtIso: plan.receivedAtIso,
|
|
120
|
+
declaration: {
|
|
121
|
+
declarationType: plan.declaration.declarationType,
|
|
122
|
+
terminationKind: plan.declaration.terminationKind,
|
|
123
|
+
},
|
|
124
|
+
operatorEmail: consumerProtection.operatorEmail,
|
|
125
|
+
}),
|
|
115
126
|
locale: plan.locale,
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
priority: "critical",
|
|
123
|
-
});
|
|
127
|
+
priority: "critical",
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
const reasons = receiptAllowed
|
|
131
|
+
? plan.reasons
|
|
132
|
+
: [...plan.reasons, "receipt_rate_limited" as const];
|
|
124
133
|
// skip: no operator notice needed for this declaration
|
|
125
|
-
if (
|
|
126
|
-
await notifyOperator(ctx, consumerProtection, plan);
|
|
134
|
+
if (reasons.length === 0) return;
|
|
135
|
+
await notifyOperator(ctx, consumerProtection, { ...plan, reasons });
|
|
127
136
|
}
|
|
128
137
|
|
|
129
138
|
// Tenants the entered email can terminate a contract for: tenants where the
|
|
130
|
-
// user
|
|
139
|
+
// user holds a purchase role and the subscription is not terminal. Every identity
|
|
131
140
|
// below comes from the trusted lookups, never from the payload.
|
|
132
141
|
async function findTerminableTenantIds(
|
|
133
142
|
ctx: HandlerContext,
|
|
143
|
+
options: ResolvedBillingFoundationOptions,
|
|
134
144
|
requestTenantId: string,
|
|
135
145
|
email: string,
|
|
136
146
|
): Promise<readonly string[]> {
|
|
@@ -148,10 +158,11 @@ async function findTerminableTenantIds(
|
|
|
148
158
|
}),
|
|
149
159
|
);
|
|
150
160
|
if (!memberships.success) return [];
|
|
161
|
+
const purchaseRoles = purchaseRolesOf(options.catalog);
|
|
151
162
|
const adminTenantIds = [
|
|
152
163
|
...new Set(
|
|
153
164
|
memberships.data
|
|
154
|
-
.filter((membership) => membership.roles.includes(
|
|
165
|
+
.filter((membership) => membership.roles.some((role) => purchaseRoles.includes(role)))
|
|
155
166
|
.map((membership) => membership.tenantId),
|
|
156
167
|
),
|
|
157
168
|
];
|
|
@@ -180,7 +191,6 @@ export function createRequestContractTerminationHandler(
|
|
|
180
191
|
schema: requestContractTerminationSchema,
|
|
181
192
|
access: { roles: ["anonymous"] },
|
|
182
193
|
rateLimit: { per: "ip+handler", limit: 5, windowSeconds: 600 },
|
|
183
|
-
additionalRateLimits: [{ per: { payloadField: "email" }, limit: 3, windowSeconds: 86400 }],
|
|
184
194
|
...(consumerProtection.terminationScope === "platform" && { tenantlessAnonymous: true }),
|
|
185
195
|
escapeHatch: {
|
|
186
196
|
reason:
|
|
@@ -196,7 +206,12 @@ export function createRequestContractTerminationHandler(
|
|
|
196
206
|
const locale = resolveConsentLocale(payload.locale);
|
|
197
207
|
let matchedTenantId: string | undefined;
|
|
198
208
|
|
|
199
|
-
const candidates = await findTerminableTenantIds(
|
|
209
|
+
const candidates = await findTerminableTenantIds(
|
|
210
|
+
ctx,
|
|
211
|
+
options,
|
|
212
|
+
event.user.tenantId,
|
|
213
|
+
payload.email,
|
|
214
|
+
);
|
|
200
215
|
const [onlyCandidate] = candidates;
|
|
201
216
|
if (candidates.length === 1 && onlyCandidate !== undefined) {
|
|
202
217
|
matchedTenantId = onlyCandidate;
|
|
@@ -257,7 +272,7 @@ export function createTerminateContractHandler(
|
|
|
257
272
|
return {
|
|
258
273
|
name: "terminate-contract",
|
|
259
274
|
description:
|
|
260
|
-
"Terminates the tenant's subscription at the end of the billing period, or withdraws from the contract and cancels it immediately; sends a receipt to the caller's email.",
|
|
275
|
+
"Terminates the tenant's subscription at the end of the billing period, or withdraws from the contract and cancels it immediately within the 14-day withdrawal period (at period end after it); sends a receipt to the caller's email.",
|
|
261
276
|
schema: terminateContractSchema,
|
|
262
277
|
access: { roles: purchaseRolesOf(options.catalog) },
|
|
263
278
|
escapeHatch: {
|
|
@@ -313,6 +328,7 @@ export function createTerminateContractHandler(
|
|
|
313
328
|
},
|
|
314
329
|
reasons: [
|
|
315
330
|
...(data.noticeReason !== null ? [data.noticeReason] : []),
|
|
331
|
+
...(data.withdrawalPeriodExpired ? (["withdrawal_period_expired"] as const) : []),
|
|
316
332
|
...needsOperatorNotice(payload),
|
|
317
333
|
],
|
|
318
334
|
tenantId,
|
package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts
CHANGED
|
@@ -72,6 +72,8 @@ export const OPERATOR_NOTICE_REASONS = [
|
|
|
72
72
|
"provider_error",
|
|
73
73
|
"no_active_subscription",
|
|
74
74
|
"recording_failed",
|
|
75
|
+
"withdrawal_period_expired",
|
|
76
|
+
"receipt_rate_limited",
|
|
75
77
|
] as const;
|
|
76
78
|
export type OperatorNoticeReason = (typeof OPERATOR_NOTICE_REASONS)[number];
|
|
77
79
|
|
|
@@ -116,6 +118,10 @@ export const TERMINATION_TEXTS: Readonly<Record<ConsentLocale, TerminationTexts>
|
|
|
116
118
|
provider_error: "Die Kündigung beim Zahlungsanbieter ist fehlgeschlagen",
|
|
117
119
|
no_active_subscription: "Keine laufende Subscription beim Anbieter",
|
|
118
120
|
recording_failed: "Die Erklärung konnte nicht gespeichert werden",
|
|
121
|
+
withdrawal_period_expired:
|
|
122
|
+
"Widerruf nach Ablauf der Widerrufsfrist: zum Periodenende gekündigt, Anspruch prüfen",
|
|
123
|
+
receipt_rate_limited:
|
|
124
|
+
"Keine Eingangsbestätigung versendet: Limit pro Empfängeradresse erreicht",
|
|
119
125
|
},
|
|
120
126
|
noticeReasons: "Grund der Benachrichtigung",
|
|
121
127
|
channel: "Kanal",
|
|
@@ -199,6 +205,9 @@ export const TERMINATION_TEXTS: Readonly<Record<ConsentLocale, TerminationTexts>
|
|
|
199
205
|
provider_error: "Cancelling at the payment provider failed",
|
|
200
206
|
no_active_subscription: "No running subscription at the provider",
|
|
201
207
|
recording_failed: "The declaration could not be stored",
|
|
208
|
+
withdrawal_period_expired:
|
|
209
|
+
"Withdrawal after the withdrawal period: cancelled at period end, review the claim",
|
|
210
|
+
receipt_rate_limited: "No receipt mail sent: the per-recipient limit was reached",
|
|
202
211
|
},
|
|
203
212
|
noticeReasons: "Reason for this notice",
|
|
204
213
|
channel: "Channel",
|
package/packages/bundled-features/src/billing-foundation/consumer-protection/withdrawal-period.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
2
|
+
import { CHECKOUT_CONSENT_RECORDED_EVENT_QN, SUBSCRIPTION_CREATED_EVENT_QN } from "../events.js";
|
|
3
|
+
|
|
4
|
+
// § 355 BGB: the withdrawal period is 14 days from conclusion of the contract.
|
|
5
|
+
export const WITHDRAWAL_PERIOD_DAYS = 14;
|
|
6
|
+
const HOURS_PER_DAY = 24;
|
|
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
|
+
type PeriodStartEvent = { readonly type: string; readonly createdAt: Temporal.Instant };
|
|
14
|
+
|
|
15
|
+
/** The period runs from the latest consent or subscription start on the stream.
|
|
16
|
+
* A stream with neither has no provable start and counts as expired. */
|
|
17
|
+
export function isWithinWithdrawalPeriod(
|
|
18
|
+
events: readonly PeriodStartEvent[],
|
|
19
|
+
now: Temporal.Instant,
|
|
20
|
+
): boolean {
|
|
21
|
+
let latestStart: Temporal.Instant | undefined;
|
|
22
|
+
for (const event of events) {
|
|
23
|
+
if (!PERIOD_START_EVENT_TYPES.has(event.type)) continue;
|
|
24
|
+
if (
|
|
25
|
+
latestStart === undefined ||
|
|
26
|
+
event.createdAt.epochMilliseconds > latestStart.epochMilliseconds
|
|
27
|
+
) {
|
|
28
|
+
latestStart = event.createdAt;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
// skip: no consent or subscription-created event, nothing proves a running period
|
|
32
|
+
if (latestStart === undefined) return false;
|
|
33
|
+
const periodEnd = latestStart.add({ hours: WITHDRAWAL_PERIOD_DAYS * HOURS_PER_DAY });
|
|
34
|
+
return now.epochMilliseconds <= periodEnd.epochMilliseconds;
|
|
35
|
+
}
|