@cosmicdrift/kumiko-bundled-features 0.337.1 → 0.339.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-tools/tool-catalog.js +37 -17
- package/dist/auth-email-password/handlers/invite-accept-with-login.write.js +3 -2
- package/dist/auth-email-password/handlers/invite-accept.write.js +3 -4
- package/dist/auth-email-password/handlers/invite-info.query.js +4 -1
- package/dist/auth-email-password/handlers/invite-signup-complete.write.js +3 -4
- package/dist/auth-email-password/handlers/signup-confirm.write.js +2 -1
- package/dist/auth-email-password/handlers/signup-request.write.js +2 -2
- package/dist/auth-email-password/web/auth-gate.d.ts +12 -2
- package/dist/auth-email-password/web/auth-gate.js +25 -1
- package/dist/auth-email-password/web/auth-redirect.d.ts +5 -0
- package/dist/auth-email-password/web/auth-redirect.js +58 -0
- package/dist/auth-email-password/web/client-plugin.d.ts +8 -1
- package/dist/auth-email-password/web/client-plugin.js +2 -0
- package/dist/auth-email-password/web/index.d.ts +2 -1
- package/dist/auth-email-password/web/index.js +1 -0
- package/dist/auth-email-password/web/session.d.ts +3 -1
- package/dist/auth-email-password/web/session.js +8 -2
- package/dist/auth-mfa/feature.js +1 -1
- package/dist/auth-mfa/handlers/enable-confirm-preauth.write.js +1 -1
- package/dist/auth-mfa/handlers/enable-start-preauth.write.js +2 -1
- package/dist/auth-mfa/handlers/verify.write.js +1 -1
- package/dist/auth-mfa/mfa-code-verifier.js +1 -1
- package/dist/auth-mfa/mfa-status-checker.js +1 -1
- package/dist/billing-foundation/aggregate-id.d.ts +2 -0
- package/dist/billing-foundation/aggregate-id.js +8 -0
- package/dist/billing-foundation/checkout-core.d.ts +3 -0
- package/dist/billing-foundation/checkout-core.js +3 -0
- package/dist/billing-foundation/constants.d.ts +38 -0
- package/dist/billing-foundation/constants.js +43 -0
- package/dist/billing-foundation/consumer-protection/confirmation-mail.d.ts +21 -0
- package/dist/billing-foundation/consumer-protection/confirmation-mail.js +125 -0
- package/dist/billing-foundation/consumer-protection/consent-gate.d.ts +32 -0
- package/dist/billing-foundation/consumer-protection/consent-gate.js +96 -0
- package/dist/billing-foundation/consumer-protection/consent-locale.d.ts +5 -0
- package/dist/billing-foundation/consumer-protection/consent-locale.js +17 -0
- package/dist/billing-foundation/consumer-protection/consent-text.d.ts +11 -0
- package/dist/billing-foundation/consumer-protection/consent-text.js +28 -0
- package/dist/billing-foundation/consumer-protection/issue-confirmation.d.ts +15 -0
- package/dist/billing-foundation/consumer-protection/issue-confirmation.js +149 -0
- package/dist/billing-foundation/consumer-protection/register-consent.d.ts +3 -0
- package/dist/billing-foundation/consumer-protection/register-consent.js +63 -0
- package/dist/billing-foundation/consumer-protection/register-termination.d.ts +3 -0
- package/dist/billing-foundation/consumer-protection/register-termination.js +40 -0
- package/dist/billing-foundation/consumer-protection/termination-mail.d.ts +45 -0
- package/dist/billing-foundation/consumer-protection/termination-mail.js +76 -0
- package/dist/billing-foundation/consumer-protection/termination-notify.d.ts +18 -0
- package/dist/billing-foundation/consumer-protection/termination-notify.js +21 -0
- package/dist/billing-foundation/consumer-protection/termination-pages.d.ts +8 -0
- package/dist/billing-foundation/consumer-protection/termination-pages.js +268 -0
- package/dist/billing-foundation/consumer-protection/termination-record.d.ts +73 -0
- package/dist/billing-foundation/consumer-protection/termination-record.js +230 -0
- package/dist/billing-foundation/consumer-protection/termination-request.d.ts +45 -0
- package/dist/billing-foundation/consumer-protection/termination-request.js +231 -0
- package/dist/billing-foundation/consumer-protection/termination-texts.d.ts +62 -0
- package/dist/billing-foundation/consumer-protection/termination-texts.js +168 -0
- package/dist/billing-foundation/events.d.ts +105 -2
- package/dist/billing-foundation/events.js +88 -2
- package/dist/billing-foundation/feature.js +6 -2
- package/dist/billing-foundation/handlers/billing-plans.query.js +19 -1
- package/dist/billing-foundation/handlers/create-checkout-session.write.js +33 -2
- package/dist/billing-foundation/handlers/process-event.write.d.ts +2 -2
- package/dist/billing-foundation/handlers/process-event.write.js +43 -15
- package/dist/billing-foundation/handlers/process-payment-event.write.d.ts +2 -1
- package/dist/billing-foundation/handlers/process-payment-event.write.js +6 -2
- package/dist/billing-foundation/handlers/start-plan-checkout.write.js +17 -1
- package/dist/billing-foundation/handlers/sync-subscription.write.js +0 -1
- package/dist/billing-foundation/i18n.js +61 -0
- package/dist/billing-foundation/index.d.ts +3 -2
- package/dist/billing-foundation/index.js +2 -1
- package/dist/billing-foundation/tenant-destroy-hook.js +4 -4
- package/dist/billing-foundation/types.d.ts +54 -9
- package/dist/billing-foundation/validate-options.js +51 -10
- package/dist/billing-foundation/web/billing-plans-panel.js +40 -3
- package/dist/billing-foundation/web/cancel-contract-dialog.d.ts +7 -0
- package/dist/billing-foundation/web/cancel-contract-dialog.js +57 -0
- package/dist/billing-foundation/web/checkout-consent-dialog.d.ts +28 -0
- package/dist/billing-foundation/web/checkout-consent-dialog.js +38 -0
- package/dist/billing-foundation/webhook-handler.js +5 -2
- package/dist/cap-counter/with-cap-enforcement.js +7 -3
- package/dist/cap-overview/access.d.ts +2 -1
- package/dist/cap-overview/access.js +26 -5
- package/dist/cap-overview/feature.d.ts +4 -0
- package/dist/cap-overview/feature.js +8 -6
- package/dist/cap-overview/handlers/caps-usage.query.d.ts +1 -1
- package/dist/cap-overview/handlers/caps-usage.query.js +6 -4
- package/dist/cap-overview/handlers/tenant-caps-list.query.js +12 -7
- package/dist/cap-overview/index.d.ts +2 -2
- package/dist/cap-overview/index.js +2 -2
- package/dist/cap-overview/screens.d.ts +1 -1
- package/dist/cap-overview/screens.js +17 -16
- package/dist/channel-discord/discord-channel.d.ts +6 -0
- package/dist/channel-discord/discord-channel.js +19 -0
- package/dist/channel-discord/feature.d.ts +3 -0
- package/dist/channel-discord/feature.js +30 -0
- package/dist/channel-discord/index.d.ts +2 -0
- package/dist/channel-discord/index.js +2 -0
- package/dist/channel-slack/feature.d.ts +3 -0
- package/dist/channel-slack/feature.js +30 -0
- package/dist/channel-slack/index.d.ts +2 -0
- package/dist/channel-slack/index.js +2 -0
- package/dist/channel-slack/slack-channel.d.ts +5 -0
- package/dist/channel-slack/slack-channel.js +16 -0
- package/dist/channel-teams/feature.d.ts +3 -0
- package/dist/channel-teams/feature.js +34 -0
- package/dist/channel-teams/index.d.ts +2 -0
- package/dist/channel-teams/index.js +2 -0
- package/dist/channel-teams/teams-channel.d.ts +5 -0
- package/dist/channel-teams/teams-channel.js +38 -0
- package/dist/channel-telegram/feature.d.ts +3 -0
- package/dist/channel-telegram/feature.js +28 -0
- package/dist/channel-telegram/index.d.ts +2 -0
- package/dist/channel-telegram/index.js +2 -0
- package/dist/channel-telegram/telegram-channel.d.ts +8 -0
- package/dist/channel-telegram/telegram-channel.js +54 -0
- package/dist/config/handlers/set.write.js +18 -2
- package/dist/crypto-shredding/feature.js +3 -2
- package/dist/crypto-shredding/handlers/forget-subject.write.d.ts +1 -1
- package/dist/crypto-shredding/handlers/forget-subject.write.js +30 -19
- package/dist/custom-fields/feature.js +9 -6
- package/dist/custom-fields/handlers/clear-custom-field.write.js +2 -1
- package/dist/custom-fields/handlers/define-tenant-field.write.js +2 -1
- package/dist/custom-fields/handlers/set-custom-field.write.js +2 -1
- package/dist/custom-fields/wire-user-data-rights.js +1 -1
- package/dist/data-retention/feature.js +1 -1
- package/dist/delivery/channel-context.d.ts +2 -1
- package/dist/delivery/channel-context.js +2 -2
- package/dist/delivery/chat-webhook-channel.d.ts +19 -0
- package/dist/delivery/chat-webhook-channel.js +48 -0
- package/dist/delivery/chat-webhook-sender.d.ts +21 -0
- package/dist/delivery/chat-webhook-sender.js +102 -0
- package/dist/delivery/delivery-service.js +12 -7
- package/dist/delivery/index.d.ts +2 -0
- package/dist/delivery/index.js +2 -0
- package/dist/delivery/jobs.js +1 -1
- package/dist/delivery/testing.d.ts +2 -0
- package/dist/delivery/types.d.ts +3 -1
- package/dist/delivery/types.js +1 -2
- package/dist/document-ingest-foundation/feature.js +3 -2
- package/dist/file-derivatives/handlers/public-variant-by-file-ref.query.js +1 -1
- package/dist/files-tenant-data/index.js +1 -1
- package/dist/form-draft/feature.js +1 -1
- package/dist/form-draft/handlers/discard.write.js +2 -1
- package/dist/form-draft/handlers/save.write.js +2 -1
- package/dist/inbound-mail-foundation/connect-routes.d.ts +7 -0
- package/dist/inbound-mail-foundation/connect-routes.js +13 -8
- package/dist/inbound-mail-foundation/feature.js +1 -1
- package/dist/inbound-mail-foundation/handlers/ingest-message.write.js +2 -1
- package/dist/inbound-mail-foundation/index.d.ts +1 -0
- package/dist/inbound-mail-foundation/index.js +1 -0
- package/dist/inbound-mail-foundation/oauth-access-token.d.ts +28 -0
- package/dist/inbound-mail-foundation/oauth-access-token.js +131 -0
- package/dist/inbound-mail-foundation/tenant-destroy-hook.js +1 -1
- package/dist/inbound-mail-foundation/types.d.ts +18 -1
- package/dist/inbound-mail-foundation/watch-supervisor.d.ts +5 -0
- package/dist/inbound-mail-foundation/watch-supervisor.js +42 -14
- package/dist/managed-pages/handlers/set.write.js +2 -1
- package/dist/notes-history/executor.d.ts +2 -0
- package/dist/notes-history/executor.js +9 -1
- package/dist/notes-history/feature.d.ts +2 -3
- package/dist/notes-history/feature.js +2 -1
- package/dist/notes-history/handlers/add-note.write.d.ts +2 -1
- package/dist/notes-history/handlers/add-note.write.js +21 -4
- package/dist/notes-history-user-data/hooks.js +26 -8
- package/dist/renderer-simple/feature.d.ts +8 -2
- package/dist/renderer-simple/feature.js +8 -5
- package/dist/renderer-simple/index.d.ts +2 -2
- package/dist/renderer-simple/index.js +1 -1
- package/dist/renderer-simple/simple-renderer.d.ts +15 -0
- package/dist/renderer-simple/simple-renderer.js +74 -27
- package/dist/secrets/feature.js +1 -1
- package/dist/sessions/feature.js +1 -1
- package/dist/sessions/handlers/revoke-all-for-user.write.js +2 -1
- package/dist/subscription-mollie/plugin-methods.js +1 -0
- package/dist/subscription-mollie/verify-webhook.js +5 -3
- package/dist/subscription-stripe/plugin-methods.d.ts +3 -1
- package/dist/subscription-stripe/plugin-methods.js +78 -13
- package/dist/subscription-stripe/verify-webhook.js +7 -3
- package/dist/tags/executor.d.ts +2 -0
- package/dist/tags/executor.js +9 -1
- package/dist/tags/feature.d.ts +4 -3
- package/dist/tags/feature.js +4 -2
- package/dist/tags/handlers/assign-tag.write.d.ts +2 -1
- package/dist/tags/handlers/assign-tag.write.js +4 -4
- package/dist/tags/handlers/remove-tag.write.d.ts +2 -1
- package/dist/tags/handlers/remove-tag.write.js +3 -3
- package/dist/tenant-handover/handlers/claim.write.js +2 -2
- package/dist/tenant-lifecycle/feature.js +1 -1
- package/dist/tenant-lifecycle/handlers/cancel-destruction.write.js +4 -1
- package/dist/tenant-lifecycle/handlers/request-destruction.write.js +2 -1
- package/dist/tier-engine/feature.js +1 -1
- package/dist/tier-engine/handlers/get-tenant-tier.query.js +2 -1
- package/dist/tier-engine/handlers/set-tenant-tier.write.js +2 -1
- package/dist/user/handlers/detail.query.js +1 -0
- package/dist/user/handlers/list.query.js +1 -0
- package/dist/user-data-rights/feature.js +2 -2
- package/dist/user-data-rights/handlers/cancel-deletion.write.js +2 -1
- package/dist/user-data-rights/handlers/confirm-deletion-by-token.write.js +1 -1
- package/dist/user-data-rights/handlers/download-attempt-list.query.js +1 -0
- package/dist/user-data-rights/handlers/download-by-job.query.js +2 -1
- package/dist/user-data-rights/handlers/download-by-token.query.js +2 -1
- package/dist/user-data-rights/handlers/export-status.query.js +2 -1
- package/dist/user-data-rights/handlers/lift-restriction.write.js +3 -4
- package/dist/user-data-rights/handlers/my-audit-log.query.js +2 -1
- package/dist/user-data-rights/handlers/request-deletion-by-email.write.js +2 -1
- package/dist/user-data-rights/handlers/request-deletion.write.js +1 -1
- package/dist/user-data-rights/handlers/request-export.write.js +2 -1
- package/dist/user-data-rights/handlers/restrict-account.write.js +2 -2
- package/dist/user-data-rights/handlers/run-forget-cleanup.write.js +2 -1
- package/dist/user-data-rights/lib/update-user-lifecycle.js +1 -1
- package/dist/user-data-rights/run-forget-cleanup.js +30 -15
- package/dist/workflow-runner/event-subscriber.js +6 -2
- package/dist/workflow-runner/event-trigger.js +5 -0
- package/dist/workflow-runner/handlers/resume-run.write.js +147 -59
- package/dist/workflow-runner/pending-projection.js +1 -0
- package/dist/workflow-runner/runner.d.ts +7 -1
- package/dist/workflow-runner/runner.js +1 -1
- package/dist/workflow-runner/tables.js +8 -1
- package/package.json +41 -9
- package/src/agent-tools/changes.json +14 -0
- package/src/auth-email-password/changes.json +7 -0
- package/src/billing-foundation/changes.json +83 -0
- package/src/cap-counter/changes.json +7 -0
- package/src/cap-overview/changes.json +14 -0
- package/src/config/changes.json +7 -0
- package/src/crypto-shredding/changes.json +13 -0
- package/src/custom-fields/changes.json +7 -0
- package/src/delivery/changes.json +14 -0
- package/src/inbound-mail-foundation/changes.json +7 -0
- package/src/notes-history/changes.json +7 -0
- package/src/renderer-simple/changes.json +9 -1
- package/src/subscription-mollie/changes.json +13 -0
- package/src/subscription-stripe/changes.json +13 -0
- package/src/tags/changes.json +7 -0
- package/src/user-data-rights/changes.json +6 -0
- package/src/workflow-runner/changes.json +7 -0
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
3
3
|
import type { HandlerContext, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
4
|
import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
|
|
5
|
-
import { type BillingEventKinds, type BillingPlanAction, SUBSCRIPTION_PROVIDER_EXTENSION, type SubscriptionEventType, type SubscriptionStatus } from "./constants.js";
|
|
5
|
+
import { type BillingEventKinds, type BillingPlanAction, SUBSCRIPTION_PROVIDER_EXTENSION, type SubscriptionCancelTiming, type SubscriptionEventType, type SubscriptionStatus, type TerminationScope } from "./constants.js";
|
|
6
6
|
export type SubscriptionEvent = {
|
|
7
7
|
/** Discriminator against PaymentEvent. Optional (instead of required) so
|
|
8
8
|
* existing plugins like subscription-mollie, which don't set this field,
|
|
@@ -21,6 +21,9 @@ export type SubscriptionEvent = {
|
|
|
21
21
|
/** Plattform-Tenant-ID (provider-customer-metadata oder lookup
|
|
22
22
|
* via providerCustomerId). Plugin macht die resolution. */
|
|
23
23
|
readonly tenantId: string;
|
|
24
|
+
/** Consent id echoed back from checkout metadata; omitted when absent or
|
|
25
|
+
* malformed. */
|
|
26
|
+
readonly consentId?: string;
|
|
24
27
|
/** Provider-eigene customer-id für späteren Lookup bei events
|
|
25
28
|
* ohne metadata. */
|
|
26
29
|
readonly providerCustomerId: string;
|
|
@@ -41,9 +44,6 @@ export type SubscriptionEvent = {
|
|
|
41
44
|
* Mollie), so projection.ts leaves the column unchanged instead of
|
|
42
45
|
* nulling it. */
|
|
43
46
|
readonly cancelAt?: string | null;
|
|
44
|
-
/** Raw provider-payload — wird 1:1 in subscription-event.rawPayload
|
|
45
|
-
* archiviert. Plugin liefert das als JSON-stringified-string. */
|
|
46
|
-
readonly rawPayload: string;
|
|
47
47
|
};
|
|
48
48
|
export type PaymentEvent = {
|
|
49
49
|
/** Required — distinguishes this union member at runtime from
|
|
@@ -58,13 +58,16 @@ export type PaymentEvent = {
|
|
|
58
58
|
* create time) — never from a freely-choosable payload field, or an
|
|
59
59
|
* attacker could attribute payments to someone else's tenant. */
|
|
60
60
|
readonly tenantId: string;
|
|
61
|
+
/** Consent id echoed back from checkout metadata; omitted when absent or
|
|
62
|
+
* malformed. */
|
|
63
|
+
readonly consentId?: string;
|
|
64
|
+
/** Provider's checkout/payment object id (Stripe checkout session id) —
|
|
65
|
+
* lets consumers key their own grants without the raw provider payload. */
|
|
66
|
+
readonly providerCheckoutId?: string;
|
|
61
67
|
/** Provider's own customer-id. */
|
|
62
68
|
readonly providerCustomerId: string;
|
|
63
69
|
/** Provider's own price/plan-ID of the purchased item. */
|
|
64
70
|
readonly priceId: string;
|
|
65
|
-
/** Raw provider-payload — archived 1:1 into payment-event.rawPayload.
|
|
66
|
-
* Plugin delivers this as a JSON-stringified string. */
|
|
67
|
-
readonly rawPayload: string;
|
|
68
71
|
};
|
|
69
72
|
export type SubscriptionProviderPlugin = {
|
|
70
73
|
/**
|
|
@@ -127,6 +130,13 @@ export type SubscriptionProviderPlugin = {
|
|
|
127
130
|
* credit top-ups). Optional with default `"subscription"` — existing
|
|
128
131
|
* callers are unaffected. */
|
|
129
132
|
readonly mode?: "subscription" | "payment";
|
|
133
|
+
/** Id of the consumer-protection consent recorded for this checkout.
|
|
134
|
+
* Travels as provider metadata and comes back on the webhook event. */
|
|
135
|
+
readonly consentId?: string;
|
|
136
|
+
/** App locale (BCP-47-ish, e.g. "de", "de-DE") for the hosted page. */
|
|
137
|
+
readonly locale?: string;
|
|
138
|
+
/** Text shown next to the submit button on the hosted page. */
|
|
139
|
+
readonly submitMessage?: string;
|
|
130
140
|
}) => Promise<{
|
|
131
141
|
readonly url: string;
|
|
132
142
|
}>;
|
|
@@ -155,7 +165,10 @@ export type SubscriptionProviderPlugin = {
|
|
|
155
165
|
* nicht, wenn auch createPortalSession fehlt — dann ist
|
|
156
166
|
* Cancel-Flow Provider-Dashboard-only).
|
|
157
167
|
*/
|
|
158
|
-
readonly cancelSubscription?: (ctx: HandlerContext,
|
|
168
|
+
readonly cancelSubscription?: (ctx: HandlerContext, options: {
|
|
169
|
+
readonly providerSubscriptionId: string;
|
|
170
|
+
readonly when: SubscriptionCancelTiming;
|
|
171
|
+
}) => Promise<void>;
|
|
159
172
|
/**
|
|
160
173
|
* The provider's own price/plan-id → app-tier-name mapping. This is the
|
|
161
174
|
* one source `checkout-core`/`plan-catalog` derive both hardening
|
|
@@ -223,7 +236,6 @@ export type ProviderSubscriptionSnapshot = {
|
|
|
223
236
|
/** ISO instant string, same shape as `SubscriptionEventPayload.currentPeriodEndIso`. */
|
|
224
237
|
readonly currentPeriodEnd: string;
|
|
225
238
|
readonly cancelAt: string | null;
|
|
226
|
-
readonly rawPayload: string;
|
|
227
239
|
};
|
|
228
240
|
export declare const KNOWN_RECURRING_INTERVALS: readonly ["day", "week", "month", "year"];
|
|
229
241
|
export type RecurringInterval = (typeof KNOWN_RECURRING_INTERVALS)[number];
|
|
@@ -267,6 +279,28 @@ export type BillingPlanCatalog<TTier extends string = string> = {
|
|
|
267
279
|
/** Defaults to the only registered provider exposing `priceToTier`. */
|
|
268
280
|
readonly providerName?: string;
|
|
269
281
|
};
|
|
282
|
+
/** Consumer-protection consent gate (§ 312j / § 356 BGB style). When set,
|
|
283
|
+
* `start-plan-checkout` and `create-checkout-session` require a `consent`
|
|
284
|
+
* payload and record it as an event before the buyer reaches the provider. */
|
|
285
|
+
export type ConsumerProtectionOptions = {
|
|
286
|
+
/** Slug of the template-resolver `text-block` holding the terms text; its
|
|
287
|
+
* content hash is stored with every recorded consent. */
|
|
288
|
+
readonly termsTextBlock: string;
|
|
289
|
+
/** Per-locale VAT note; must carry at least `de` and `en`. */
|
|
290
|
+
readonly vatNote: Readonly<Record<string, string>>;
|
|
291
|
+
readonly operatorEmail: string;
|
|
292
|
+
/** Where the public § 312k declaration is reachable. `"tenant-host"`
|
|
293
|
+
* (default): on a host that resolves a tenant. `"platform"`: also on a
|
|
294
|
+
* host that resolves none (the platform apex) — the declaration then runs
|
|
295
|
+
* without a request tenant and finds the contract by the declarant's email. */
|
|
296
|
+
readonly terminationScope?: TerminationScope;
|
|
297
|
+
/** Root-relative paths or absolute https URLs. */
|
|
298
|
+
readonly legalLinks: {
|
|
299
|
+
readonly terms: string;
|
|
300
|
+
readonly withdrawal: string;
|
|
301
|
+
readonly privacy: string;
|
|
302
|
+
};
|
|
303
|
+
};
|
|
270
304
|
export type BillingFoundationOptions<TTier extends string = string> = {
|
|
271
305
|
/** Absolute http(s) URL. Its origin is the only redirect origin
|
|
272
306
|
* create-checkout-session/create-portal-session (when set) and the plan
|
|
@@ -276,6 +310,8 @@ export type BillingFoundationOptions<TTier extends string = string> = {
|
|
|
276
310
|
* NOT via `new URL(path, baseUrl)` (which would drop the prefix). */
|
|
277
311
|
readonly baseUrl?: string;
|
|
278
312
|
readonly catalog?: BillingPlanCatalog<TTier>;
|
|
313
|
+
/** Enables the consent gate on checkout; requires `baseUrl`. */
|
|
314
|
+
readonly consumerProtection?: ConsumerProtectionOptions;
|
|
279
315
|
/** Injectable clock for the stale-incomplete-subscription check
|
|
280
316
|
* (constants.ts's `isSubscriptionBlockingCheckout`) — same always-
|
|
281
317
|
* optional, real-time-default shape as `createDekCache`'s `now` option.
|
|
@@ -318,6 +354,15 @@ export type BillingPlansResult = {
|
|
|
318
354
|
} | null;
|
|
319
355
|
readonly canPurchase: boolean;
|
|
320
356
|
readonly plans: readonly BillingPlanView[];
|
|
357
|
+
/** Present only when `consumerProtection` is configured. */
|
|
358
|
+
readonly consumerProtection?: {
|
|
359
|
+
readonly consentTexts: Readonly<Record<"de" | "en", {
|
|
360
|
+
readonly earlyPerformance: string;
|
|
361
|
+
readonly withdrawalLoss: string;
|
|
362
|
+
readonly consentTextVersion: string;
|
|
363
|
+
}>>;
|
|
364
|
+
readonly legalLinks: ConsumerProtectionOptions["legalLinks"];
|
|
365
|
+
};
|
|
321
366
|
};
|
|
322
367
|
declare module "@cosmicdrift/kumiko-framework/engine" {
|
|
323
368
|
interface KumikoExtensionOptionsMap {
|
|
@@ -2,25 +2,66 @@
|
|
|
2
2
|
// options — split out of feature.ts to keep that file at registration-only
|
|
3
3
|
// concerns (app-feature-structure guard's 300-line budget). Internal — not
|
|
4
4
|
// re-exported from index.ts.
|
|
5
|
+
import * as z from "zod";
|
|
6
|
+
import { isTerminationScope, TERMINATION_SCOPES } from "./constants.js";
|
|
5
7
|
function isRootRelativePath(path) {
|
|
6
8
|
return path.startsWith("/") && !path.startsWith("//");
|
|
7
9
|
}
|
|
10
|
+
function isRootRelativeOrHttpsUrl(link) {
|
|
11
|
+
if (isRootRelativePath(link))
|
|
12
|
+
return true;
|
|
13
|
+
try {
|
|
14
|
+
return new URL(link).protocol === "https:";
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function validateConsumerProtection(cp) {
|
|
21
|
+
if (cp.termsTextBlock.trim() === "") {
|
|
22
|
+
throw new Error("createBillingFoundationFeature: consumerProtection.termsTextBlock is empty.");
|
|
23
|
+
}
|
|
24
|
+
for (const locale of ["de", "en"]) {
|
|
25
|
+
if (!cp.vatNote[locale]?.trim()) {
|
|
26
|
+
throw new Error(`createBillingFoundationFeature: consumerProtection.vatNote needs a non-empty "${locale}" entry.`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (!z.email().safeParse(cp.operatorEmail).success) {
|
|
30
|
+
throw new Error(`createBillingFoundationFeature: consumerProtection.operatorEmail "${cp.operatorEmail}" is not a valid email address.`);
|
|
31
|
+
}
|
|
32
|
+
if (cp.terminationScope !== undefined && !isTerminationScope(cp.terminationScope)) {
|
|
33
|
+
throw new Error(`createBillingFoundationFeature: consumerProtection.terminationScope "${String(cp.terminationScope)}" must be one of ${TERMINATION_SCOPES.map((scope) => `"${scope}"`).join(", ")}.`);
|
|
34
|
+
}
|
|
35
|
+
for (const [key, link] of Object.entries(cp.legalLinks)) {
|
|
36
|
+
if (!isRootRelativeOrHttpsUrl(link)) {
|
|
37
|
+
throw new Error(`createBillingFoundationFeature: consumerProtection.legalLinks.${key} "${link}" must be a root-relative path ("/..." not "//") or an absolute https URL.`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function validateBaseUrl(baseUrl) {
|
|
42
|
+
let parsed;
|
|
43
|
+
try {
|
|
44
|
+
parsed = new URL(baseUrl);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
throw new Error(`createBillingFoundationFeature: baseUrl "${baseUrl}" is not a parseable absolute URL.`);
|
|
48
|
+
}
|
|
49
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
50
|
+
throw new Error(`createBillingFoundationFeature: baseUrl "${baseUrl}" must use http or https (parsed protocol "${parsed.protocol}").`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
8
53
|
/** Validates `options` and throws a plain `Error` with a clear message at
|
|
9
54
|
* feature-definition time — same pattern as `createCapOverviewFeature`.
|
|
10
55
|
* `createBillingFoundationFeature()` (no options) always succeeds; the
|
|
11
56
|
* extra checks only fire once a caller opts into `baseUrl`/`catalog`. */
|
|
12
57
|
export function validateOptions(options) {
|
|
13
|
-
if (options.baseUrl !== undefined)
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
catch {
|
|
19
|
-
throw new Error(`createBillingFoundationFeature: baseUrl "${options.baseUrl}" is not a parseable absolute URL.`);
|
|
20
|
-
}
|
|
21
|
-
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
22
|
-
throw new Error(`createBillingFoundationFeature: baseUrl "${options.baseUrl}" must use http or https (parsed protocol "${parsed.protocol}").`);
|
|
58
|
+
if (options.baseUrl !== undefined)
|
|
59
|
+
validateBaseUrl(options.baseUrl);
|
|
60
|
+
if (options.consumerProtection) {
|
|
61
|
+
if (options.baseUrl === undefined) {
|
|
62
|
+
throw new Error("createBillingFoundationFeature: consumerProtection requires baseUrl (checkout redirect URLs are built from it).");
|
|
23
63
|
}
|
|
64
|
+
validateConsumerProtection(options.consumerProtection);
|
|
24
65
|
}
|
|
25
66
|
const { catalog } = options;
|
|
26
67
|
// skip: no catalog configured, nothing more to validate.
|
|
@@ -5,6 +5,8 @@ import { useLocale, useMutation, usePrimitives, useQuery, useTranslation, } from
|
|
|
5
5
|
import { formatMoney, PlanCard, PlanGrid, } from "@cosmicdrift/kumiko-renderer-web";
|
|
6
6
|
import { useState } from "react";
|
|
7
7
|
import { BillingPlanActions, SubscriptionFoundationHandlers, SubscriptionFoundationQueries, SubscriptionStatuses, } from "../constants.js";
|
|
8
|
+
import { CancelContractDialog } from "./cancel-contract-dialog.js";
|
|
9
|
+
import { CheckoutConsentDialog } from "./checkout-consent-dialog.js";
|
|
8
10
|
function intervalKey(price) {
|
|
9
11
|
if (price === null || price.interval === null)
|
|
10
12
|
return undefined;
|
|
@@ -12,6 +14,13 @@ function intervalKey(price) {
|
|
|
12
14
|
? `billing-foundation.plans.everyInterval.${price.interval}`
|
|
13
15
|
: `billing-foundation.plans.perInterval.${price.interval}`;
|
|
14
16
|
}
|
|
17
|
+
function renewalKey(price) {
|
|
18
|
+
if (price === null || price.interval === null)
|
|
19
|
+
return undefined;
|
|
20
|
+
return price.intervalCount !== null && price.intervalCount > 1
|
|
21
|
+
? `billing-foundation.consent.renewsEvery.${price.interval}`
|
|
22
|
+
: `billing-foundation.consent.renews.${price.interval}`;
|
|
23
|
+
}
|
|
15
24
|
function localizedParams(params, locale) {
|
|
16
25
|
if (params === undefined)
|
|
17
26
|
return undefined;
|
|
@@ -73,7 +82,7 @@ function planCta(plan, result, t, onAction, redirecting) {
|
|
|
73
82
|
export function BillingPlansPanel(_props) {
|
|
74
83
|
const t = useTranslation();
|
|
75
84
|
const locale = useLocale().locale();
|
|
76
|
-
const { Banner } = usePrimitives();
|
|
85
|
+
const { Banner, Button } = usePrimitives();
|
|
77
86
|
const query = useQuery(SubscriptionFoundationQueries.billingPlans, {});
|
|
78
87
|
const checkoutMutation = useMutation(SubscriptionFoundationHandlers.startPlanCheckout);
|
|
79
88
|
const switchMutation = useMutation(SubscriptionFoundationHandlers.switchPlan);
|
|
@@ -84,7 +93,21 @@ export function BillingPlansPanel(_props) {
|
|
|
84
93
|
// enabled during its own in-flight request — a double-click risk — and
|
|
85
94
|
// `mutation.pending` alone drops before `window.location.assign` completes.
|
|
86
95
|
const [redirecting, setRedirecting] = useState(false);
|
|
96
|
+
const [consentTier, setConsentTier] = useState(null);
|
|
97
|
+
const [cancelOpen, setCancelOpen] = useState(false);
|
|
98
|
+
async function orderWithConsent(tier, consent) {
|
|
99
|
+
const result = await checkoutMutation.mutate({ tier, consent });
|
|
100
|
+
if (result.isSuccess) {
|
|
101
|
+
setRedirecting(true);
|
|
102
|
+
window.location.assign(result.data.url);
|
|
103
|
+
}
|
|
104
|
+
return result;
|
|
105
|
+
}
|
|
87
106
|
async function handleAction(tier, kind) {
|
|
107
|
+
if (kind === "checkout" && query.data?.consumerProtection !== undefined) {
|
|
108
|
+
setConsentTier(tier);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
88
111
|
setRedirecting(true);
|
|
89
112
|
const mutation = kind === "checkout" ? checkoutMutation : switchMutation;
|
|
90
113
|
const result = await mutation.mutate({ tier });
|
|
@@ -114,7 +137,10 @@ export function BillingPlansPanel(_props) {
|
|
|
114
137
|
if (!query.data)
|
|
115
138
|
return null;
|
|
116
139
|
const result = query.data;
|
|
117
|
-
|
|
140
|
+
// The consent dialog shows the checkout error itself.
|
|
141
|
+
const mutationError = (consentTier === null ? checkoutMutation.error : null) ??
|
|
142
|
+
switchMutation.error ??
|
|
143
|
+
portalMutation.error;
|
|
118
144
|
const canManage = result.subscription !== null && !result.subscription.terminal && result.canPurchase;
|
|
119
145
|
function toCardProps(plan) {
|
|
120
146
|
const key = intervalKey(plan.price);
|
|
@@ -151,6 +177,8 @@ export function BillingPlansPanel(_props) {
|
|
|
151
177
|
// (a legacy/free/pilot tier outside `catalog.plans`) — when none of the
|
|
152
178
|
// rendered plans is the current one, a synthetic current-tier card (no
|
|
153
179
|
// price, no cta) keeps it visible regardless of enabled/disabled.
|
|
180
|
+
const consentPlan = consentTier !== null ? result.plans.find((plan) => plan.tier === consentTier) : undefined;
|
|
181
|
+
const consumerProtection = result.consumerProtection;
|
|
154
182
|
const currentTierInPlans = result.plans.some((plan) => plan.isCurrent);
|
|
155
183
|
const visiblePlans = result.enabled
|
|
156
184
|
? result.plans
|
|
@@ -161,5 +189,14 @@ export function BillingPlansPanel(_props) {
|
|
|
161
189
|
date: toInstant(result.subscription.cancelAt).toLocaleString(locale, {
|
|
162
190
|
dateStyle: "medium",
|
|
163
191
|
}),
|
|
164
|
-
}), " ", t("billing-foundation.plans.switchRequiresReactivation")] })), _jsxs(PlanGrid, { testId: "billing-plans-grid", children: [!currentTierInPlans && (_jsx(PlanCard, { title: t(result.currentTier.labelKey), current: true, testId: "billing-plan-card-current", features: result.currentTier.benefits.map((benefit) => t(benefit.labelKey, localizedParams(benefit.params, locale))), secondaryAction: manageOrReactivateAction(canManage, result.subscription, t, handleManage, redirecting) })), visiblePlans.map((plan) => (_jsx(PlanCard, { testId: `billing-plan-card-${plan.tier}`, ...toCardProps(plan) }, plan.tier)))] })
|
|
192
|
+
}), " ", t("billing-foundation.plans.switchRequiresReactivation")] })), consumerProtection !== undefined && canManage && (_jsx("div", { children: _jsx(Button, { variant: "secondary", onClick: () => setCancelOpen(true), testId: "billing-plans-cancel-contract", children: t("billing-foundation.cancel.open") }) })), _jsxs(PlanGrid, { testId: "billing-plans-grid", children: [!currentTierInPlans && (_jsx(PlanCard, { title: t(result.currentTier.labelKey), current: true, testId: "billing-plan-card-current", features: result.currentTier.benefits.map((benefit) => t(benefit.labelKey, localizedParams(benefit.params, locale))), secondaryAction: manageOrReactivateAction(canManage, result.subscription, t, handleManage, redirecting) })), visiblePlans.map((plan) => (_jsx(PlanCard, { testId: `billing-plan-card-${plan.tier}`, ...toCardProps(plan) }, plan.tier)))] }), consumerProtection !== undefined &&
|
|
193
|
+
consentPlan !== undefined &&
|
|
194
|
+
consentPlan.price !== null && (_jsx(CheckoutConsentDialog, { open: true, onOpenChange: (open) => {
|
|
195
|
+
if (!open)
|
|
196
|
+
setConsentTier(null);
|
|
197
|
+
}, planName: t(consentPlan.labelKey), price: {
|
|
198
|
+
amount: formatMoney(consentPlan.price.unitAmount, consentPlan.price.currency, locale),
|
|
199
|
+
renewalKey: renewalKey(consentPlan.price),
|
|
200
|
+
count: new Intl.NumberFormat(locale).format(consentPlan.price.intervalCount ?? 1),
|
|
201
|
+
}, consumerProtection: consumerProtection, onOrder: (consent) => orderWithConsent(consentPlan.tier, consent), onConsentTextOutdated: () => void query.refetch() })), consumerProtection !== undefined && canManage && (_jsx(CancelContractDialog, { open: cancelOpen, onOpenChange: setCancelOpen, onTerminated: () => void query.refetch() }))] }));
|
|
165
202
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
export type CancelContractDialogProps = {
|
|
3
|
+
readonly open: boolean;
|
|
4
|
+
readonly onOpenChange: (open: boolean) => void;
|
|
5
|
+
readonly onTerminated: () => void;
|
|
6
|
+
};
|
|
7
|
+
export declare function CancelContractDialog({ open, onOpenChange, onTerminated, }: CancelContractDialogProps): ReactNode;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// @runtime client
|
|
3
|
+
import { toInstant } from "@cosmicdrift/kumiko-headless";
|
|
4
|
+
import { useLocale, useMutation, usePrimitives, useTranslation, } from "@cosmicdrift/kumiko-renderer";
|
|
5
|
+
import { ModeSwitch } from "@cosmicdrift/kumiko-renderer-web";
|
|
6
|
+
import { useState } from "react";
|
|
7
|
+
import { SubscriptionFoundationHandlers } from "../constants.js";
|
|
8
|
+
const DECLARATION_TYPES = [
|
|
9
|
+
"termination",
|
|
10
|
+
"withdrawal",
|
|
11
|
+
];
|
|
12
|
+
const TERMINATION_KINDS = ["ordinary", "extraordinary"];
|
|
13
|
+
// kumiko-lint-ignore no-custom-primitives Uses usePrimitives().Modal internally, name is a billing domain action, not a primitive reimplementation
|
|
14
|
+
export function CancelContractDialog({ open, onOpenChange, onTerminated, }) {
|
|
15
|
+
const t = useTranslation();
|
|
16
|
+
const locale = useLocale().locale();
|
|
17
|
+
const { Modal, Heading, Text, Field, Input, Button, Banner } = usePrimitives();
|
|
18
|
+
const terminate = useMutation(SubscriptionFoundationHandlers.terminateContract);
|
|
19
|
+
const [step, setStep] = useState("form");
|
|
20
|
+
const [declarationType, setDeclarationType] = useState("termination");
|
|
21
|
+
const [terminationKind, setTerminationKind] = useState("ordinary");
|
|
22
|
+
const [reason, setReason] = useState("");
|
|
23
|
+
const [receipt, setReceipt] = useState(null);
|
|
24
|
+
const reasonMissing = terminationKind === "extraordinary" && reason.trim().length === 0;
|
|
25
|
+
async function submit() {
|
|
26
|
+
const trimmedReason = reason.trim();
|
|
27
|
+
const result = await terminate.mutate({
|
|
28
|
+
declarationType,
|
|
29
|
+
terminationKind,
|
|
30
|
+
...(trimmedReason.length > 0 && { reason: trimmedReason }),
|
|
31
|
+
});
|
|
32
|
+
if (!result.isSuccess)
|
|
33
|
+
return;
|
|
34
|
+
setReceipt(result.data);
|
|
35
|
+
setStep("receipt");
|
|
36
|
+
onTerminated();
|
|
37
|
+
}
|
|
38
|
+
function formatInstant(iso) {
|
|
39
|
+
return toInstant(iso).toLocaleString(locale, { dateStyle: "medium", timeStyle: "short" });
|
|
40
|
+
}
|
|
41
|
+
return (_jsx(Modal, { open: open, onOpenChange: onOpenChange, title: t("billing-foundation.cancel.title"), testId: "cancel-contract-dialog", children: _jsxs("div", { className: "flex flex-col gap-4", children: [_jsx(Heading, { variant: "page", children: t("billing-foundation.cancel.title") }), step === "form" && (_jsxs(_Fragment, { children: [_jsx(Text, { variant: "muted", children: t("billing-foundation.cancel.declaration") }), _jsx(ModeSwitch, { value: declarationType, options: DECLARATION_TYPES.map((value) => ({
|
|
42
|
+
value,
|
|
43
|
+
label: t(`billing-foundation.cancel.declaration.${value}`),
|
|
44
|
+
})), onChange: setDeclarationType, ariaLabel: t("billing-foundation.cancel.declaration"), testId: "cancel-declaration-type" }), declarationType === "withdrawal" && (_jsx(Text, { variant: "muted", children: t("billing-foundation.cancel.withdrawalHint") })), _jsx(Text, { variant: "muted", children: t("billing-foundation.cancel.kind") }), _jsx(ModeSwitch, { value: terminationKind, options: TERMINATION_KINDS.map((value) => ({
|
|
45
|
+
value,
|
|
46
|
+
label: t(`billing-foundation.cancel.kind.${value}`),
|
|
47
|
+
})), onChange: setTerminationKind, ariaLabel: t("billing-foundation.cancel.kind"), testId: "cancel-termination-kind" }), terminationKind === "extraordinary" && (_jsx(Field, { id: "cancel-reason", label: t("billing-foundation.cancel.reason"), required: true, children: _jsx(Input, { kind: "textarea", id: "cancel-reason", name: "cancel-reason", value: reason, onChange: setReason, required: true, rows: 3 }) })), _jsxs("div", { className: "flex justify-end gap-2", children: [_jsx(Button, { variant: "secondary", onClick: () => onOpenChange(false), testId: "cancel-contract-close", children: t("billing-foundation.cancel.close") }), _jsx(Button, { disabled: reasonMissing, onClick: () => setStep("confirm"), testId: "cancel-contract-continue", children: t("billing-foundation.cancel.continue") })] })] })), step === "confirm" && (_jsxs(_Fragment, { children: [_jsx(Text, { children: t("billing-foundation.cancel.confirmQuestion", {
|
|
48
|
+
declaration: t(`billing-foundation.cancel.declaration.${declarationType}`),
|
|
49
|
+
kind: t(`billing-foundation.cancel.kind.${terminationKind}`),
|
|
50
|
+
}) }), terminate.error !== null && (_jsx(Banner, { variant: "error", testId: "cancel-contract-error", children: t(terminate.error.i18nKey, terminate.error.i18nParams) })), _jsxs("div", { className: "flex justify-end gap-2", children: [_jsx(Button, { variant: "secondary", disabled: terminate.pending, onClick: () => setStep("form"), testId: "cancel-contract-back", children: t("billing-foundation.cancel.back") }), _jsx(Button, { variant: "danger", disabled: terminate.pending, loading: terminate.pending, onClick: submit, testId: "cancel-contract-submit", children: t("billing-foundation.cancel.submit") })] })] })), step === "receipt" && receipt !== null && (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex flex-col gap-1", "data-testid": "cancel-contract-receipt", children: [_jsx(Text, { children: t("billing-foundation.cancel.receivedAt", {
|
|
51
|
+
date: formatInstant(receipt.receivedAtIso),
|
|
52
|
+
}) }), _jsx(Text, { children: receipt.effectiveAtIso !== null
|
|
53
|
+
? t("billing-foundation.cancel.effectiveAt", {
|
|
54
|
+
date: formatInstant(receipt.effectiveAtIso),
|
|
55
|
+
})
|
|
56
|
+
: t("billing-foundation.cancel.effectiveUnknown") }), _jsx(Text, { variant: "muted", children: t("billing-foundation.cancel.emailConfirmation") })] }), _jsx("div", { className: "flex justify-end", children: _jsx(Button, { onClick: () => onOpenChange(false), testId: "cancel-contract-done", children: t("billing-foundation.cancel.close") }) })] }))] }) }));
|
|
57
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { WriteResult } from "@cosmicdrift/kumiko-headless";
|
|
2
|
+
import { type ReactNode } from "react";
|
|
3
|
+
import type { BillingPlansResult } from "../types.js";
|
|
4
|
+
export declare const CONSENT_TEXT_OUTDATED_CODE = "consent_text_outdated";
|
|
5
|
+
type ConsumerProtectionView = NonNullable<BillingPlansResult["consumerProtection"]>;
|
|
6
|
+
export type CheckoutConsentPayload = {
|
|
7
|
+
readonly earlyPerformanceRequested: true;
|
|
8
|
+
readonly withdrawalLossAcknowledged: true;
|
|
9
|
+
readonly consentTextVersion: string;
|
|
10
|
+
readonly locale: string;
|
|
11
|
+
};
|
|
12
|
+
export type CheckoutConsentDialogProps = {
|
|
13
|
+
readonly open: boolean;
|
|
14
|
+
readonly onOpenChange: (open: boolean) => void;
|
|
15
|
+
readonly planName: string;
|
|
16
|
+
readonly price: {
|
|
17
|
+
readonly amount: string;
|
|
18
|
+
readonly renewalKey?: string;
|
|
19
|
+
readonly count: string;
|
|
20
|
+
};
|
|
21
|
+
readonly consumerProtection: ConsumerProtectionView;
|
|
22
|
+
readonly onOrder: (consent: CheckoutConsentPayload) => Promise<WriteResult<{
|
|
23
|
+
url: string;
|
|
24
|
+
}>>;
|
|
25
|
+
readonly onConsentTextOutdated: () => void;
|
|
26
|
+
};
|
|
27
|
+
export declare function CheckoutConsentDialog({ open, onOpenChange, planName, price, consumerProtection, onOrder, onConsentTextOutdated, }: CheckoutConsentDialogProps): ReactNode;
|
|
28
|
+
export {};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useLocale, usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { resolveConsentLocale } from "../consumer-protection/consent-locale.js";
|
|
5
|
+
export const CONSENT_TEXT_OUTDATED_CODE = "consent_text_outdated";
|
|
6
|
+
// kumiko-lint-ignore no-custom-primitives Uses usePrimitives().Modal internally, name is a billing domain action, not a primitive reimplementation
|
|
7
|
+
export function CheckoutConsentDialog({ open, onOpenChange, planName, price, consumerProtection, onOrder, onConsentTextOutdated, }) {
|
|
8
|
+
const t = useTranslation();
|
|
9
|
+
const uiLocale = useLocale().locale();
|
|
10
|
+
const { Modal, Heading, Text, Field, Input, Button, Link, Banner } = usePrimitives();
|
|
11
|
+
const [earlyPerformance, setEarlyPerformance] = useState(false);
|
|
12
|
+
const [withdrawalLoss, setWithdrawalLoss] = useState(false);
|
|
13
|
+
const [submitting, setSubmitting] = useState(false);
|
|
14
|
+
const [errorText, setErrorText] = useState(null);
|
|
15
|
+
const consentLocale = resolveConsentLocale(uiLocale);
|
|
16
|
+
const texts = consumerProtection.consentTexts[consentLocale];
|
|
17
|
+
const { legalLinks } = consumerProtection;
|
|
18
|
+
async function order() {
|
|
19
|
+
setSubmitting(true);
|
|
20
|
+
setErrorText(null);
|
|
21
|
+
const result = await onOrder({
|
|
22
|
+
earlyPerformanceRequested: true,
|
|
23
|
+
withdrawalLossAcknowledged: true,
|
|
24
|
+
consentTextVersion: texts.consentTextVersion,
|
|
25
|
+
locale: consentLocale,
|
|
26
|
+
});
|
|
27
|
+
if (result.isSuccess)
|
|
28
|
+
return;
|
|
29
|
+
setSubmitting(false);
|
|
30
|
+
setErrorText(t(result.error.i18nKey, result.error.i18nParams));
|
|
31
|
+
if (result.error.code === CONSENT_TEXT_OUTDATED_CODE) {
|
|
32
|
+
setEarlyPerformance(false);
|
|
33
|
+
setWithdrawalLoss(false);
|
|
34
|
+
onConsentTextOutdated();
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return (_jsx(Modal, { open: open, onOpenChange: onOpenChange, title: t("billing-foundation.consent.title"), testId: "checkout-consent-dialog", children: _jsxs("div", { className: "flex flex-col gap-4", children: [_jsx(Heading, { variant: "page", children: t("billing-foundation.consent.title") }), _jsxs("div", { className: "flex flex-col gap-1", "data-testid": "checkout-consent-summary", children: [_jsxs(Text, { children: [planName, " \u2014 ", price.amount] }), price.renewalKey !== undefined && (_jsx(Text, { variant: "muted", children: t(price.renewalKey, { count: price.count }) })), _jsx(Text, { variant: "muted", children: t("billing-foundation.consent.cancelAnytime") })] }), _jsxs("div", { className: "flex flex-wrap gap-4", children: [_jsx(Link, { href: legalLinks.terms, target: "_blank", children: t("billing-foundation.consent.link.terms") }), _jsx(Link, { href: legalLinks.withdrawal, target: "_blank", children: t("billing-foundation.consent.link.withdrawal") }), _jsx(Link, { href: legalLinks.privacy, target: "_blank", children: t("billing-foundation.consent.link.privacy") })] }), _jsx(Field, { id: "consent-early-performance", label: texts.earlyPerformance, layout: "inline", children: _jsx(Input, { kind: "boolean", id: "consent-early-performance", name: "consent-early-performance", value: earlyPerformance, onChange: setEarlyPerformance, disabled: submitting }) }), _jsx(Field, { id: "consent-withdrawal-loss", label: texts.withdrawalLoss, layout: "inline", children: _jsx(Input, { kind: "boolean", id: "consent-withdrawal-loss", name: "consent-withdrawal-loss", value: withdrawalLoss, onChange: setWithdrawalLoss, disabled: submitting }) }), errorText !== null && (_jsx(Banner, { variant: "error", testId: "checkout-consent-error", children: errorText })), _jsxs("div", { className: "flex justify-end gap-2", children: [_jsx(Button, { variant: "secondary", disabled: submitting, onClick: () => onOpenChange(false), testId: "checkout-consent-cancel", children: t("billing-foundation.consent.back") }), _jsx(Button, { disabled: !earlyPerformance || !withdrawalLoss || submitting, loading: submitting, onClick: order, testId: "checkout-consent-order", children: t("billing-foundation.consent.order") })] })] }) }));
|
|
38
|
+
}
|
|
@@ -61,7 +61,10 @@ export function createSubscriptionWebhookRoute(options = {}) {
|
|
|
61
61
|
providerName: parsed.providerName,
|
|
62
62
|
providerCustomerId: parsed.providerCustomerId,
|
|
63
63
|
priceId: parsed.priceId,
|
|
64
|
-
|
|
64
|
+
...(parsed.consentId !== undefined && { consentId: parsed.consentId }),
|
|
65
|
+
...(parsed.providerCheckoutId !== undefined && {
|
|
66
|
+
providerCheckoutId: parsed.providerCheckoutId,
|
|
67
|
+
}),
|
|
65
68
|
},
|
|
66
69
|
});
|
|
67
70
|
return respondFromDispatch(c, dispatched.isSuccess && options.afterDispatch
|
|
@@ -81,7 +84,7 @@ export function createSubscriptionWebhookRoute(options = {}) {
|
|
|
81
84
|
tier: parsed.tier,
|
|
82
85
|
currentPeriodEndIso: parsed.currentPeriodEnd,
|
|
83
86
|
...(parsed.cancelAt !== undefined && { cancelAtIso: parsed.cancelAt }),
|
|
84
|
-
|
|
87
|
+
...(parsed.consentId !== undefined && { consentId: parsed.consentId }),
|
|
85
88
|
},
|
|
86
89
|
});
|
|
87
90
|
return respondFromDispatch(c, dispatched.isSuccess && options.afterDispatch
|
|
@@ -9,9 +9,13 @@
|
|
|
9
9
|
// Wrapper macht das Pattern explizit + co-located.
|
|
10
10
|
//
|
|
11
11
|
// **Atomicity caveat:** calendar booking runs in-process via bookCapUsage
|
|
12
|
-
// (see book-cap-usage.ts).
|
|
13
|
-
// SystemAdmin-only increment-rolling
|
|
14
|
-
//
|
|
12
|
+
// (see book-cap-usage.ts).
|
|
13
|
+
// Rolling booking still dispatches the SystemAdmin-only increment-rolling
|
|
14
|
+
// handler. In-process booking would work today only because the entity
|
|
15
|
+
// executor appends without the event-ownership check (that check runs in
|
|
16
|
+
// appendDomainEventCore alone); relying on that gap would break once the
|
|
17
|
+
// executor path enforces ownership, so rolling callers need a SystemAdmin
|
|
18
|
+
// identity until cap-counter declares an explicit foreign-booking opt-in.
|
|
15
19
|
//
|
|
16
20
|
// No automatic markSoftWarned here — that's inside enforceCapAndMaybeNotify
|
|
17
21
|
// (enforce-cap.ts).
|
|
@@ -1 +1,2 @@
|
|
|
1
|
-
export declare const
|
|
1
|
+
export declare const DEFAULT_CAP_USAGE_ROLES: readonly string[];
|
|
2
|
+
export declare function resolveCapUsageRoles(usageVisibleTo: readonly string[] | undefined): readonly string[];
|
|
@@ -1,8 +1,29 @@
|
|
|
1
1
|
import { access } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// tenant's usage. Deliberately not `access.authenticated` — that preset omits
|
|
5
|
-
// TenantAdmin and would revoke the access TenantAdmins already have. The
|
|
2
|
+
// Usage numbers are an operator concern by default; wider visibility is an
|
|
3
|
+
// explicit `usageVisibleTo` opt-in on createCapOverviewFeature. The my-caps
|
|
6
4
|
// screen and the query that fills its cards must carry the SAME rule, or the
|
|
7
5
|
// dashboard renders and every card 403s.
|
|
8
|
-
export const
|
|
6
|
+
export const DEFAULT_CAP_USAGE_ROLES = access.roles(...access.admin);
|
|
7
|
+
// Roles the engine itself knows (role-assignment.ts ranks). App-declared roles
|
|
8
|
+
// (assignableRole) only exist once the registry is composed, which is after
|
|
9
|
+
// this feature is defined, so they cannot be validated here.
|
|
10
|
+
const BUILT_IN_USAGE_VISIBLE_ROLES = new Set([
|
|
11
|
+
"User",
|
|
12
|
+
"Member",
|
|
13
|
+
"Editor",
|
|
14
|
+
"Admin",
|
|
15
|
+
"TenantAdmin",
|
|
16
|
+
"SystemAdmin",
|
|
17
|
+
]);
|
|
18
|
+
export function resolveCapUsageRoles(usageVisibleTo) {
|
|
19
|
+
if (usageVisibleTo === undefined)
|
|
20
|
+
return DEFAULT_CAP_USAGE_ROLES;
|
|
21
|
+
if (usageVisibleTo.length === 0) {
|
|
22
|
+
throw new Error("createCapOverviewFeature: `usageVisibleTo` must not be empty.");
|
|
23
|
+
}
|
|
24
|
+
const unknown = usageVisibleTo.filter((role) => !BUILT_IN_USAGE_VISIBLE_ROLES.has(role));
|
|
25
|
+
if (unknown.length > 0) {
|
|
26
|
+
throw new Error(`createCapOverviewFeature: usageVisibleTo has unknown role(s) ${unknown.map((r) => `"${r}"`).join(", ")} — known roles: ${[...BUILT_IN_USAGE_VISIBLE_ROLES].join(", ")}`);
|
|
27
|
+
}
|
|
28
|
+
return usageVisibleTo;
|
|
29
|
+
}
|
|
@@ -10,5 +10,9 @@ export type CreateCapOverviewOptions = {
|
|
|
10
10
|
* from (see screens.ts doc). "Filter by tier" was explicitly requested;
|
|
11
11
|
* omitting `tiers` silently drops that filter, not a supported default. */
|
|
12
12
|
readonly tiers?: readonly string[];
|
|
13
|
+
/** Roles that may read their own tenant's usage (`caps:usage` + the my-caps
|
|
14
|
+
* screen). Defaults to the admin roles (TenantAdmin, Admin, SystemAdmin);
|
|
15
|
+
* list "User"/"Editor" to show usage to regular members. Built-in roles only. */
|
|
16
|
+
readonly usageVisibleTo?: readonly string[];
|
|
13
17
|
};
|
|
14
18
|
export declare function createCapOverviewFeature(opts: CreateCapOverviewOptions): FeatureDefinition;
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
// platform-wide table of every tenant's tier, billing status, and
|
|
6
6
|
// usage against a configurable set of caps.
|
|
7
7
|
// 2. caps:usage query + my-caps / platform-tenant-caps dashboards —
|
|
8
|
-
// per-tenant usage cards.
|
|
9
|
-
// tenant; SystemAdmin can additionally view any tenant via `tenantId`.
|
|
8
|
+
// per-tenant usage cards. Tenant admins see their own
|
|
9
|
+
// tenant (`usageVisibleTo` widens it); SystemAdmin can additionally view any tenant via `tenantId`.
|
|
10
10
|
//
|
|
11
11
|
// **What this feature does NOT do:**
|
|
12
12
|
// - No writes. Reads tier-engine's read_tier_assignments, billing-
|
|
@@ -16,16 +16,18 @@
|
|
|
16
16
|
//
|
|
17
17
|
// **Boot-Dependencies:** tenant, tier-engine, billing-foundation.
|
|
18
18
|
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
19
|
+
import { resolveCapUsageRoles } from "./access.js";
|
|
19
20
|
import { CAP_OVERVIEW_FEATURE } from "./constants.js";
|
|
20
21
|
import { createCapsUsageQuery } from "./handlers/caps-usage.query.js";
|
|
21
22
|
import { createTenantCapsListQuery } from "./handlers/tenant-caps-list.query.js";
|
|
22
23
|
import { tenantOptionsQuery } from "./handlers/tenant-options.query.js";
|
|
23
24
|
import { CAP_OVERVIEW_I18N } from "./i18n.js";
|
|
24
|
-
import {
|
|
25
|
+
import { createMyCapsScreen, createTenantCapListScreen, platformTenantCapsScreen, } from "./screens.js";
|
|
25
26
|
export function createCapOverviewFeature(opts) {
|
|
26
27
|
if (opts.caps.length === 0) {
|
|
27
28
|
throw new Error("createCapOverviewFeature: `caps` must not be empty.");
|
|
28
29
|
}
|
|
30
|
+
const usageRoles = resolveCapUsageRoles(opts.usageVisibleTo);
|
|
29
31
|
const capIds = new Set(opts.caps.map((cap) => cap.id));
|
|
30
32
|
const listCaps = opts.listCaps ?? opts.caps.slice(0, 3).map((cap) => cap.id);
|
|
31
33
|
for (const id of listCaps) {
|
|
@@ -34,7 +36,7 @@ export function createCapOverviewFeature(opts) {
|
|
|
34
36
|
}
|
|
35
37
|
}
|
|
36
38
|
return defineFeature(CAP_OVERVIEW_FEATURE, (r) => {
|
|
37
|
-
r.describe("Read-only visibility into per-tenant tier assignment and cap usage. SystemAdmin gets a platform-wide tenant list with usage bars;
|
|
39
|
+
r.describe("Read-only visibility into per-tenant tier assignment and cap usage. SystemAdmin gets a platform-wide tenant list with usage bars; tenant admins (or the roles in `usageVisibleTo`) get their own tenant's usage as dashboard cards. Reads tier-engine, billing-foundation, and tenant data plus app-owned usage tables via caller-supplied CapSpec callbacks — never writes.");
|
|
38
40
|
r.uiHints({
|
|
39
41
|
displayLabel: "Cap Overview · Tier & Usage Visibility",
|
|
40
42
|
category: "operations",
|
|
@@ -45,10 +47,10 @@ export function createCapOverviewFeature(opts) {
|
|
|
45
47
|
r.requires("tier-engine");
|
|
46
48
|
r.requires("billing-foundation");
|
|
47
49
|
r.queryHandler(createTenantCapsListQuery(opts.caps, listCaps));
|
|
48
|
-
r.queryHandler(createCapsUsageQuery(opts.caps));
|
|
50
|
+
r.queryHandler(createCapsUsageQuery(opts.caps, usageRoles));
|
|
49
51
|
r.queryHandler(tenantOptionsQuery);
|
|
50
52
|
r.screen(createTenantCapListScreen(opts.caps, listCaps, opts.tiers));
|
|
51
|
-
r.screen(
|
|
53
|
+
r.screen(createMyCapsScreen(usageRoles));
|
|
52
54
|
r.screen(platformTenantCapsScreen);
|
|
53
55
|
r.translations({ keys: CAP_OVERVIEW_I18N });
|
|
54
56
|
});
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import { type QueryHandlerDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import type { CapSpec } from "../types.js";
|
|
3
|
-
export declare function createCapsUsageQuery(caps: readonly CapSpec[]): QueryHandlerDefinition;
|
|
3
|
+
export declare function createCapsUsageQuery(caps: readonly CapSpec[], usageRoles: readonly string[]): QueryHandlerDefinition;
|
|
@@ -4,15 +4,14 @@ import { crossTenantOverrideDenied, defineQueryHandler, } from "@cosmicdrift/kum
|
|
|
4
4
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
5
5
|
import * as z from "zod";
|
|
6
6
|
import { tierAssignmentEntity } from "../../tier-engine/index.js";
|
|
7
|
-
import { MY_CAPS_ACCESS_ROLES } from "../access.js";
|
|
8
7
|
import { computeFraction, computeTone, computeUnclampedFraction } from "../usage-math.js";
|
|
9
8
|
const tierAssignmentTable = buildEntityTable("tier-assignment", tierAssignmentEntity);
|
|
10
|
-
export function createCapsUsageQuery(caps) {
|
|
9
|
+
export function createCapsUsageQuery(caps, usageRoles) {
|
|
11
10
|
return defineQueryHandler({
|
|
12
11
|
name: "caps:usage",
|
|
13
12
|
description: "Returns the calling user's own tenant's configured caps with used amount, tier limit and utilisation fraction; use it to answer how close a tenant is to its quota (only SystemAdmin may target another tenant via tenantId).",
|
|
14
13
|
schema: z.object({ tenantId: z.string().min(1).optional() }),
|
|
15
|
-
access: { roles:
|
|
14
|
+
access: { roles: usageRoles },
|
|
16
15
|
// COUNT/SUM/AVG are covered by TenantDb.count/TenantDb.aggregate; the grant
|
|
17
16
|
// stays for app-owned CapSpec.usage() providers that still use unsafeRaw.
|
|
18
17
|
escapeHatch: {
|
|
@@ -48,9 +47,12 @@ export function createCapsUsageQuery(caps) {
|
|
|
48
47
|
? ctx.systemDb.assertRowsTenant(assignmentRows, "tenantId")
|
|
49
48
|
: assignmentRows;
|
|
50
49
|
const tier = checkedRows[0]?.tier ?? "";
|
|
50
|
+
// Override path: limits must read the TARGET tenant's config, not the
|
|
51
|
+
// SystemAdmin caller's.
|
|
52
|
+
const config = override === undefined ? ctx.config : ctx.configFor?.(targetTenantId);
|
|
51
53
|
const rows = await Promise.all(caps.map(async (cap) => {
|
|
52
54
|
const used = await cap.usage(db, targetTenantId);
|
|
53
|
-
const limit = await cap.limit(tier, { config
|
|
55
|
+
const limit = await cap.limit(tier, { config });
|
|
54
56
|
const base = {
|
|
55
57
|
id: cap.id,
|
|
56
58
|
label: cap.label,
|