@cosmicdrift/kumiko-bundled-features 0.342.0 → 0.344.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.
Files changed (123) hide show
  1. package/dist/audit/constants.d.ts +5 -1
  2. package/dist/audit/constants.js +5 -1
  3. package/dist/audit/escape-hatch-audit-sink.d.ts +1 -0
  4. package/dist/audit/escape-hatch-audit-sink.js +1 -0
  5. package/dist/audit/run-escape-hatch-retention.d.ts +3 -2
  6. package/dist/audit/run-escape-hatch-retention.js +71 -7
  7. package/dist/auth-email-password/email-templates.d.ts +4 -0
  8. package/dist/auth-email-password/email-templates.js +69 -16
  9. package/dist/auth-email-password/handlers/invite-create.write.js +4 -1
  10. package/dist/auth-email-password/handlers/signup-request.write.js +4 -1
  11. package/dist/auth-email-password/handlers/token-request-handler.js +3 -0
  12. package/dist/auth-email-password/magic-link-mail.d.ts +2 -0
  13. package/dist/auth-email-password/magic-link-mail.js +2 -0
  14. package/dist/billing-foundation/checkout-core.d.ts +15 -4
  15. package/dist/billing-foundation/checkout-core.js +18 -6
  16. package/dist/billing-foundation/consumer-protection/confirmation-mail.d.ts +13 -3
  17. package/dist/billing-foundation/consumer-protection/confirmation-mail.js +23 -20
  18. package/dist/billing-foundation/consumer-protection/consent-gate.d.ts +6 -1
  19. package/dist/billing-foundation/consumer-protection/consent-gate.js +6 -1
  20. package/dist/billing-foundation/consumer-protection/consent-locale.js +1 -1
  21. package/dist/billing-foundation/consumer-protection/issue-confirmation.js +24 -2
  22. package/dist/billing-foundation/events.d.ts +2 -0
  23. package/dist/billing-foundation/events.js +2 -0
  24. package/dist/billing-foundation/handlers/billing-plans.query.d.ts +1 -1
  25. package/dist/billing-foundation/handlers/billing-plans.query.js +5 -1
  26. package/dist/billing-foundation/handlers/create-checkout-session.write.js +46 -24
  27. package/dist/billing-foundation/index.d.ts +3 -1
  28. package/dist/billing-foundation/index.js +2 -0
  29. package/dist/billing-foundation/types.d.ts +16 -7
  30. package/dist/billing-foundation/types.js +3 -0
  31. package/dist/billing-foundation/validate-options.d.ts +1 -1
  32. package/dist/billing-foundation/validate-options.js +24 -2
  33. package/dist/billing-foundation/web/billing-plans-panel.js +1 -1
  34. package/dist/billing-foundation/web/cancel-contract-dialog.js +2 -2
  35. package/dist/billing-foundation/web/checkout-consent-dialog.d.ts +1 -1
  36. package/dist/billing-foundation/web/checkout-consent-dialog.js +4 -4
  37. package/dist/billing-foundation/web/index.d.ts +3 -1
  38. package/dist/billing-foundation/web/index.js +2 -0
  39. package/dist/channel-discord/feature.js +1 -0
  40. package/dist/channel-email/feature.js +1 -0
  41. package/dist/channel-in-app/feature.js +1 -0
  42. package/dist/channel-push/feature.js +1 -0
  43. package/dist/channel-slack/feature.js +1 -0
  44. package/dist/channel-teams/feature.js +1 -0
  45. package/dist/channel-teams/teams-channel.d.ts +2 -1
  46. package/dist/channel-teams/teams-channel.js +12 -0
  47. package/dist/channel-telegram/feature.js +1 -0
  48. package/dist/delivery/chat-webhook-channel.d.ts +2 -1
  49. package/dist/delivery/chat-webhook-channel.js +5 -2
  50. package/dist/delivery/chat-webhook-sender.d.ts +8 -1
  51. package/dist/delivery/chat-webhook-sender.js +45 -4
  52. package/dist/delivery/constants.d.ts +1 -1
  53. package/dist/delivery/constants.js +1 -1
  54. package/dist/delivery/delivery-service.d.ts +2 -1
  55. package/dist/delivery/delivery-service.js +55 -4
  56. package/dist/delivery/events.d.ts +19 -1
  57. package/dist/delivery/events.js +11 -1
  58. package/dist/delivery/feature.js +29 -5
  59. package/dist/delivery/handlers/log.query.d.ts +4 -2
  60. package/dist/delivery/handlers/log.query.js +46 -18
  61. package/dist/delivery/i18n.js +36 -0
  62. package/dist/delivery/index.d.ts +2 -1
  63. package/dist/delivery/index.js +1 -0
  64. package/dist/delivery/jobs.js +24 -10
  65. package/dist/delivery/notify-factory.d.ts +5 -0
  66. package/dist/delivery/notify-factory.js +6 -0
  67. package/dist/delivery/public-names.d.ts +4 -0
  68. package/dist/delivery/public-names.js +4 -0
  69. package/dist/delivery/tables.js +3 -0
  70. package/dist/delivery/testing.d.ts +2 -0
  71. package/dist/delivery/testing.js +2 -1
  72. package/dist/delivery/types.d.ts +5 -3
  73. package/dist/delivery/web/client-plugin.js +9 -1
  74. package/dist/delivery/web/delivery-channel-cell.d.ts +3 -0
  75. package/dist/delivery/web/delivery-channel-cell.js +10 -0
  76. package/dist/delivery/web/delivery-error-cell.d.ts +3 -0
  77. package/dist/delivery/web/delivery-error-cell.js +16 -0
  78. package/dist/delivery/web/delivery-status-cell.d.ts +1 -1
  79. package/dist/delivery/web/delivery-status-cell.js +11 -1
  80. package/dist/delivery/web/delivery-time-cell.d.ts +3 -0
  81. package/dist/delivery/web/delivery-time-cell.js +6 -0
  82. package/dist/delivery/web/delivery-type-cell.d.ts +3 -0
  83. package/dist/delivery/web/delivery-type-cell.js +17 -0
  84. package/dist/legal-pages/constants.d.ts +2 -0
  85. package/dist/legal-pages/feature.js +7 -2
  86. package/dist/renderer-simple/simple-renderer.js +8 -0
  87. package/dist/secrets/constants.d.ts +6 -0
  88. package/dist/secrets/constants.js +8 -0
  89. package/dist/secrets/feature.d.ts +3 -0
  90. package/dist/secrets/feature.js +3 -0
  91. package/dist/secrets/i18n.js +4 -3
  92. package/dist/secrets/index.d.ts +3 -2
  93. package/dist/secrets/index.js +3 -2
  94. package/dist/secrets/write-gate.d.ts +1 -0
  95. package/dist/secrets/write-gate.js +14 -6
  96. package/dist/shared/index.d.ts +1 -0
  97. package/dist/shared/index.js +1 -0
  98. package/dist/shared/redact.d.ts +4 -0
  99. package/dist/shared/redact.js +21 -0
  100. package/dist/step-dispatcher/feature.js +2 -1
  101. package/dist/step-dispatcher/mail-runner.d.ts +0 -1
  102. package/dist/step-dispatcher/mail-runner.js +0 -5
  103. package/dist/tenant/web/member-roles-cell.js +1 -1
  104. package/dist/tenant/web/member-status-cell.js +1 -1
  105. package/dist/tenant-lifecycle/feature.js +8 -2
  106. package/dist/tenant-lifecycle/run-tenant-destroy.d.ts +2 -1
  107. package/dist/tenant-lifecycle/run-tenant-destroy.js +16 -7
  108. package/dist/user/index.d.ts +1 -0
  109. package/dist/user/index.js +1 -0
  110. package/dist/user/resolve-display-names.d.ts +2 -0
  111. package/dist/user/resolve-display-names.js +24 -0
  112. package/package.json +9 -9
  113. package/src/audit/changes.json +6 -0
  114. package/src/auth-email-password/changes.json +7 -0
  115. package/src/billing-foundation/changes.json +7 -0
  116. package/src/channel-teams/changes.json +8 -0
  117. package/src/delivery/changes.json +32 -0
  118. package/src/jobs/changes.json +6 -0
  119. package/src/legal-pages/changes.json +6 -0
  120. package/src/renderer-simple/changes.json +6 -0
  121. package/src/secrets/changes.json +6 -0
  122. /package/dist/{tenant → shared}/web/translate-or-raw.d.ts +0 -0
  123. /package/dist/{tenant → shared}/web/translate-or-raw.js +0 -0
