@cosmicdrift/kumiko-samples 0.387.0 → 0.388.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +8 -1
  3. package/packages/bundled-features/src/agent-tools/changes.json +6 -0
  4. package/packages/bundled-features/src/agent-tools/tool-dispatch.ts +9 -9
  5. package/packages/bundled-features/src/audit/changes.json +12 -0
  6. package/packages/bundled-features/src/audit/handlers/audit-events.ts +1 -1
  7. package/packages/bundled-features/src/auth-mfa/changes.json +6 -0
  8. package/packages/bundled-features/src/auth-mfa/feature-exports.ts +2 -2
  9. package/packages/bundled-features/src/auth-mfa/feature.ts +6 -0
  10. package/packages/bundled-features/src/auth-mfa/web/i18n.ts +6 -1
  11. package/packages/bundled-features/src/auth-mfa/web/mfa-code-or-recovery-field.tsx +68 -0
  12. package/packages/bundled-features/src/auth-mfa/web/mfa-disable-dialog.tsx +23 -13
  13. package/packages/bundled-features/src/auth-mfa/web/mfa-regenerate-recovery-dialog.tsx +23 -13
  14. package/packages/bundled-features/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +15 -6
  15. package/packages/bundled-features/src/auth-mfa/web/mfa-verify-screen.tsx +65 -18
  16. package/packages/bundled-features/src/billing-foundation/changes.json +55 -0
  17. package/packages/bundled-features/src/billing-foundation/checkout-core.ts +32 -42
  18. package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +8 -2
  19. package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts +2 -0
  20. package/packages/bundled-features/src/billing-foundation/consumer-protection/issue-confirmation.ts +14 -3
  21. package/packages/bundled-features/src/billing-foundation/consumer-protection/mail-notifications.ts +3 -4
  22. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-receipt-limit.ts +12 -6
  23. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +35 -19
  24. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +12 -8
  25. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-revoke.ts +14 -8
  26. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +5 -0
  27. package/packages/bundled-features/src/billing-foundation/consumer-protection/withdrawal-period.ts +8 -10
  28. package/packages/bundled-features/src/billing-foundation/events.ts +1 -0
  29. package/packages/bundled-features/src/billing-foundation/feature.ts +3 -1
  30. package/packages/bundled-features/src/billing-foundation/handlers/billing-plans.query.ts +71 -6
  31. package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +1 -0
  32. package/packages/bundled-features/src/billing-foundation/handlers/plan-switch-preview.query.ts +65 -0
  33. package/packages/bundled-features/src/billing-foundation/handlers/process-event.write.ts +9 -2
  34. package/packages/bundled-features/src/billing-foundation/handlers/start-plan-checkout.write.ts +44 -8
  35. package/packages/bundled-features/src/billing-foundation/handlers/switch-plan.write.ts +72 -82
  36. package/packages/bundled-features/src/billing-foundation/handlers/sync-subscription.write.ts +14 -1
  37. package/packages/bundled-features/src/billing-foundation/i18n.ts +22 -0
  38. package/packages/bundled-features/src/billing-foundation/index.ts +2 -0
  39. package/packages/bundled-features/src/billing-foundation/plan-catalog.ts +62 -27
  40. package/packages/bundled-features/src/billing-foundation/plan-switch.ts +84 -0
  41. package/packages/bundled-features/src/billing-foundation/types.ts +80 -1
  42. package/packages/bundled-features/src/billing-foundation/validate-options.ts +19 -0
  43. package/packages/bundled-features/src/billing-foundation/web/billing-plans-panel.tsx +119 -47
  44. package/packages/bundled-features/src/billing-foundation/web/checkout-consent-dialog.tsx +89 -42
  45. package/packages/bundled-features/src/billing-foundation/web/constants.ts +4 -0
  46. package/packages/bundled-features/src/billing-foundation/web/plan-price.ts +55 -0
  47. package/packages/bundled-features/src/billing-foundation/web/plan-switch-confirm-dialog.tsx +224 -0
  48. package/packages/bundled-features/src/cap-counter/cap-reservation.ts +5 -1
  49. package/packages/bundled-features/src/cap-counter/changes.json +18 -0
  50. package/packages/bundled-features/src/cap-counter/enforce-cap.ts +2 -1
  51. package/packages/bundled-features/src/cap-counter/stock-cap-guard.ts +8 -1
  52. package/packages/bundled-features/src/channel-email/changes.json +6 -0
  53. package/packages/bundled-features/src/channel-email/feature.ts +1 -1
  54. package/packages/bundled-features/src/config/changes.json +12 -0
  55. package/packages/bundled-features/src/config/handlers/cascade.query.ts +4 -2
  56. package/packages/bundled-features/src/config/handlers/values.query.ts +4 -2
  57. package/packages/bundled-features/src/config/write-helpers.ts +27 -8
  58. package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
  59. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +10 -9
  60. package/packages/bundled-features/src/delivery/changes.json +30 -0
  61. package/packages/bundled-features/src/delivery/channel-context.ts +9 -3
  62. package/packages/bundled-features/src/delivery/db/queries/preferences.ts +2 -2
  63. package/packages/bundled-features/src/delivery/delivery-service.ts +35 -5
  64. package/packages/bundled-features/src/delivery/handlers/preferences.query.ts +20 -1
  65. package/packages/bundled-features/src/delivery/handlers/unsubscribe-page-chrome.query.ts +1 -1
  66. package/packages/bundled-features/src/delivery/unsubscribe-link.ts +3 -1
  67. package/packages/bundled-features/src/delivery/unsubscribe-page-strings.ts +1 -0
  68. package/packages/bundled-features/src/delivery/unsubscribe-page.ts +6 -0
  69. package/packages/bundled-features/src/delivery/unsubscribe.ts +36 -15
  70. package/packages/bundled-features/src/file-foundation/index.ts +1 -0
  71. package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +7 -1
  72. package/packages/bundled-features/src/file-provider-s3/env-named-feature.ts +0 -2
  73. package/packages/bundled-features/src/files/README.md +1 -1
  74. package/packages/bundled-features/src/form-draft/changes.json +6 -0
  75. package/packages/bundled-features/src/form-draft/db/queries/cleanup.ts +2 -2
  76. package/packages/bundled-features/src/form-draft/decrypt-draft.ts +4 -1
  77. package/packages/bundled-features/src/host-routing/README.md +1 -1
  78. package/packages/bundled-features/src/host-routing/changes.json +6 -0
  79. package/packages/bundled-features/src/host-routing/dispatch.ts +16 -17
  80. package/packages/bundled-features/src/host-routing/options.ts +2 -1
  81. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +18 -0
  82. package/packages/bundled-features/src/inbound-mail-foundation/handlers/update-account.write.ts +17 -5
  83. package/packages/bundled-features/src/inbound-mail-foundation/i18n.ts +0 -1
  84. package/packages/bundled-features/src/inbound-mail-foundation/screens.ts +0 -3
  85. package/packages/bundled-features/src/inbound-mail-foundation/types.ts +4 -3
  86. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +32 -16
  87. package/packages/bundled-features/src/jobs/changes.json +13 -0
  88. package/packages/bundled-features/src/jobs/feature.ts +14 -4
  89. package/packages/bundled-features/src/jobs/handlers/tenant-failures.query.ts +45 -40
  90. package/packages/bundled-features/src/jobs/handlers/tenant-runs.query.ts +63 -59
  91. package/packages/bundled-features/src/jobs/job-run-logger.ts +5 -4
  92. package/packages/bundled-features/src/legal-pages/README.md +1 -1
  93. package/packages/bundled-features/src/mail-foundation/changes.json +6 -0
  94. package/packages/bundled-features/src/mail-foundation/feature.ts +5 -3
  95. package/packages/bundled-features/src/personal-access-tokens/feature.ts +4 -1
  96. package/packages/bundled-features/src/renderer-pdf/changes.json +6 -0
  97. package/packages/bundled-features/src/renderer-pdf/render-table-pdf.ts +27 -10
  98. package/packages/bundled-features/src/sessions/feature.ts +7 -2
  99. package/packages/bundled-features/src/shared/decrypt-stored-pii.ts +10 -0
  100. package/packages/bundled-features/src/shared/index.ts +1 -1
  101. package/packages/bundled-features/src/shared/mail-template-body.ts +10 -3
  102. package/packages/bundled-features/src/step-dispatcher/changes.json +6 -0
  103. package/packages/bundled-features/src/step-dispatcher/feature.ts +1 -0
  104. package/packages/bundled-features/src/subscription-dodo/changes.json +14 -0
  105. package/packages/bundled-features/src/subscription-dodo/client.ts +45 -0
  106. package/packages/bundled-features/src/subscription-dodo/constants.ts +24 -0
  107. package/packages/bundled-features/src/subscription-dodo/feature.ts +207 -0
  108. package/packages/bundled-features/src/subscription-dodo/index.ts +16 -0
  109. package/packages/bundled-features/src/subscription-dodo/plugin-methods.ts +345 -0
  110. package/packages/bundled-features/src/subscription-dodo/runtime.ts +126 -0
  111. package/packages/bundled-features/src/subscription-dodo/verify-webhook.ts +262 -0
  112. package/packages/bundled-features/src/subscription-stripe/changes.json +12 -0
  113. package/packages/bundled-features/src/subscription-stripe/feature.ts +1 -0
  114. package/packages/bundled-features/src/subscription-stripe/runtime.ts +7 -6
  115. package/packages/bundled-features/src/template-resolver/api.ts +31 -4
  116. package/packages/bundled-features/src/template-resolver/changes.json +48 -0
  117. package/packages/bundled-features/src/template-resolver/compile-document-body.ts +1 -0
  118. package/packages/bundled-features/src/template-resolver/feature.ts +2 -2
  119. package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +3 -2
  120. package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +27 -5
  121. package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +3 -2
  122. package/packages/bundled-features/src/template-resolver/handlers/document-base.ts +3 -2
  123. package/packages/bundled-features/src/template-resolver/handlers/find-by-id.query.ts +3 -2
  124. package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +18 -19
  125. package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +15 -5
  126. package/packages/bundled-features/src/template-resolver/handlers/tenant-override-counts.query.ts +9 -5
  127. package/packages/bundled-features/src/template-resolver/handlers/tenant-override-selection.ts +8 -6
  128. package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +44 -5
  129. package/packages/bundled-features/src/template-resolver/handlers/usage.query.ts +37 -26
  130. package/packages/bundled-features/src/template-resolver/handlers/validate.query.ts +7 -2
  131. package/packages/bundled-features/src/template-resolver/seeding.ts +32 -14
  132. package/packages/bundled-features/src/template-resolver/store-template-resource.ts +22 -4
  133. package/packages/bundled-features/src/template-resolver/template-delivery-policy.ts +0 -11
  134. package/packages/bundled-features/src/template-resolver/template-meta.ts +12 -4
  135. package/packages/bundled-features/src/template-resolver/template-preview.ts +11 -3
  136. package/packages/bundled-features/src/template-resolver/web/constants.ts +7 -5
  137. package/packages/bundled-features/src/template-resolver/web/index.ts +7 -0
  138. package/packages/bundled-features/src/template-resolver/web/mail-layout-constants.ts +8 -0
  139. package/packages/bundled-features/src/tenant/changes.json +6 -0
  140. package/packages/bundled-features/src/tenant/handlers/member-directory.query.ts +9 -3
  141. package/packages/bundled-features/src/tenant/handlers/tenant-directory.query.ts +7 -4
  142. package/packages/bundled-features/src/tenant-lifecycle/changes.json +6 -0
  143. package/packages/bundled-features/src/tenant-lifecycle/constants.ts +5 -0
  144. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +5 -1
  145. package/packages/bundled-features/src/tier-engine/changes.json +6 -0
  146. package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +4 -1
  147. package/packages/bundled-features/src/tier-engine/web/constants.ts +3 -3
  148. package/packages/bundled-features/src/user-data-rights/run-export-jobs.ts +3 -4
  149. package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +2 -1
  150. package/packages/bundled-features/src/waitlist/changes.json +18 -0
  151. package/packages/bundled-features/src/waitlist/handlers/export-purge.write.ts +2 -1
  152. package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +5 -5
  153. package/packages/bundled-features/src/waitlist/import.ts +5 -0
  154. package/packages/bundled-features/src/waitlist/legacy-migrate.ts +22 -3
  155. package/packages/bundled-features/src/waitlist/link-on-signup.ts +20 -10
  156. package/packages/bundled-features/src/waitlist/options.ts +3 -0
  157. package/samples/apps/styleguide/src/features/widgets/i18n.ts +11 -0
  158. package/samples/apps/styleguide/src/features/widgets/web/Widgets.tsx +24 -0
  159. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-constants.ts +5 -0
  160. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +90 -15
  161. package/samples/apps/use-all-bundled/e2e-billing-gallery/server.ts +16 -6
  162. package/samples/apps/use-all-bundled/feature-manifest.json +120 -3
  163. package/samples/apps/use-all-bundled/src/run-config.ts +9 -3
  164. package/samples/recipes/auth-mfa/README.md +5 -0
  165. package/samples/recipes/auth-mfa/package.json +5 -2
  166. package/samples/recipes/auth-mfa/src/web/confirmation-code-step.tsx +49 -0
  167. package/samples/recipes/auth-mfa/tsconfig.json +8 -0
  168. package/samples/recipes/env-composition/README.md +3 -1
  169. package/samples/recipes/env-composition/src/env.ts +2 -0
  170. package/samples/recipes/legal-pages/README.md +1 -1
  171. package/samples/recipes/rate-limiting/README.md +18 -0
  172. package/samples/recipes/rate-limiting/src/feature.ts +12 -0
  173. package/packages/bundled-features/src/billing-foundation/consumer-protection/stored-user-email.ts +0 -12