@@ -1,7 +1,7 @@
1
1
  // @runtime client
2
2
  // Crypto-free so the browser panel can share the locale rule with the server.
3
3
  export const CONSENT_LOCALES = ["de", "en"];
4
- export const FALLBACK_CONSENT_LOCALE = "de";
4
+ export const FALLBACK_CONSENT_LOCALE = "en";
5
5
  function isConsentLocale(value) {
6
6
  return CONSENT_LOCALES.some((locale) => locale === value);
7
7
  }
@@ -1,8 +1,10 @@
1
1
  // § 312f contract confirmation: one mail per recorded consent, sent by the
2
2
  // system-only issue-contract-confirmation handler that the event-triggered
3
3
  // jobs (register-consent.ts) call.
4
- import { access, createSystemUser, } from "@cosmicdrift/kumiko-framework/engine";
4
+ import { access, createSystemUser, SYSTEM_TENANT_ID, } from "@cosmicdrift/kumiko-framework/engine";
5
5
  import { InternalError, UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
6
+ import { createI18n } from "@cosmicdrift/kumiko-framework/i18n";
7
+ import { isValidIanaTimeZone } from "@cosmicdrift/kumiko-framework/time";
6
8
  import * as z from "zod";
7
9
  import { requireTemplateResolver, TEXT_BLOCK_KIND } from "../../template-resolver/index.js";
8
10
  import { UserQueries } from "../../user/index.js";
@@ -22,7 +24,17 @@ export const issueContractConfirmationSchema = z
22
24
  const recipientRowSchema = z.object({
23
25
  email: z.string().min(1),
24
26
  isDeleted: z.boolean().optional(),
27
+ timezone: z.string().nullish(),
25
28
  });
29
+ // createI18n returns the key itself for an unknown key.
30
+ function translatedOrUndefined(translation, key) {
31
+ return translation === key ? undefined : translation;
32
+ }
33
+ function pickTimeZone(userZone, tenantZone) {
34
+ if (userZone && isValidIanaTimeZone(userZone))
35
+ return userZone;
36
+ return isValidIanaTimeZone(tenantZone) ? tenantZone : "UTC";
37
+ }
26
38
  export function createIssueContractConfirmationHandler(options, consumerProtection) {
27
39
  return {
28
40
  name: "issue-contract-confirmation",
@@ -100,7 +112,7 @@ export function createIssueContractConfirmationHandler(options, consumerProtecti
100
112
  }
101
113
  const locale = resolveConsentLocale(consent.locale);
102
114
  const terms = await requireTemplateResolver(ctx, "billing-foundation").resolveTemplate({
103
- tenantId,
115
+ tenantId: SYSTEM_TENANT_ID,
104
116
  slug: consumerProtection.termsTextBlock,
105
117
  kind: TEXT_BLOCK_KIND,
106
118
  locale,
@@ -108,9 +120,19 @@ export function createIssueContractConfirmationHandler(options, consumerProtecti
108
120
  if (terms.version !== consent.termsTemplateVersion) {
109
121
  ctx.log?.warn(`[billing-foundation:issue-contract-confirmation] terms text block changed since consent ${payload.consentId} (version ${consent.termsTemplateVersion} -> ${terms.version}); the mail carries the current text`);
110
122
  }
123
+ const i18n = createI18n(ctx.registry, { defaultLocale: "en" });
124
+ const tierLabel = consent.tier !== null && options.catalog
125
+ ? translatedOrUndefined(i18n.t(options.catalog.tierLabelKey(consent.tier), locale), options.catalog.tierLabelKey(consent.tier))
126
+ : undefined;
127
+ const itemLabel = consent.itemLabelKey !== undefined
128
+ ? translatedOrUndefined(i18n.t(consent.itemLabelKey, locale, consent.itemLabelParams), consent.itemLabelKey)
129
+ : undefined;
111
130
  const issuedAtIso = options.now().toString();
112
131
  const content = renderContractConfirmation({
113
132
  consent,
133
+ timeZone: pickTimeZone(recipient.data.timezone, ctx.tz.tenant),
134
+ ...(tierLabel !== undefined && { tierLabel }),
135
+ ...(itemLabel !== undefined && { itemLabel }),
114
136
  consentGivenAtIso: consentEvent.createdAt.toString(),
115
137
  contractStartIso: issuedAtIso,
116
138
  ...(payload.currentPeriodEndIso !== undefined && {
@@ -65,6 +65,8 @@ export declare const checkoutConsentRecordedPayloadSchema: z.ZodObject<{
65
65
  termsTemplateVersion: z.ZodNumber;
66
66
  locale: z.ZodString;
67
67
  actorUserId: z.ZodString;
68
+ itemLabelKey: z.ZodOptional<z.ZodString>;
69
+ itemLabelParams: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>>>;
68
70
  }, z.core.$strip>;
69
71
  export type CheckoutConsentRecordedPayload = z.infer<typeof checkoutConsentRecordedPayloadSchema>;
70
72
  export declare const CONTRACT_CONFIRMATION_ISSUED_EVENT_SHORT: "contract-confirmation-issued";
@@ -101,6 +101,8 @@ export const checkoutConsentRecordedPayloadSchema = z.object({
101
101
  termsTemplateVersion: z.number(),
102
102
  locale: z.string().min(2).max(35),
103
103
  actorUserId: z.string().min(1),
104
+ itemLabelKey: z.string().min(1).max(200).optional(),
105
+ itemLabelParams: z.record(z.string(), z.union([z.string().max(100), z.number()])).optional(),
104
106
  });
105
107
  // =============================================================================
106
108
  // contract-confirmation-issued — § 312f confirmation mail sent
@@ -1,3 +1,3 @@
1
1
  import type { QueryHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
2
- import type { BillingPlanCatalog, ResolvedBillingFoundationOptions } from "../types.js";
2
+ import { type BillingPlanCatalog, type ResolvedBillingFoundationOptions } from "../types.js";
3
3
  export declare function createBillingPlansQuery(options: ResolvedBillingFoundationOptions, catalog: BillingPlanCatalog): QueryHandlerDef;
@@ -5,6 +5,7 @@ import * as z from "zod";
5
5
  import { findCatalogProvider } from "../checkout-core.js";
6
6
  import { CONSENT_TEXTS, consentTextVersion, } from "../consumer-protection/consent-text.js";
7
7
  import { buildBillingPlans } from "../plan-catalog.js";
8
+ import { isLegalLinkSet, } from "../types.js";
8
9
  const billingPlansSchema = z.object({}).strict();
9
10
  export function createBillingPlansQuery(options, catalog) {
10
11
  return {
@@ -25,12 +26,15 @@ export function createBillingPlansQuery(options, catalog) {
25
26
  de: consentTextView("de"),
26
27
  en: consentTextView("en"),
27
28
  },
28
- legalLinks: consumerProtection.legalLinks,
29
+ legalLinks: legalLinksPerLocale(consumerProtection.legalLinks),
29
30
  },
30
31
  };
31
32
  },
32
33
  };
33
34
  }
35
+ function legalLinksPerLocale(legalLinks) {
36
+ return isLegalLinkSet(legalLinks) ? { de: legalLinks, en: legalLinks } : legalLinks;
37
+ }
34
38
  function consentTextView(locale) {
35
39
  const { earlyPerformance, withdrawalLoss } = CONSENT_TEXTS[locale];
36
40
  return { earlyPerformance, withdrawalLoss, consentTextVersion: consentTextVersion(locale) };
@@ -21,8 +21,9 @@
21
21
  // billing-foundation:write:switch-plan instead. Plan-driven callers should
22
22
  // prefer start-plan-checkout/switch-plan over calling this handler with a
23
23
  // hand-picked priceId.
24
+ import { UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
24
25
  import * as z from "zod";
25
- import { findProviderPlugin, openCheckout } from "../checkout-core.js";
26
+ import { assertCheckoutAllowed, openCheckout, startProviderCheckout, } from "../checkout-core.js";
26
27
  import { consentCheckoutFields, consentPayloadSchema, prepareConsent, recordConsent, } from "../consumer-protection/consent-gate.js";
27
28
  const createCheckoutSessionSchema = z.object({
28
29
  /** Welcher Provider — entityName eines registrierten subscription-
@@ -44,7 +45,7 @@ const createCheckoutSessionSchema = z.object({
44
45
  export function createCheckoutSessionHandler(options) {
45
46
  const consumerProtection = options.consumerProtection;
46
47
  const schema = consumerProtection
47
- ? createCheckoutSessionSchema.extend({ consent: consentPayloadSchema.optional() })
48
+ ? createCheckoutSessionSchema.extend({ consent: consentPayloadSchema.optional() }).strict()
48
49
  : createCheckoutSessionSchema;
49
50
  return {
50
51
  name: "create-checkout-session",
@@ -56,27 +57,39 @@ export function createCheckoutSessionHandler(options) {
56
57
  handler: async (event, ctx) => {
57
58
  // @cast-boundary engine-payload — dispatcher-zod-validated payload
58
59
  const payload = event.payload;
59
- const mode = payload.mode ?? "subscription";
60
- const consent = consumerProtection
61
- ? await prepareConsent(ctx, consumerProtection, payload.consent, mode)
62
- : undefined;
63
- const result = await openCheckout(ctx, { baseUrl: options.baseUrl, catalog: options.catalog, now: options.now }, {
60
+ const openOptions = { baseUrl: options.baseUrl, catalog: options.catalog, now: options.now };
61
+ const checkoutInput = {
64
62
  providerName: payload.providerName,
65
63
  priceId: payload.priceId,
66
64
  successUrl: payload.successUrl,
67
65
  cancelUrl: payload.cancelUrl,
68
66
  ...(payload.providerCustomerId && { providerCustomerId: payload.providerCustomerId }),
69
67
  ...(payload.mode && { mode: payload.mode }),
70
- ...(consent && consentCheckoutFields(consent)),
71
- });
72
- if (consent) {
73
- const plugin = findProviderPlugin(ctx, payload.providerName)?.plugin;
74
- await recordConsent(ctx, consent, {
75
- tier: mode === "subscription" ? (plugin?.priceToTier?.[payload.priceId] ?? null) : null,
76
- priceId: payload.priceId,
77
- price: await lookupPrice(ctx, plugin, payload.priceId),
78
- });
68
+ };
69
+ if (!consumerProtection) {
70
+ const result = await openCheckout(ctx, openOptions, checkoutInput);
71
+ return {
72
+ isSuccess: true,
73
+ data: { url: result.url, providerName: result.providerName },
74
+ };
79
75
  }
76
+ const mode = payload.mode ?? "subscription";
77
+ const plugin = await assertCheckoutAllowed(ctx, openOptions, checkoutInput);
78
+ const price = await lookupPrice(ctx, plugin, payload.priceId);
79
+ const orderItem = mode === "payment"
80
+ ? await consumerProtection.oneOffItemLabel?.(ctx, payload.priceId)
81
+ : undefined;
82
+ const consent = await prepareConsent(ctx, consumerProtection, payload.consent, mode);
83
+ const result = await startProviderCheckout(ctx, plugin, {
84
+ ...checkoutInput,
85
+ ...consentCheckoutFields(consent),
86
+ });
87
+ await recordConsent(ctx, consent, {
88
+ tier: mode === "subscription" ? (plugin.priceToTier?.[payload.priceId] ?? null) : null,
89
+ priceId: payload.priceId,
90
+ price,
91
+ ...(orderItem && { orderItem }),
92
+ });
80
93
  return {
81
94
  isSuccess: true,
82
95
  data: { url: result.url, providerName: result.providerName },
@@ -84,16 +97,25 @@ export function createCheckoutSessionHandler(options) {
84
97
  },
85
98
  };
86
99
  }
87
- // A failed price lookup must not lose a consent the buyer already gave at the
88
- // provider, so it degrades to null price fields.
100
+ // The price is part of the consent record, so it must be known before the
101
+ // buyer is sent to the provider.
89
102
  async function lookupPrice(ctx, plugin, priceId) {
90
- if (!plugin?.retrievePrices)
91
- return null;
103
+ let price;
92
104
  try {
93
- const prices = await plugin.retrievePrices(ctx, [priceId]);
94
- return prices.find((p) => p.priceId === priceId) ?? null;
105
+ const prices = (await plugin.retrievePrices?.(ctx, [priceId])) ?? [];
106
+ price = prices.find((p) => p.priceId === priceId);
95
107
  }
96
- catch {
97
- return null;
108
+ catch (cause) {
109
+ throw priceUnavailable(priceId, cause);
98
110
  }
111
+ if (!price)
112
+ throw priceUnavailable(priceId);
113
+ return price;
114
+ }
115
+ function priceUnavailable(priceId, cause) {
116
+ return new UnprocessableError("price_unavailable", {
117
+ i18nKey: "billing-foundation.errors.priceUnavailable",
118
+ message: `billing-foundation: no live price resolved for priceId "${priceId}"`,
119
+ ...(cause instanceof Error && { cause }),
120
+ });
99
121
  }
@@ -2,6 +2,8 @@ export { paymentAggregateId, subscriptionAggregateId } from "./aggregate-id.js";
2
2
  export { type BillingInfo, type BillingInfoQueryDeps, createBillingInfoQueryConfig, } from "./billing-info-query.js";
3
3
  export { isBillingEnabled, type ResolvedProvider, resolveProviderPlugin, } from "./checkout-core.js";
4
4
  export { BILLING_FOUNDATION_FEATURE, BILLING_PLANS_PANEL_COMPONENT, BILLING_PLANS_SCREEN_ID, type BillingEventKind, BillingEventKinds, type BillingPlanAction, BillingPlanActions, DEFAULT_PURCHASE_ROLES, isSwitchableSubscriptionStatus, isTerminalSubscriptionStatus, parseProviderConsentId, SUBSCRIPTION_PROVIDER_EXTENSION, type SubscriptionCancelTiming, SubscriptionCancelTimings, type SubscriptionEventType, SubscriptionEventTypes, SubscriptionFoundationHandlers, SubscriptionFoundationQueries, type SubscriptionStatus, SubscriptionStatuses, } from "./constants.js";
5
+ export { type ConsentPayload, consentPayloadSchema, type OrderItem, } from "./consumer-protection/consent-gate.js";
6
+ export { CONSENT_LOCALES, type ConsentLocale, resolveConsentLocale, } from "./consumer-protection/consent-locale.js";
5
7
  export { type ContractTerminationRoutesOptions, createContractTerminationRoutes, } from "./consumer-protection/termination-pages.js";
6
8
  export { paymentEntity, subscriptionEntity } from "./entities.js";
7
9
  export { INVOICE_PAID_EVENT_QN, INVOICE_PAID_EVENT_SHORT, INVOICE_PAYMENT_FAILED_EVENT_QN, INVOICE_PAYMENT_FAILED_EVENT_SHORT, PAYMENT_AGGREGATE_TYPE, PAYMENT_RECEIVED_EVENT_QN, PAYMENT_RECEIVED_EVENT_SHORT, type PaymentEventHeaders, type PaymentEventPayload, paymentEventPayloadSchema, SUBSCRIPTION_AGGREGATE_TYPE, SUBSCRIPTION_CANCELED_EVENT_QN, SUBSCRIPTION_CANCELED_EVENT_SHORT, SUBSCRIPTION_CREATED_EVENT_QN, SUBSCRIPTION_CREATED_EVENT_SHORT, SUBSCRIPTION_UPDATED_EVENT_QN, SUBSCRIPTION_UPDATED_EVENT_SHORT, type SubscriptionEventHeaders, type SubscriptionEventPayload, subscriptionEventPayloadSchema, } from "./events.js";
@@ -10,5 +12,5 @@ export { getSubscriptionForTenant, type SubscriptionView } from "./get-subscript
10
12
  export { paymentsProjectionTable, subscriptionsProjectionTable } from "./projection.js";
11
13
  export { billingPlansPanel, createBillingPlansScreen } from "./screens.js";
12
14
  export { createSubscriptionTierSync, effectiveTierFromSubscription, SUBSCRIPTION_WEBHOOK_PATH, type SubscriptionTierSyncDeps, } from "./subscription-tier-sync.js";
13
- export { type BillingFoundationOptions, type BillingPlanBenefit, type BillingPlanCatalog, type BillingPlanPrice, type BillingPlansResult, type BillingPlanView, type ConsumerProtectionOptions, KNOWN_RECURRING_INTERVALS, type PaymentEvent, type ProviderPrice, type ProviderSubscriptionSnapshot, type RecurringInterval, type SubscriptionEvent, type SubscriptionProviderPlugin, } from "./types.js";
15
+ export { type BillingFoundationOptions, type BillingPlanBenefit, type BillingPlanCatalog, type BillingPlanPrice, type BillingPlansResult, type BillingPlanView, type ConsumerProtectionOptions, KNOWN_RECURRING_INTERVALS, type LegalLinkSet, type PaymentEvent, type ProviderPrice, type ProviderSubscriptionSnapshot, type RecurringInterval, type SubscriptionEvent, type SubscriptionProviderPlugin, } from "./types.js";
14
16
  export { createSubscriptionWebhookRoute, type SubscriptionWebhookRouteOptions, } from "./webhook-handler.js";
@@ -3,6 +3,8 @@ export { paymentAggregateId, subscriptionAggregateId } from "./aggregate-id.js";
3
3
  export { createBillingInfoQueryConfig, } from "./billing-info-query.js";
4
4
  export { isBillingEnabled, resolveProviderPlugin, } from "./checkout-core.js";
5
5
  export { BILLING_FOUNDATION_FEATURE, BILLING_PLANS_PANEL_COMPONENT, BILLING_PLANS_SCREEN_ID, BillingEventKinds, BillingPlanActions, DEFAULT_PURCHASE_ROLES, isSwitchableSubscriptionStatus, isTerminalSubscriptionStatus, parseProviderConsentId, SUBSCRIPTION_PROVIDER_EXTENSION, SubscriptionCancelTimings, SubscriptionEventTypes, SubscriptionFoundationHandlers, SubscriptionFoundationQueries, SubscriptionStatuses, } from "./constants.js";
6
+ export { consentPayloadSchema, } from "./consumer-protection/consent-gate.js";
7
+ export { CONSENT_LOCALES, resolveConsentLocale, } from "./consumer-protection/consent-locale.js";
6
8
  export { createContractTerminationRoutes, } from "./consumer-protection/termination-pages.js";
7
9
  export { paymentEntity, subscriptionEntity } from "./entities.js";
8
10
  export { INVOICE_PAID_EVENT_QN, INVOICE_PAID_EVENT_SHORT, INVOICE_PAYMENT_FAILED_EVENT_QN, INVOICE_PAYMENT_FAILED_EVENT_SHORT, PAYMENT_AGGREGATE_TYPE, PAYMENT_RECEIVED_EVENT_QN, PAYMENT_RECEIVED_EVENT_SHORT, paymentEventPayloadSchema, SUBSCRIPTION_AGGREGATE_TYPE, SUBSCRIPTION_CANCELED_EVENT_QN, SUBSCRIPTION_CANCELED_EVENT_SHORT, SUBSCRIPTION_CREATED_EVENT_QN, SUBSCRIPTION_CREATED_EVENT_SHORT, SUBSCRIPTION_UPDATED_EVENT_QN, SUBSCRIPTION_UPDATED_EVENT_SHORT, subscriptionEventPayloadSchema, } from "./events.js";
@@ -3,6 +3,8 @@ 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
5
  import { type BillingEventKinds, type BillingPlanAction, SUBSCRIPTION_PROVIDER_EXTENSION, type SubscriptionCancelTiming, type SubscriptionEventType, type SubscriptionStatus, type TerminationScope } from "./constants.js";
6
+ import type { OrderItem } from "./consumer-protection/consent-gate.js";
7
+ import type { ConsentLocale } from "./consumer-protection/consent-locale.js";
6
8
  export type SubscriptionEvent = {
7
9
  /** Discriminator against PaymentEvent. Optional (instead of required) so
8
10
  * existing plugins like subscription-mollie, which don't set this field,
@@ -279,6 +281,12 @@ export type BillingPlanCatalog<TTier extends string = string> = {
279
281
  /** Defaults to the only registered provider exposing `priceToTier`. */
280
282
  readonly providerName?: string;
281
283
  };
284
+ export type LegalLinkSet = {
285
+ readonly terms: string;
286
+ readonly withdrawal: string;
287
+ readonly privacy: string;
288
+ };
289
+ export declare function isLegalLinkSet(links: LegalLinkSet | Readonly<Record<ConsentLocale, LegalLinkSet>>): links is LegalLinkSet;
282
290
  /** Consumer-protection consent gate (§ 312j / § 356 BGB style). When set,
283
291
  * `start-plan-checkout` and `create-checkout-session` require a `consent`
284
292
  * payload and record it as an event before the buyer reaches the provider. */
@@ -294,12 +302,13 @@ export type ConsumerProtectionOptions = {
294
302
  * host that resolves none (the platform apex) — the declaration then runs
295
303
  * without a request tenant and finds the contract by the declarant's email. */
296
304
  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
- };
305
+ /** Names the item of a one-off payment (mode "payment") for the consent record
306
+ * and the confirmation mail; the label key is resolved through the registry
307
+ * i18n, so it must be qualified with the registering feature. */
308
+ readonly oneOffItemLabel?: (ctx: HandlerContext, priceId: string) => Promise<OrderItem | undefined>;
309
+ /** Root-relative paths or absolute https URLs. One set for every locale, or
310
+ * one set per consent locale (`de` and `en` required). */
311
+ readonly legalLinks: LegalLinkSet | Readonly<Record<ConsentLocale, LegalLinkSet>>;
303
312
  };
304
313
  export type BillingFoundationOptions<TTier extends string = string> = {
305
314
  /** Absolute http(s) URL. Its origin is the only redirect origin
@@ -361,7 +370,7 @@ export type BillingPlansResult = {
361
370
  readonly withdrawalLoss: string;
362
371
  readonly consentTextVersion: string;
363
372
  }>>;
364
- readonly legalLinks: ConsumerProtectionOptions["legalLinks"];
373
+ readonly legalLinks: Readonly<Record<ConsentLocale, LegalLinkSet>>;
365
374
  };
366
375
  };
367
376
  declare module "@cosmicdrift/kumiko-framework/engine" {
@@ -30,3 +30,6 @@ import { SUBSCRIPTION_PROVIDER_EXTENSION, } from "./constants.js";
30
30
  // =============================================================================
31
31
  // Closed set a provider's recurring-price interval narrows to; an unrecognized provider value maps to null instead of widening this type.
32
32
  export const KNOWN_RECURRING_INTERVALS = ["day", "week", "month", "year"];
33
+ export function isLegalLinkSet(links) {
34
+ return "terms" in links;
35
+ }
@@ -1,4 +1,4 @@
1
- import type { BillingFoundationOptions } from "./types.js";
1
+ import { type BillingFoundationOptions } from "./types.js";
2
2
  /** Validates `options` and throws a plain `Error` with a clear message at
3
3
  * feature-definition time — same pattern as `createCapOverviewFeature`.
4
4
  * `createBillingFoundationFeature()` (no options) always succeeds; the
@@ -4,6 +4,8 @@
4
4
  // re-exported from index.ts.
5
5
  import * as z from "zod";
6
6
  import { isTerminationScope, TERMINATION_SCOPES } from "./constants.js";
7
+ import { CONSENT_LOCALES } from "./consumer-protection/consent-locale.js";
8
+ import { isLegalLinkSet, } from "./types.js";
7
9
  function isRootRelativePath(path) {
8
10
  return path.startsWith("/") && !path.startsWith("//");
9
11
  }
@@ -29,12 +31,32 @@ function validateConsumerProtection(cp) {
29
31
  if (!z.email().safeParse(cp.operatorEmail).success) {
30
32
  throw new Error(`createBillingFoundationFeature: consumerProtection.operatorEmail "${cp.operatorEmail}" is not a valid email address.`);
31
33
  }
34
+ if (cp.oneOffItemLabel !== undefined && typeof cp.oneOffItemLabel !== "function") {
35
+ throw new Error("createBillingFoundationFeature: consumerProtection.oneOffItemLabel must be a function.");
36
+ }
32
37
  if (cp.terminationScope !== undefined && !isTerminationScope(cp.terminationScope)) {
33
38
  throw new Error(`createBillingFoundationFeature: consumerProtection.terminationScope "${String(cp.terminationScope)}" must be one of ${TERMINATION_SCOPES.map((scope) => `"${scope}"`).join(", ")}.`);
34
39
  }
35
- for (const [key, link] of Object.entries(cp.legalLinks)) {
40
+ if (isLegalLinkSet(cp.legalLinks)) {
41
+ validateLegalLinkSet(cp.legalLinks, "legalLinks");
42
+ }
43
+ else {
44
+ validatePerLocaleLegalLinks(cp.legalLinks);
45
+ }
46
+ }
47
+ function validatePerLocaleLegalLinks(legalLinks) {
48
+ for (const locale of CONSENT_LOCALES) {
49
+ const set = legalLinks[locale];
50
+ if (!set) {
51
+ throw new Error(`createBillingFoundationFeature: consumerProtection.legalLinks needs a "${locale}" entry when given per locale.`);
52
+ }
53
+ validateLegalLinkSet(set, `legalLinks.${locale}`);
54
+ }
55
+ }
56
+ function validateLegalLinkSet(set, path) {
57
+ for (const [key, link] of Object.entries(set)) {
36
58
  if (!isRootRelativeOrHttpsUrl(link)) {
37
- throw new Error(`createBillingFoundationFeature: consumerProtection.legalLinks.${key} "${link}" must be a root-relative path ("/..." not "//") or an absolute https URL.`);
59
+ throw new Error(`createBillingFoundationFeature: consumerProtection.${path}.${key} "${link}" must be a root-relative path ("/..." not "//") or an absolute https URL.`);
38
60
  }
39
61
  }
40
62
  }
@@ -189,7 +189,7 @@ export function BillingPlansPanel(_props) {
189
189
  date: toInstant(result.subscription.cancelAt).toLocaleString(locale, {
190
190
  dateStyle: "medium",
191
191
  }),
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 &&
192
+ }), " ", t("billing-foundation.plans.switchRequiresReactivation")] })), consumerProtection !== undefined && canManage && result.subscription?.cancelAt == null && (_jsx("div", { children: _jsx(Button, { variant: "secondary", onClick: () => setCancelOpen(true), testId: "billing-plans-cancel-contract", children: t("billing-foundation.cancel.open") }) })), _jsxs(PlanGrid, { testId: "billing-plans-grid", children: [!currentTierInPlans && (_jsx(PlanCard, { title: t(result.currentTier.labelKey), current: true, testId: "billing-plan-card-current", features: result.currentTier.benefits.map((benefit) => t(benefit.labelKey, localizedParams(benefit.params, locale))), secondaryAction: manageOrReactivateAction(canManage, result.subscription, t, handleManage, redirecting) })), visiblePlans.map((plan) => (_jsx(PlanCard, { testId: `billing-plan-card-${plan.tier}`, ...toCardProps(plan) }, plan.tier)))] }), consumerProtection !== undefined &&
193
193
  consentPlan !== undefined &&
194
194
  consentPlan.price !== null && (_jsx(CheckoutConsentDialog, { open: true, onOpenChange: (open) => {
195
195
  if (!open)
@@ -14,7 +14,7 @@ const TERMINATION_KINDS = ["ordinary", "extraordinary"];
14
14
  export function CancelContractDialog({ open, onOpenChange, onTerminated, }) {
15
15
  const t = useTranslation();
16
16
  const locale = useLocale().locale();
17
- const { Modal, Heading, Text, Field, Input, Button, Banner } = usePrimitives();
17
+ const { Modal, Text, Field, Input, Button, Banner } = usePrimitives();
18
18
  const terminate = useMutation(SubscriptionFoundationHandlers.terminateContract);
19
19
  const [step, setStep] = useState("form");
20
20
  const [declarationType, setDeclarationType] = useState("termination");
@@ -38,7 +38,7 @@ export function CancelContractDialog({ open, onOpenChange, onTerminated, }) {
38
38
  function formatInstant(iso) {
39
39
  return toInstant(iso).toLocaleString(locale, { dateStyle: "medium", timeStyle: "short" });
40
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) => ({
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: [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
42
  value,
43
43
  label: t(`billing-foundation.cancel.declaration.${value}`),
44
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) => ({
@@ -16,7 +16,7 @@ export type CheckoutConsentDialogProps = {
16
16
  readonly price: {
17
17
  readonly amount: string;
18
18
  readonly renewalKey?: string;
19
- readonly count: string;
19
+ readonly count?: string;
20
20
  };
21
21
  readonly consumerProtection: ConsumerProtectionView;
22
22
  readonly onOrder: (consent: CheckoutConsentPayload) => Promise<WriteResult<{
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useLocale, usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
3
3
  import { useState } from "react";
4
4
  import { resolveConsentLocale } from "../consumer-protection/consent-locale.js";
@@ -7,14 +7,14 @@ export const CONSENT_TEXT_OUTDATED_CODE = "consent_text_outdated";
7
7
  export function CheckoutConsentDialog({ open, onOpenChange, planName, price, consumerProtection, onOrder, onConsentTextOutdated, }) {
8
8
  const t = useTranslation();
9
9
  const uiLocale = useLocale().locale();
10
- const { Modal, Heading, Text, Field, Input, Button, Link, Banner } = usePrimitives();
10
+ const { Modal, Text, Field, Input, Button, Link, Banner } = usePrimitives();
11
11
  const [earlyPerformance, setEarlyPerformance] = useState(false);
12
12
  const [withdrawalLoss, setWithdrawalLoss] = useState(false);
13
13
  const [submitting, setSubmitting] = useState(false);
14
14
  const [errorText, setErrorText] = useState(null);
15
15
  const consentLocale = resolveConsentLocale(uiLocale);
16
16
  const texts = consumerProtection.consentTexts[consentLocale];
17
- const { legalLinks } = consumerProtection;
17
+ const legalLinks = consumerProtection.legalLinks[consentLocale];
18
18
  async function order() {
19
19
  setSubmitting(true);
20
20
  setErrorText(null);
@@ -34,5 +34,5 @@ export function CheckoutConsentDialog({ open, onOpenChange, planName, price, con
34
34
  onConsentTextOutdated();
35
35
  }
36
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") })] })] }) }));
37
+ return (_jsx(Modal, { open: open, onOpenChange: onOpenChange, title: t("billing-foundation.consent.title"), testId: "checkout-consent-dialog", children: _jsxs("div", { className: "flex flex-col gap-4", children: [_jsxs("div", { className: "flex flex-col gap-1", "data-testid": "checkout-consent-summary", children: [_jsxs(Text, { children: [planName, " \u2014 ", price.amount] }), price.renewalKey !== undefined && (_jsxs(_Fragment, { children: [_jsx(Text, { variant: "muted", children: t(price.renewalKey, price.count !== undefined ? { count: price.count } : undefined) }), _jsx(Text, { variant: "muted", children: t("billing-foundation.consent.cancelAnytime") })] }))] }), _jsxs("div", { className: "flex flex-wrap gap-4", children: [_jsx(Link, { href: legalLinks.terms, target: "_blank", children: t("billing-foundation.consent.link.terms") }), _jsx(Link, { href: legalLinks.withdrawal, target: "_blank", children: t("billing-foundation.consent.link.withdrawal") }), _jsx(Link, { href: legalLinks.privacy, target: "_blank", children: t("billing-foundation.consent.link.privacy") })] }), _jsx(Field, { id: "consent-early-performance", label: texts.earlyPerformance, layout: "inline", children: _jsx(Input, { kind: "boolean", id: "consent-early-performance", name: "consent-early-performance", value: earlyPerformance, onChange: setEarlyPerformance, disabled: submitting }) }), _jsx(Field, { id: "consent-withdrawal-loss", label: texts.withdrawalLoss, layout: "inline", children: _jsx(Input, { kind: "boolean", id: "consent-withdrawal-loss", name: "consent-withdrawal-loss", value: withdrawalLoss, onChange: setWithdrawalLoss, disabled: submitting }) }), errorText !== null && (_jsx(Banner, { variant: "error", testId: "checkout-consent-error", children: errorText })), _jsxs("div", { className: "flex justify-end gap-2", children: [_jsx(Button, { variant: "secondary", disabled: submitting, onClick: () => onOpenChange(false), testId: "checkout-consent-cancel", children: t("billing-foundation.consent.back") }), _jsx(Button, { disabled: !earlyPerformance || !withdrawalLoss || submitting, loading: submitting, onClick: order, testId: "checkout-consent-order", children: t("billing-foundation.consent.order") })] })] }) }));
38
38
  }
@@ -1,4 +1,6 @@
1
1
  export { BILLING_PLANS_SCREEN_ID, type BillingPlanAction, BillingPlanActions, SubscriptionFoundationHandlers, SubscriptionFoundationQueries, type SubscriptionStatus, SubscriptionStatuses, } from "../constants.js";
2
- export type { BillingPlanBenefit, BillingPlanCatalog, BillingPlansResult, BillingPlanView, } from "../types.js";
2
+ export { CONSENT_LOCALES, type ConsentLocale, resolveConsentLocale, } from "../consumer-protection/consent-locale.js";
3
+ export type { BillingPlanBenefit, BillingPlanCatalog, BillingPlansResult, BillingPlanView, LegalLinkSet, } from "../types.js";
3
4
  export { BillingPlansPanel } from "./billing-plans-panel.js";
5
+ export { CheckoutConsentDialog, type CheckoutConsentDialogProps, type CheckoutConsentPayload, CONSENT_TEXT_OUTDATED_CODE, } from "./checkout-consent-dialog.js";
4
6
  export { type BillingFoundationClientOptions, billingFoundationClient } from "./client-plugin.js";
@@ -4,5 +4,7 @@
4
4
  // the server side (createBillingFoundationFeature) lives under
5
5
  // `@cosmicdrift/kumiko-bundled-features/billing-foundation` and has no React deps.
6
6
  export { BILLING_PLANS_SCREEN_ID, BillingPlanActions, SubscriptionFoundationHandlers, SubscriptionFoundationQueries, SubscriptionStatuses, } from "../constants.js";
7
+ export { CONSENT_LOCALES, resolveConsentLocale, } from "../consumer-protection/consent-locale.js";
7
8
  export { BillingPlansPanel } from "./billing-plans-panel.js";
9
+ export { CheckoutConsentDialog, CONSENT_TEXT_OUTDATED_CODE, } from "./checkout-consent-dialog.js";
8
10
  export { billingFoundationClient } from "./client-plugin.js";
@@ -27,6 +27,7 @@ export function createChannelDiscordFeature(options = {}) {
27
27
  valueSchema: chatWebhookUrlSchema(webhookTarget),
28
28
  });
29
29
  const channel = createDiscordChannel(options, webhooks.keyFor);
30
+ r.translations({ keys: { "delivery.channel.discord": { en: "Discord" } } });
30
31
  r.useExtension(DELIVERY_CHANNEL_EXTENSION, "discord", {
31
32
  mode: channel.mode,
32
33
  send: channel.send,
@@ -11,6 +11,7 @@ export function createChannelEmailFeature(options) {
11
11
  recommended: false,
12
12
  });
13
13
  r.requires("delivery");
14
+ r.translations({ keys: { "delivery.channel.email": { en: "Email" } } });
14
15
  r.useExtension(DELIVERY_CHANNEL_EXTENSION, "email", {
15
16
  mode: channel.mode,
16
17
  resolve: channel.resolve,
@@ -19,6 +19,7 @@ export function createChannelInAppFeature() {
19
19
  reason: "read_side.in_app_messages",
20
20
  });
21
21
  // Register as delivery channel via extension system
22
+ r.translations({ keys: { "delivery.channel.inApp": { en: "In-app" } } });
22
23
  r.useExtension(DELIVERY_CHANNEL_EXTENSION, "inApp", {
23
24
  mode: inAppChannel.mode,
24
25
  resolve: inAppChannel.resolve,
@@ -11,6 +11,7 @@ export function createChannelPushFeature(options) {
11
11
  recommended: false,
12
12
  });
13
13
  r.requires("delivery");
14
+ r.translations({ keys: { "delivery.channel.push": { en: "Push" } } });
14
15
  r.useExtension(DELIVERY_CHANNEL_EXTENSION, "push", {
15
16
  mode: channel.mode,
16
17
  resolve: channel.resolve,
@@ -24,6 +24,7 @@ export function createChannelSlackFeature(options = {}) {
24
24
  valueSchema: chatWebhookUrlSchema(webhookTarget),
25
25
  });
26
26
  const channel = createSlackChannel(options, webhooks.keyFor);
27
+ r.translations({ keys: { "delivery.channel.slack": { en: "Slack" } } });
27
28
  r.useExtension(DELIVERY_CHANNEL_EXTENSION, "slack", {
28
29
  mode: channel.mode,
29
30
  send: channel.send,
@@ -28,6 +28,7 @@ export function createChannelTeamsFeature(options = {}) {
28
28
  valueSchema: chatWebhookUrlSchema(webhookTarget),
29
29
  });
30
30
  const channel = createTeamsChannel(options, webhooks.keyFor);
31
+ r.translations({ keys: { "delivery.channel.teams": { en: "Microsoft Teams" } } });
31
32
  r.useExtension(DELIVERY_CHANNEL_EXTENSION, "teams", {
32
33
  mode: channel.mode,
33
34
  send: channel.send,
@@ -1,4 +1,4 @@
1
- import { type ChatWebhookChannelOptions, type DeliveryChannel } from "../delivery/index.js";
1
+ import { type ChatSendResult, type ChatWebhookChannelOptions, type ChatWebhookResponse, type DeliveryChannel } from "../delivery/index.js";
2
2
  export type TeamsChannelOptions = ChatWebhookChannelOptions;
3
3
  export declare const TEAMS_SECRET_KEYS: {
4
4
  readonly webhookPrefix: "channel-teams:webhooks.";
@@ -6,4 +6,5 @@ export declare const TEAMS_SECRET_KEYS: {
6
6
  };
7
7
  export declare const TEAMS_DEFAULT_ALLOWED_HOSTS: readonly [".webhook.office.com", ".logic.azure.com", ".powerplatform.com"];
8
8
  export declare function buildTeamsAdaptiveCard(title: string, body: string | undefined): unknown;
9
+ export declare function classifyTeamsResponse(response: ChatWebhookResponse): Promise<ChatSendResult>;
9
10
  export declare function createTeamsChannel(options: TeamsChannelOptions, keyFor: (connection: string) => string): DeliveryChannel;
@@ -30,12 +30,24 @@ export function buildTeamsAdaptiveCard(title, body) {
30
30
  ],
31
31
  };
32
32
  }
33
+ // Classic Office connectors answer 200 with the body "1". Workflow endpoints answer
34
+ // 202 and never say whether the card reached the channel. Any other 2xx (an empty
35
+ // 200 is what a made-up URL returns) is not a Teams success.
36
+ export async function classifyTeamsResponse(response) {
37
+ if (response.status === 202)
38
+ return { ok: true, confirmed: false };
39
+ if (response.status === 200 && (await response.readBodyPrefix()).trim() === "1") {
40
+ return { ok: true, confirmed: true };
41
+ }
42
+ return { ok: false, code: "unexpected_response" };
43
+ }
33
44
  export function createTeamsChannel(options, keyFor) {
34
45
  return createChatWebhookChannel({
35
46
  name: "teams",
36
47
  featureName: "channel-teams",
37
48
  defaultAllowedHosts: TEAMS_DEFAULT_ALLOWED_HOSTS,
38
49
  keyFor,
50
+ classifyResponse: classifyTeamsResponse,
39
51
  buildBody: (message) => buildTeamsAdaptiveCard(message.title, message.body),
40
52
  options,
41
53
  });
@@ -22,6 +22,7 @@ export function createChannelTelegramFeature(options = {}) {
22
22
  valueSchema: z.string().refine(isTelegramBotToken, "invalid bot token"),
23
23
  });
24
24
  const channel = createTelegramChannel(options, botToken.name);
25
+ r.translations({ keys: { "delivery.channel.telegram": { en: "Telegram" } } });
25
26
  r.useExtension(DELIVERY_CHANNEL_EXTENSION, "telegram", {
26
27
  mode: channel.mode,
27
28
  send: channel.send,
@@ -1,4 +1,4 @@
1
- import { type ChatSendResult, type ChatWebhookTarget } from "./chat-webhook-sender.js";
1
+ import { type ChatSendResult, type ChatWebhookRequest, type ChatWebhookTarget } from "./chat-webhook-sender.js";
2
2
  import type { ChannelMessage, ChannelResult, DeliveryChannel } from "./types.js";
3
3
  export type ChatWebhookChannelOptions = {
4
4
  readonly allowedHosts?: readonly string[];
@@ -18,6 +18,7 @@ export type ChatWebhookChannelSpec = {
18
18
  readonly requiredPathPrefix?: string;
19
19
  readonly keyFor: (connection: string) => string;
20
20
  readonly buildBody: (message: ChannelMessage) => unknown;
21
+ readonly classifyResponse?: ChatWebhookRequest["classifyResponse"];
21
22
  readonly options: ChatWebhookChannelOptions;
22
23
  };
23
24
  export declare function createChatWebhookChannel(spec: ChatWebhookChannelSpec): DeliveryChannel;
@@ -1,9 +1,11 @@
1
1
  import { SYSTEM_USER_ID } from "@cosmicdrift/kumiko-types/identifiers";
2
2
  import { chatConnectionNameSchema, DEFAULT_CHAT_TIMEOUT_MS, postChatWebhook, } from "./chat-webhook-sender.js";
3
3
  export function toChannelResult(address, result) {
4
- return result.ok
4
+ if (!result.ok)
5
+ return { status: "failed", error: result.code, address };
6
+ return result.confirmed
5
7
  ? { status: "sent", address }
6
- : { status: "failed", error: result.code, address };
8
+ : { status: "sent", confirmed: false, address };
7
9
  }
8
10
  export function chatMessageText(message) {
9
11
  return message.body ? `${message.title}\n${message.body}` : message.title;
@@ -46,6 +48,7 @@ export function createChatWebhookChannel(spec) {
46
48
  ...resolveChatWebhookTarget(spec, options),
47
49
  timeoutMs: options.timeoutMs ?? DEFAULT_CHAT_TIMEOUT_MS,
48
50
  body: spec.buildBody(message),
51
+ ...(spec.classifyResponse && { classifyResponse: spec.classifyResponse }),
49
52
  });
50
53
  return toChannelResult(address, result);
51
54
  },