@@ -1,5 +1,6 @@
1
1
  import { useLocale, usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
2
2
  import type { WriteResult } from "@cosmicdrift/kumiko-renderer/headless";
3
+ import { ChoiceCards } from "@cosmicdrift/kumiko-renderer/web";
3
4
  import { type ReactNode, useState } from "react";
4
5
  import type { BillingPlansResult } from "../types.ts";
5
6
  import { resolveConsentLocale } from "./consumer-protection/consent-locale.ts";
@@ -24,8 +25,24 @@ export type CheckoutConsentDialogProps = {
24
25
  readonly renewalKey?: string;
25
26
  readonly count?: string;
26
27
  };
27
- readonly consumerProtection: ConsumerProtectionView;
28
- readonly onOrder: (consent: CheckoutConsentPayload) => Promise<WriteResult<{ url: string }>>;
28
+ /** Without it the dialog shows no legal links and no consent checkboxes and orders without a consent. */
29
+ readonly consumerProtection?: ConsumerProtectionView;
30
+ /** Several providers to pick from; the parent follows `value` for price and order. */
31
+ readonly providerChoice?: {
32
+ readonly options: readonly {
33
+ readonly value: string;
34
+ readonly title: string;
35
+ readonly description?: string;
36
+ readonly aside?: string;
37
+ }[];
38
+ readonly value: string;
39
+ readonly onChange: (value: string) => void;
40
+ };
41
+ /** Provider note under the price line when there is no provider choice. */
42
+ readonly note?: string;
43
+ readonly onOrder: (
44
+ consent: CheckoutConsentPayload | undefined,
45
+ ) => Promise<WriteResult<{ url: string }>>;
29
46
  readonly onConsentTextOutdated: () => void;
30
47
  };
31
48
 
@@ -36,6 +53,8 @@ export function CheckoutConsentDialog({
36
53
  planName,
37
54
  price,
38
55
  consumerProtection,
56
+ providerChoice,
57
+ note,
39
58
  onOrder,
40
59
  onConsentTextOutdated,
41
60
  }: CheckoutConsentDialogProps): ReactNode {
@@ -48,18 +67,23 @@ export function CheckoutConsentDialog({
48
67
  const [errorText, setErrorText] = useState<string | null>(null);
49
68
 
50
69
  const consentLocale = resolveConsentLocale(uiLocale);
51
- const texts = consumerProtection.consentTexts[consentLocale];
52
- const legalLinks = consumerProtection.legalLinks[consentLocale];
70
+ const texts = consumerProtection?.consentTexts[consentLocale];
71
+ const legalLinks = consumerProtection?.legalLinks[consentLocale];
72
+ const consentGiven = consumerProtection === undefined || (earlyPerformance && withdrawalLoss);
53
73
 
54
74
  async function order(): Promise<void> {
55
75
  setSubmitting(true);
56
76
  setErrorText(null);
57
- const result = await onOrder({
58
- earlyPerformanceRequested: true,
59
- withdrawalLossAcknowledged: true,
60
- consentTextVersion: texts.consentTextVersion,
61
- locale: consentLocale,
62
- });
77
+ const result = await onOrder(
78
+ texts === undefined
79
+ ? undefined
80
+ : {
81
+ earlyPerformanceRequested: true,
82
+ withdrawalLossAcknowledged: true,
83
+ consentTextVersion: texts.consentTextVersion,
84
+ locale: consentLocale,
85
+ },
86
+ );
63
87
  if (result.isSuccess) return;
64
88
  setSubmitting(false);
65
89
  setErrorText(t(result.error.i18nKey, result.error.i18nParams));
@@ -78,6 +102,18 @@ export function CheckoutConsentDialog({
78
102
  testId="checkout-consent-dialog"
79
103
  >
80
104
  <div className="flex flex-col gap-4">
105
+ {providerChoice !== undefined && (
106
+ <div className="flex flex-col gap-2">
107
+ <Text variant="muted">{t("billing-foundation.consent.chooseProvider")}</Text>
108
+ <ChoiceCards
109
+ value={providerChoice.value}
110
+ options={providerChoice.options}
111
+ onChange={providerChoice.onChange}
112
+ ariaLabel={t("billing-foundation.consent.chooseProvider")}
113
+ testId="checkout-provider-choice"
114
+ />
115
+ </div>
116
+ )}
81
117
  <div className="flex flex-col gap-1" data-testid="checkout-consent-summary">
82
118
  <Text>
83
119
  {planName} — {price.amount}
@@ -93,38 +129,49 @@ export function CheckoutConsentDialog({
93
129
  <Text variant="muted">{t("billing-foundation.consent.cancelAnytime")}</Text>
94
130
  </>
95
131
  )}
132
+ {note !== undefined && (
133
+ <Text variant="muted" testId="checkout-consent-note">
134
+ {note}
135
+ </Text>
136
+ )}
96
137
  </div>
97
- <div className="flex flex-wrap gap-4">
98
- <Link href={legalLinks.terms} target="_blank">
99
- {t("billing-foundation.consent.link.terms")}
100
- </Link>
101
- <Link href={legalLinks.withdrawal} target="_blank">
102
- {t("billing-foundation.consent.link.withdrawal")}
103
- </Link>
104
- <Link href={legalLinks.privacy} target="_blank">
105
- {t("billing-foundation.consent.link.privacy")}
106
- </Link>
107
- </div>
108
- <Field id="consent-early-performance" label={texts.earlyPerformance} layout="inline">
109
- <Input
110
- kind="boolean"
111
- id="consent-early-performance"
112
- name="consent-early-performance"
113
- value={earlyPerformance}
114
- onChange={setEarlyPerformance}
115
- disabled={submitting}
116
- />
117
- </Field>
118
- <Field id="consent-withdrawal-loss" label={texts.withdrawalLoss} layout="inline">
119
- <Input
120
- kind="boolean"
121
- id="consent-withdrawal-loss"
122
- name="consent-withdrawal-loss"
123
- value={withdrawalLoss}
124
- onChange={setWithdrawalLoss}
125
- disabled={submitting}
126
- />
127
- </Field>
138
+ {legalLinks !== undefined && (
139
+ <div className="flex flex-wrap gap-4">
140
+ <Link href={legalLinks.terms} target="_blank">
141
+ {t("billing-foundation.consent.link.terms")}
142
+ </Link>
143
+ <Link href={legalLinks.withdrawal} target="_blank">
144
+ {t("billing-foundation.consent.link.withdrawal")}
145
+ </Link>
146
+ <Link href={legalLinks.privacy} target="_blank">
147
+ {t("billing-foundation.consent.link.privacy")}
148
+ </Link>
149
+ </div>
150
+ )}
151
+ {texts !== undefined && (
152
+ <>
153
+ <Field id="consent-early-performance" label={texts.earlyPerformance} layout="inline">
154
+ <Input
155
+ kind="boolean"
156
+ id="consent-early-performance"
157
+ name="consent-early-performance"
158
+ value={earlyPerformance}
159
+ onChange={setEarlyPerformance}
160
+ disabled={submitting}
161
+ />
162
+ </Field>
163
+ <Field id="consent-withdrawal-loss" label={texts.withdrawalLoss} layout="inline">
164
+ <Input
165
+ kind="boolean"
166
+ id="consent-withdrawal-loss"
167
+ name="consent-withdrawal-loss"
168
+ value={withdrawalLoss}
169
+ onChange={setWithdrawalLoss}
170
+ disabled={submitting}
171
+ />
172
+ </Field>
173
+ </>
174
+ )}
128
175
  {errorText !== null && (
129
176
  <Banner variant="error" testId="checkout-consent-error">
130
177
  {errorText}
@@ -140,7 +187,7 @@ export function CheckoutConsentDialog({
140
187
  {t("billing-foundation.consent.back")}
141
188
  </Button>
142
189
  <Button
143
- disabled={!earlyPerformance || !withdrawalLoss || submitting}
190
+ disabled={!consentGiven || submitting}
144
191
  loading={submitting}
145
192
  onClick={order}
146
193
  testId="checkout-consent-order"
@@ -95,6 +95,10 @@ export const SubscriptionFoundationQueries = {
95
95
  * current tier and per-plan action. Only registered when a `catalog` is
96
96
  * configured. */
97
97
  billingPlans: "billing-foundation:query:billing-plans",
98
+ /** What switching to a tier would cost right now, for providers that switch
99
+ * plans in-app (preview + apply). Only registered when a `catalog` is
100
+ * configured. */
101
+ planSwitchPreview: "billing-foundation:query:plan-switch-preview",
98
102
  /** System-only probe: does the caller tenant hold a non-terminal
99
103
  * subscription (consumerProtection only). */
100
104
  terminableSubscription: "billing-foundation:query:terminable-subscription",
@@ -0,0 +1,55 @@
1
+ import type { BillingPlanView } from "../types.ts";
2
+
3
+ type Recurrence = {
4
+ readonly interval: string | null;
5
+ readonly intervalCount: number | null;
6
+ };
7
+
8
+ export function intervalKey(price: Recurrence | null): string | undefined {
9
+ if (price === null || price.interval === null) return undefined;
10
+ return price.intervalCount !== null && price.intervalCount > 1
11
+ ? `billing-foundation.plans.everyInterval.${price.interval}`
12
+ : `billing-foundation.plans.perInterval.${price.interval}`;
13
+ }
14
+
15
+ export function renewalKey(price: Recurrence | null): string | undefined {
16
+ if (price === null || price.interval === null) return undefined;
17
+ return price.intervalCount !== null && price.intervalCount > 1
18
+ ? `billing-foundation.consent.renewsEvery.${price.interval}`
19
+ : `billing-foundation.consent.renews.${price.interval}`;
20
+ }
21
+
22
+ type PlanPrice = NonNullable<BillingPlanView["price"]>;
23
+
24
+ export function pricesDiffer(prices: readonly PlanPrice[]): boolean {
25
+ const [first, ...rest] = prices;
26
+ return (
27
+ first !== undefined &&
28
+ rest.some(
29
+ (price) =>
30
+ price.unitAmount !== first.unitAmount ||
31
+ price.currency !== first.currency ||
32
+ price.interval !== first.interval ||
33
+ price.intervalCount !== first.intervalCount,
34
+ )
35
+ );
36
+ }
37
+
38
+ /** Price of `tier` at every provider that offers it, in provider order. */
39
+ export function tierPrices(
40
+ providers: readonly { readonly plans: readonly BillingPlanView[] }[],
41
+ tier: string,
42
+ ): PlanPrice[] {
43
+ return providers.flatMap((provider) => {
44
+ const price = provider.plans.find((plan) => plan.tier === tier)?.price;
45
+ return price ? [price] : [];
46
+ });
47
+ }
48
+
49
+ export function cheapestPrice(prices: readonly PlanPrice[]): PlanPrice | undefined {
50
+ return prices.reduce<PlanPrice | undefined>(
51
+ (lowest, price) =>
52
+ lowest === undefined || price.unitAmount < lowest.unitAmount ? price : lowest,
53
+ undefined,
54
+ );
55
+ }
@@ -0,0 +1,224 @@
1
+ import {
2
+ useLocale,
3
+ useMutation,
4
+ usePrimitives,
5
+ useQuery,
6
+ useTranslation,
7
+ } from "@cosmicdrift/kumiko-renderer";
8
+ import { formatMoney } from "@cosmicdrift/kumiko-renderer/web";
9
+ import { type ReactNode, useState } from "react";
10
+ import type { BillingPlansResult, PlanSwitchPreview } from "../types.ts";
11
+ import {
12
+ type CheckoutConsentPayload,
13
+ CONSENT_TEXT_OUTDATED_CODE,
14
+ } from "./checkout-consent-dialog.tsx";
15
+ import { SubscriptionFoundationHandlers, SubscriptionFoundationQueries } from "./constants.ts";
16
+ import { resolveConsentLocale } from "./consumer-protection/consent-locale.ts";
17
+ import { intervalKey } from "./plan-price.ts";
18
+
19
+ type ConsumerProtectionView = NonNullable<BillingPlansResult["consumerProtection"]>;
20
+
21
+ type PlanSwitchPreviewReply = {
22
+ readonly preview: PlanSwitchPreview;
23
+ readonly target: {
24
+ readonly unitAmount: number;
25
+ readonly currency: string;
26
+ readonly interval: string | null;
27
+ readonly intervalCount: number | null;
28
+ };
29
+ };
30
+
31
+ export type PlanSwitchConfirmDialogProps = {
32
+ readonly open: boolean;
33
+ readonly onOpenChange: (open: boolean) => void;
34
+ readonly tier: string;
35
+ readonly fromPlanName: string;
36
+ readonly toPlanName: string;
37
+ readonly consumerProtection?: ConsumerProtectionView;
38
+ readonly onApplied: () => void;
39
+ readonly onConsentTextOutdated: () => void;
40
+ };
41
+
42
+ // kumiko-lint-ignore no-custom-primitives Uses usePrimitives().Modal internally, name is a billing domain action, not a primitive reimplementation
43
+ export function PlanSwitchConfirmDialog({
44
+ open,
45
+ onOpenChange,
46
+ tier,
47
+ fromPlanName,
48
+ toPlanName,
49
+ consumerProtection,
50
+ onApplied,
51
+ onConsentTextOutdated,
52
+ }: PlanSwitchConfirmDialogProps): ReactNode {
53
+ const t = useTranslation();
54
+ const locale = useLocale().locale();
55
+ const { Modal, Text, Field, Input, Button, Link, Banner } = usePrimitives();
56
+ const previewQuery = useQuery<PlanSwitchPreviewReply>(
57
+ SubscriptionFoundationQueries.planSwitchPreview,
58
+ { tier },
59
+ );
60
+ const switchMutation = useMutation<{ readonly applied: true }>(
61
+ SubscriptionFoundationHandlers.switchPlan,
62
+ );
63
+ const [earlyPerformance, setEarlyPerformance] = useState(false);
64
+ const [withdrawalLoss, setWithdrawalLoss] = useState(false);
65
+ const [submitting, setSubmitting] = useState(false);
66
+ const [errorText, setErrorText] = useState<string | null>(null);
67
+
68
+ const consentLocale = resolveConsentLocale(locale);
69
+ const texts = consumerProtection?.consentTexts[consentLocale];
70
+ const legalLinks = consumerProtection?.legalLinks[consentLocale];
71
+ const consentGiven = consumerProtection === undefined || (earlyPerformance && withdrawalLoss);
72
+
73
+ async function confirm(): Promise<void> {
74
+ setSubmitting(true);
75
+ setErrorText(null);
76
+ const consent: CheckoutConsentPayload | undefined =
77
+ texts === undefined
78
+ ? undefined
79
+ : {
80
+ earlyPerformanceRequested: true,
81
+ withdrawalLossAcknowledged: true,
82
+ consentTextVersion: texts.consentTextVersion,
83
+ locale: consentLocale,
84
+ };
85
+ const result = await switchMutation.mutate({ tier, ...(consent && { consent }) });
86
+ if (result.isSuccess) {
87
+ onApplied();
88
+ return;
89
+ }
90
+ setSubmitting(false);
91
+ setErrorText(t(result.error.i18nKey, result.error.i18nParams));
92
+ if (result.error.code === CONSENT_TEXT_OUTDATED_CODE) {
93
+ setEarlyPerformance(false);
94
+ setWithdrawalLoss(false);
95
+ onConsentTextOutdated();
96
+ }
97
+ }
98
+
99
+ const reply = previewQuery.data;
100
+ const money = (amount: number, currency: string) => formatMoney(amount, currency, locale);
101
+ const recurringKey = reply === null ? undefined : intervalKey(reply.target);
102
+ const recurringPrice =
103
+ reply === null
104
+ ? ""
105
+ : `${money(reply.target.unitAmount, reply.target.currency)}${
106
+ recurringKey === undefined
107
+ ? ""
108
+ : ` ${t(recurringKey, { count: new Intl.NumberFormat(locale).format(reply.target.intervalCount ?? 1) })}`
109
+ }`;
110
+
111
+ return (
112
+ <Modal
113
+ open={open}
114
+ onOpenChange={onOpenChange}
115
+ title={t("billing-foundation.planSwitch.title")}
116
+ testId="plan-switch-confirm-dialog"
117
+ >
118
+ <div className="flex flex-col gap-4">
119
+ <Text>
120
+ {t("billing-foundation.planSwitch.summary", { from: fromPlanName, to: toPlanName })}
121
+ </Text>
122
+ {previewQuery.error !== null && (
123
+ <Banner variant="error" testId="plan-switch-preview-error">
124
+ {t(previewQuery.error.i18nKey, previewQuery.error.i18nParams)}
125
+ </Banner>
126
+ )}
127
+ {previewQuery.loading && reply === null && (
128
+ <Banner variant="loading" testId="plan-switch-preview-loading">
129
+ {t("kumiko.widget.loading")}
130
+ </Banner>
131
+ )}
132
+ {reply !== null && (
133
+ <div className="flex flex-col gap-1" data-testid="plan-switch-summary">
134
+ <Text>
135
+ {t("billing-foundation.planSwitch.dueNow", {
136
+ amount: money(reply.preview.amountDueNow, reply.preview.currency),
137
+ })}
138
+ {reply.preview.tax !== null &&
139
+ ` (${t("billing-foundation.planSwitch.includesTax", {
140
+ tax: money(reply.preview.tax, reply.preview.currency),
141
+ })})`}
142
+ </Text>
143
+ <Text variant="muted">
144
+ {t("billing-foundation.planSwitch.credit", {
145
+ amount: money(reply.preview.credit, reply.preview.currency),
146
+ })}
147
+ </Text>
148
+ <Text variant="muted">
149
+ {t("billing-foundation.planSwitch.recurring", {
150
+ amount: recurringPrice,
151
+ })}
152
+ </Text>
153
+ <Text variant="muted">{t("billing-foundation.planSwitch.effective")}</Text>
154
+ <Text variant="muted">{t("billing-foundation.planSwitch.pendingNote")}</Text>
155
+ </div>
156
+ )}
157
+ {legalLinks !== undefined && (
158
+ <div className="flex flex-wrap gap-4">
159
+ <Link href={legalLinks.terms} target="_blank">
160
+ {t("billing-foundation.consent.link.terms")}
161
+ </Link>
162
+ <Link href={legalLinks.withdrawal} target="_blank">
163
+ {t("billing-foundation.consent.link.withdrawal")}
164
+ </Link>
165
+ <Link href={legalLinks.privacy} target="_blank">
166
+ {t("billing-foundation.consent.link.privacy")}
167
+ </Link>
168
+ </div>
169
+ )}
170
+ {texts !== undefined && (
171
+ <>
172
+ <Field
173
+ id="plan-switch-early-performance"
174
+ label={texts.earlyPerformance}
175
+ layout="inline"
176
+ >
177
+ <Input
178
+ kind="boolean"
179
+ id="plan-switch-early-performance"
180
+ name="plan-switch-early-performance"
181
+ value={earlyPerformance}
182
+ onChange={setEarlyPerformance}
183
+ disabled={submitting}
184
+ />
185
+ </Field>
186
+ <Field id="plan-switch-withdrawal-loss" label={texts.withdrawalLoss} layout="inline">
187
+ <Input
188
+ kind="boolean"
189
+ id="plan-switch-withdrawal-loss"
190
+ name="plan-switch-withdrawal-loss"
191
+ value={withdrawalLoss}
192
+ onChange={setWithdrawalLoss}
193
+ disabled={submitting}
194
+ />
195
+ </Field>
196
+ </>
197
+ )}
198
+ {errorText !== null && (
199
+ <Banner variant="error" testId="plan-switch-confirm-error">
200
+ {errorText}
201
+ </Banner>
202
+ )}
203
+ <div className="flex justify-end gap-2">
204
+ <Button
205
+ variant="secondary"
206
+ disabled={submitting}
207
+ onClick={() => onOpenChange(false)}
208
+ testId="plan-switch-confirm-cancel"
209
+ >
210
+ {t("billing-foundation.consent.back")}
211
+ </Button>
212
+ <Button
213
+ disabled={!consentGiven || reply === null || submitting}
214
+ loading={submitting}
215
+ onClick={confirm}
216
+ testId="plan-switch-confirm-order"
217
+ >
218
+ {t("billing-foundation.planSwitch.order")}
219
+ </Button>
220
+ </div>
221
+ </div>
222
+ </Modal>
223
+ );
224
+ }
@@ -473,7 +473,11 @@ async function consumeClaimedRow(
473
473
  if (!booked.isSuccess) return booked;
474
474
  await txDb.updateMany(
475
475
  capReservationsTable,
476
- { consumedAmount: claimed.consumedAmount + options.units },
476
+ {
477
+ consumedAmount: claimed.consumedAmount + options.units,
478
+ // Keep the tombstone alive past its last consume, else a later consume would start a fresh one and double count.
479
+ expiresAt: Temporal.Now.instant().add({ minutes: CAP_SETTLED_TOMBSTONE_TTL_MINUTES }),
480
+ },
477
481
  ownRow,
478
482
  );
479
483
  return booked;
@@ -1,4 +1,22 @@
1
1
  [
2
+ {
3
+ "version": "0.388.0",
4
+ "type": "fix",
5
+ "title": "Consume on a released reservation extends its tombstone",
6
+ "detail": "Every consume against a released reservation tombstone now pushes its expiry out, so a long-running job that consumes across the sweep can no longer lose the tombstone and have its earlier units counted twice at settle."
7
+ },
8
+ {
9
+ "version": "0.388.0",
10
+ "type": "fix",
11
+ "title": "Misconfigured cap period fails on every enforcement",
12
+ "detail": "A periodEndIso that is unparsable or not after periodStartIso now throws on each enforceCap or reservation call instead of only once the hard cap is reached, so a bad period no longer turns into a 500 at the limit instead of cap_exceeded."
13
+ },
14
+ {
15
+ "version": "0.388.0",
16
+ "type": "fix",
17
+ "title": "Stock cap lock covers the counted rows, not the error code",
18
+ "detail": "withStockCap serializes on the table and where filter that are counted, so two handlers capping the same rows under different codes can no longer both pass the check in parallel and exceed the cap together."
19
+ },
2
20
  {
3
21
  "version": "0.378.0",
4
22
  "type": "fix",
@@ -71,8 +71,9 @@ export function assertBelowHardCap(
71
71
  },
72
72
  ): void {
73
73
  const tolerance = CAP_TOLERANCES[cap.profile];
74
+ // Validated on every call so a misconfigured period fails deterministically, not only at the limit.
75
+ const resetAt = resolvePeriodEndIso(cap.periodStartIso, cap.periodEndIso);
74
76
  if (valueSeenByLastUnit >= cap.limit * tolerance.hard) {
75
- const resetAt = resolvePeriodEndIso(cap.periodStartIso, cap.periodEndIso);
76
77
  throw new CapExceededError(cap.capName, cap.limit, valueSeenByLastUnit, tolerance, {
77
78
  ...(resetAt !== undefined && { resetAt }),
78
79
  ...(cap.i18nKey !== undefined && { i18nKey: cap.i18nKey }),
@@ -40,6 +40,13 @@ export type StockCapSpec<TCaps> = {
40
40
  readonly field: string;
41
41
  };
42
42
 
43
+ // Lock on the counted set, not on `code`: two handlers capping the same rows under
44
+ // different codes must still serialize against each other.
45
+ function countedSetKey(spec: Pick<StockCapSpec<unknown>, "table" | "where">): string {
46
+ const where = Object.entries(spec.where ?? {}).sort(([a], [b]) => a.localeCompare(b));
47
+ return `${spec.table.tableName}:${JSON.stringify(where)}`;
48
+ }
49
+
43
50
  export type StockCapGuard<TCaps> = {
44
51
  readonly checkStockCap: (
45
52
  db: TenantDb,
@@ -81,7 +88,7 @@ export function createStockCapGuard<TCaps>(
81
88
  // ctx.db is the handler's transaction, so the xact lock releases with its commit or
82
89
  // rollback. A handler wrapped in several caps must list them in the same order
83
90
  // everywhere; Postgres aborts one side of an inverse order with a deadlock error.
84
- await ctx.db.advisoryXactLock(STOCK_CAP_LOCK_NAMESPACE, spec.code);
91
+ await ctx.db.advisoryXactLock(STOCK_CAP_LOCK_NAMESPACE, countedSetKey(spec));
85
92
  const failure = await checkStockCap(ctx.db, spec, { config: ctx.config });
86
93
  return failure ?? handler.handler(event, ctx);
87
94
  },
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.388.0",
4
+ "type": "fix",
5
+ "title": "Corrected description of an email channel without app transport",
6
+ "detail": "Without an app transport only notifications declared with tenantTransport true are delivered; all others fail with missing_credentials, so such apps must mark every outgoing notification or keep an app transport."
7
+ },
2
8
  {
3
9
  "version": "0.376.0",
4
10
  "type": "improvement",
@@ -12,7 +12,7 @@ export function createChannelEmailFeature(options: EmailChannelOptions) {
12
12
 
13
13
  const feature = defineFeature("channel-email", (r) => {
14
14
  r.describe(
15
- "Wires an `EmailTransport` (typically `mail-transport-smtp` in production, `createInMemoryTransport()` in tests) into the delivery system as the `email` channel. Requires `delivery`; pass an `EmailChannelOptions` with a `transport` and a `resolveEmail` function that maps a user ID to their email address. Mails render from stored templates (`renderer-mail-html`): a notification routed to this channel needs `template` + `meta` on `r.notification`, otherwise its delivery fails. Pass `tenantTransport: tenantMailTransport` (from `mail-foundation`) to send the notifications declared with `tenantTransport: true` through the sending tenant's own configured provider: a broken provider fails the send, a tenant without one uses the app `transport`; every other notification (password reset, sign-in) always goes through the app `transport`. `transport` may be left out when `tenantTransport` is set; a mail that then has no transport fails with `missing_credentials`. Notifications declared with `unsubscribe: true` get a signed `data.unsubscribeUrl` from delivery, which turns into the `List-Unsubscribe` headers.",
15
+ "Wires an `EmailTransport` (typically `mail-transport-smtp` in production, `createInMemoryTransport()` in tests) into the delivery system as the `email` channel. Requires `delivery`; pass an `EmailChannelOptions` with a `transport` and a `resolveEmail` function that maps a user ID to their email address. Mails render from stored templates (`renderer-mail-html`): a notification routed to this channel needs `template` + `meta` on `r.notification`, otherwise its delivery fails. Pass `tenantTransport: tenantMailTransport` (from `mail-foundation`) to send the notifications declared with `tenantTransport: true` through the sending tenant's own configured provider: a broken provider fails the send, a tenant without one uses the app `transport`; every other notification (password reset, sign-in) always goes through the app `transport`. `transport` may be left out when `tenantTransport` is set, but then only notifications declared with `tenantTransport: true` are delivered: every other notification (password reset, sign-in, invitations) fails with `missing_credentials`, so an app without an app SMTP server must mark every notification that should go out or keep an app `transport`. Notifications declared with `unsubscribe: true` get a signed `data.unsubscribeUrl` from delivery, which turns into the `List-Unsubscribe` headers.",
16
16
  );
17
17
  r.uiHints({
18
18
  displayLabel: "Email Channel",
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "version": "0.388.0",
4
+ "type": "improvement",
5
+ "title": "config:query:values and config:query:cascade accept a read scope",
6
+ "detail": "Both queries take an optional scope (system, tenant, user). With scope system the cascade of a tenant or user key is evaluated at system level, so the system row is returned even when the caller's own tenant has a row for the key. Only a SystemAdmin gets the narrowed read; other callers keep the normal cascade. Inherited-value redaction still applies. Without scope the behavior is unchanged."
7
+ },
8
+ {
9
+ "version": "0.388.0",
10
+ "type": "fix",
11
+ "title": "Remove unreachable branch in configRowHoldsValue",
12
+ "detail": "The undefined key definition is now handled together with the plaintext comparison, dropping a dead return without changing behavior."
13
+ },
2
14
  {
3
15
  "version": "0.364.0",
4
16
  "type": "fix",
@@ -1,13 +1,14 @@
1
1
  import {
2
2
  type ConfigCascade,
3
3
  type ConfigCascadeLevel,
4
+ ConfigScopes,
4
5
  defineQueryHandler,
5
6
  isEncryptedAtRest,
6
7
  } from "@cosmicdrift/kumiko-framework/engine";
7
8
  import * as z from "zod";
8
9
  import { requireConfigResolver, requireSystemDb } from "../feature.ts";
9
10
  import { MASKED, redactInheritedCascade, shouldRedactInherited } from "../read-redaction.ts";
10
- import { hasConfigAccess } from "../write-helpers.ts";
11
+ import { hasConfigAccess, keyDefAtScope } from "../write-helpers.ts";
11
12
 
12
13
  export const cascadeQuery = defineQueryHandler({
13
14
  name: "cascade",
@@ -15,6 +16,7 @@ export const cascadeQuery = defineQueryHandler({
15
16
  "Shows, per config key the caller may read, the value at every cascade level (user, tenant, system, app override, default) and which level won; use it to explain where a setting's current value comes from.",
16
17
  schema: z.object({
17
18
  keys: z.array(z.string()).optional(),
19
+ scope: z.enum([ConfigScopes.system, ConfigScopes.tenant, ConfigScopes.user]).optional(),
18
20
  }),
19
21
  access: {
20
22
  openToAll: {
@@ -47,7 +49,7 @@ export const cascadeQuery = defineQueryHandler({
47
49
  const keyDef = allKeys.get(key);
48
50
  if (!keyDef) continue;
49
51
  if (!hasConfigAccess(keyDef.access.read, query.user.roles)) continue;
50
- keyDefs.set(key, keyDef);
52
+ keyDefs.set(key, keyDefAtScope(keyDef, query.payload.scope, query.user.roles));
51
53
  filteredKeys.push(key);
52
54
  }
53
55
 
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  type ConfigKeyDefinition,
3
3
  type ConfigScope,
4
+ ConfigScopes,
4
5
  type ConfigValueSource,
5
6
  defineQueryHandler,
6
7
  isEncryptedAtRest,
@@ -8,7 +9,7 @@ import {
8
9
  import * as z from "zod";
9
10
  import { requireConfigResolver, requireSystemDb } from "../feature.ts";
10
11
  import { MASKED, redactInheritedCascade, shouldRedactInherited } from "../read-redaction.ts";
11
- import { hasConfigAccess } from "../write-helpers.ts";
12
+ import { hasConfigAccess, keyDefAtScope } from "../write-helpers.ts";
12
13
 
13
14
  export const valuesQuery = defineQueryHandler({
14
15
  name: "values",
@@ -16,6 +17,7 @@ export const valuesQuery = defineQueryHandler({
16
17
  "Returns the effective value, scope and winning source for every config key the caller may read (optionally narrowed to `keys`), with encrypted and secret-backed values masked; use it to inspect the current settings.",
17
18
  schema: z.object({
18
19
  keys: z.array(z.string()).optional(),
20
+ scope: z.enum([ConfigScopes.system, ConfigScopes.tenant, ConfigScopes.user]).optional(),
19
21
  }),
20
22
  // Per-key read access enforced via hasConfigAccess inside the handler.
21
23
  access: {
@@ -44,7 +46,7 @@ export const valuesQuery = defineQueryHandler({
44
46
  for (const [qualifiedKey, keyDef] of allKeys) {
45
47
  if (requestedKeys !== undefined && !requestedKeys.has(qualifiedKey)) continue;
46
48
  if (!hasConfigAccess(keyDef.access.read, query.user.roles)) continue;
47
- keyDefs.set(qualifiedKey, keyDef);
49
+ keyDefs.set(qualifiedKey, keyDefAtScope(keyDef, query.payload.scope, query.user.roles));
48
50
  filteredKeys.push(qualifiedKey);
49
51
  }
50
52