@cosmicdrift/kumiko-bundled-features 0.1.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 (333) hide show
  1. package/package.json +90 -0
  2. package/src/audit/__tests__/audit.integration.ts +328 -0
  3. package/src/audit/constants.ts +7 -0
  4. package/src/audit/feature.ts +23 -0
  5. package/src/audit/handlers/list.query.ts +98 -0
  6. package/src/audit/index.ts +2 -0
  7. package/src/auth-email-password/__tests__/account-lockout-no-redis.integration.ts +149 -0
  8. package/src/auth-email-password/__tests__/account-lockout.integration.ts +308 -0
  9. package/src/auth-email-password/__tests__/auth-claims.integration.ts +512 -0
  10. package/src/auth-email-password/__tests__/auth.integration.ts +610 -0
  11. package/src/auth-email-password/__tests__/confirm-token-flow.test.ts +67 -0
  12. package/src/auth-email-password/__tests__/email-templates.test.ts +106 -0
  13. package/src/auth-email-password/__tests__/email-verification.integration.ts +327 -0
  14. package/src/auth-email-password/__tests__/identity-v3-hash.test.ts +174 -0
  15. package/src/auth-email-password/__tests__/identity-v3-login.integration.ts +150 -0
  16. package/src/auth-email-password/__tests__/invite-flow.integration.ts +458 -0
  17. package/src/auth-email-password/__tests__/multi-roles.integration.ts +256 -0
  18. package/src/auth-email-password/__tests__/password-reset.integration.ts +346 -0
  19. package/src/auth-email-password/__tests__/public-routes-rate-limit.integration.ts +144 -0
  20. package/src/auth-email-password/__tests__/seed-admin.integration.ts +176 -0
  21. package/src/auth-email-password/__tests__/session-callbacks.integration.ts +310 -0
  22. package/src/auth-email-password/__tests__/session-strict-mode.integration.ts +101 -0
  23. package/src/auth-email-password/__tests__/signed-token.test.ts +78 -0
  24. package/src/auth-email-password/__tests__/signup-flow.integration.ts +259 -0
  25. package/src/auth-email-password/auth-user-row.ts +41 -0
  26. package/src/auth-email-password/constants.ts +101 -0
  27. package/src/auth-email-password/email-templates.ts +283 -0
  28. package/src/auth-email-password/feature.ts +140 -0
  29. package/src/auth-email-password/handlers/change-password.write.ts +58 -0
  30. package/src/auth-email-password/handlers/confirm-token-flow.ts +191 -0
  31. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +203 -0
  32. package/src/auth-email-password/handlers/invite-accept.write.ts +189 -0
  33. package/src/auth-email-password/handlers/invite-create.write.ts +145 -0
  34. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +192 -0
  35. package/src/auth-email-password/handlers/login.write.ts +208 -0
  36. package/src/auth-email-password/handlers/logout.write.ts +12 -0
  37. package/src/auth-email-password/handlers/request-email-verification.write.ts +29 -0
  38. package/src/auth-email-password/handlers/request-password-reset.write.ts +31 -0
  39. package/src/auth-email-password/handlers/reset-password.write.ts +61 -0
  40. package/src/auth-email-password/handlers/signup-confirm.write.ts +170 -0
  41. package/src/auth-email-password/handlers/signup-request.write.ts +104 -0
  42. package/src/auth-email-password/handlers/token-request-handler.ts +114 -0
  43. package/src/auth-email-password/handlers/verify-email.write.ts +62 -0
  44. package/src/auth-email-password/i18n.ts +211 -0
  45. package/src/auth-email-password/identity-v3-hash.ts +97 -0
  46. package/src/auth-email-password/index.ts +35 -0
  47. package/src/auth-email-password/invite-token-store.ts +92 -0
  48. package/src/auth-email-password/lockout-store.ts +118 -0
  49. package/src/auth-email-password/password-hashing.ts +43 -0
  50. package/src/auth-email-password/reset-token.ts +28 -0
  51. package/src/auth-email-password/seeding.ts +183 -0
  52. package/src/auth-email-password/signed-token.ts +85 -0
  53. package/src/auth-email-password/signup-token-store.ts +104 -0
  54. package/src/auth-email-password/stream-tenant.ts +31 -0
  55. package/src/auth-email-password/testing.ts +5 -0
  56. package/src/auth-email-password/token-burn-store.ts +57 -0
  57. package/src/auth-email-password/verification-token.ts +27 -0
  58. package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +51 -0
  59. package/src/auth-email-password/web/__tests__/forgot-password-screen.test.tsx +80 -0
  60. package/src/auth-email-password/web/__tests__/login-screen.test.tsx +94 -0
  61. package/src/auth-email-password/web/__tests__/reset-password-screen.test.tsx +108 -0
  62. package/src/auth-email-password/web/__tests__/session-roles.test.ts +54 -0
  63. package/src/auth-email-password/web/__tests__/tenant-switcher.test.tsx +100 -0
  64. package/src/auth-email-password/web/__tests__/test-utils.tsx +73 -0
  65. package/src/auth-email-password/web/__tests__/user-menu.test.tsx +55 -0
  66. package/src/auth-email-password/web/__tests__/verify-email-screen.test.tsx +59 -0
  67. package/src/auth-email-password/web/auth-client.ts +350 -0
  68. package/src/auth-email-password/web/auth-form-primitives.tsx +70 -0
  69. package/src/auth-email-password/web/auth-gate.tsx +33 -0
  70. package/src/auth-email-password/web/client-plugin.ts +48 -0
  71. package/src/auth-email-password/web/default-topbar-actions.tsx +47 -0
  72. package/src/auth-email-password/web/forgot-password-screen.tsx +110 -0
  73. package/src/auth-email-password/web/index.ts +56 -0
  74. package/src/auth-email-password/web/invite-accept-screen.tsx +220 -0
  75. package/src/auth-email-password/web/login-screen.tsx +150 -0
  76. package/src/auth-email-password/web/reset-password-screen.tsx +152 -0
  77. package/src/auth-email-password/web/session.tsx +171 -0
  78. package/src/auth-email-password/web/signup-complete-screen.tsx +150 -0
  79. package/src/auth-email-password/web/signup-screen.tsx +130 -0
  80. package/src/auth-email-password/web/tenant-switcher.tsx +116 -0
  81. package/src/auth-email-password/web/use-shell-user.ts +34 -0
  82. package/src/auth-email-password/web/user-menu.tsx +89 -0
  83. package/src/auth-email-password/web/verify-email-screen.tsx +102 -0
  84. package/src/billing-foundation/__tests__/billing-foundation.integration.ts +568 -0
  85. package/src/billing-foundation/__tests__/feature.test.ts +110 -0
  86. package/src/billing-foundation/__tests__/webhook-handler.test.ts +199 -0
  87. package/src/billing-foundation/aggregate-id.ts +21 -0
  88. package/src/billing-foundation/constants.ts +70 -0
  89. package/src/billing-foundation/entities.ts +50 -0
  90. package/src/billing-foundation/events.ts +71 -0
  91. package/src/billing-foundation/feature.ts +122 -0
  92. package/src/billing-foundation/get-subscription-for-tenant.ts +39 -0
  93. package/src/billing-foundation/handlers/create-checkout-session.write.ts +79 -0
  94. package/src/billing-foundation/handlers/create-portal-session.write.ts +73 -0
  95. package/src/billing-foundation/handlers/list-subscriptions.query.ts +20 -0
  96. package/src/billing-foundation/handlers/process-event.write.ts +160 -0
  97. package/src/billing-foundation/index.ts +42 -0
  98. package/src/billing-foundation/projection.ts +135 -0
  99. package/src/billing-foundation/types.ts +157 -0
  100. package/src/billing-foundation/webhook-handler.ts +184 -0
  101. package/src/cap-counter/__tests__/cap-counter.integration.ts +566 -0
  102. package/src/cap-counter/__tests__/enforce-cap.test.ts +422 -0
  103. package/src/cap-counter/__tests__/with-cap-enforcement.integration.ts +265 -0
  104. package/src/cap-counter/aggregate-id.ts +61 -0
  105. package/src/cap-counter/constants.ts +32 -0
  106. package/src/cap-counter/enforce-cap.ts +404 -0
  107. package/src/cap-counter/entity.ts +48 -0
  108. package/src/cap-counter/feature.ts +90 -0
  109. package/src/cap-counter/handlers/get-counter.query.ts +43 -0
  110. package/src/cap-counter/handlers/increment-rolling.write.ts +79 -0
  111. package/src/cap-counter/handlers/increment.write.ts +92 -0
  112. package/src/cap-counter/handlers/mark-soft-warned.write.ts +57 -0
  113. package/src/cap-counter/index.ts +34 -0
  114. package/src/cap-counter/with-cap-enforcement.ts +179 -0
  115. package/src/channel-email/email-channel.ts +48 -0
  116. package/src/channel-email/feature.ts +15 -0
  117. package/src/channel-email/index.ts +4 -0
  118. package/src/channel-email/smtp-transport.ts +65 -0
  119. package/src/channel-email/types.ts +34 -0
  120. package/src/channel-in-app/constants.ts +11 -0
  121. package/src/channel-in-app/feature.ts +30 -0
  122. package/src/channel-in-app/handlers/inbox.query.ts +28 -0
  123. package/src/channel-in-app/handlers/mark-all-read.write.ts +21 -0
  124. package/src/channel-in-app/handlers/mark-read.write.ts +32 -0
  125. package/src/channel-in-app/handlers/unread-count.query.ts +20 -0
  126. package/src/channel-in-app/in-app-channel.ts +44 -0
  127. package/src/channel-in-app/index.ts +4 -0
  128. package/src/channel-in-app/tables.ts +22 -0
  129. package/src/channel-push/feature.ts +15 -0
  130. package/src/channel-push/index.ts +3 -0
  131. package/src/channel-push/push-channel.ts +33 -0
  132. package/src/channel-push/types.ts +22 -0
  133. package/src/config/__tests__/app-overrides.test.ts +118 -0
  134. package/src/config/__tests__/config.integration.ts +1246 -0
  135. package/src/config/constants.ts +23 -0
  136. package/src/config/feature.ts +117 -0
  137. package/src/config/handlers/__tests__/prepare-config-write.test.ts +209 -0
  138. package/src/config/handlers/reset.write.ts +45 -0
  139. package/src/config/handlers/schema.query.ts +22 -0
  140. package/src/config/handlers/set.write.ts +93 -0
  141. package/src/config/handlers/values.query.ts +43 -0
  142. package/src/config/index.ts +15 -0
  143. package/src/config/resolver.ts +283 -0
  144. package/src/config/table.ts +35 -0
  145. package/src/config/write-helpers.ts +268 -0
  146. package/src/delivery/__tests__/delivery-events.integration.ts +166 -0
  147. package/src/delivery/__tests__/delivery.integration.ts +1405 -0
  148. package/src/delivery/constants.ts +33 -0
  149. package/src/delivery/delivery-service.ts +489 -0
  150. package/src/delivery/events.ts +18 -0
  151. package/src/delivery/feature.ts +70 -0
  152. package/src/delivery/handlers/log.query.ts +21 -0
  153. package/src/delivery/handlers/preferences.query.ts +18 -0
  154. package/src/delivery/handlers/set-preference.write.ts +28 -0
  155. package/src/delivery/index.ts +35 -0
  156. package/src/delivery/tables.ts +74 -0
  157. package/src/delivery/testing.ts +47 -0
  158. package/src/delivery/types.ts +71 -0
  159. package/src/delivery/unsubscribe.ts +99 -0
  160. package/src/delivery/upsert-preference.ts +145 -0
  161. package/src/feature-toggles/__tests__/feature-toggles.integration.ts +687 -0
  162. package/src/feature-toggles/constants.ts +20 -0
  163. package/src/feature-toggles/events.ts +18 -0
  164. package/src/feature-toggles/feature.ts +98 -0
  165. package/src/feature-toggles/global-feature-state-table.ts +28 -0
  166. package/src/feature-toggles/handlers/list.query.ts +26 -0
  167. package/src/feature-toggles/handlers/registered.query.ts +56 -0
  168. package/src/feature-toggles/handlers/set.write.ts +158 -0
  169. package/src/feature-toggles/index.ts +9 -0
  170. package/src/feature-toggles/toggle-runtime.ts +73 -0
  171. package/src/file-foundation/__tests__/feature.test.ts +35 -0
  172. package/src/file-foundation/__tests__/file-foundation.integration.ts +235 -0
  173. package/src/file-foundation/feature.ts +123 -0
  174. package/src/file-foundation/index.ts +7 -0
  175. package/src/file-provider-inmemory/__tests__/feature.test.ts +35 -0
  176. package/src/file-provider-inmemory/feature.ts +73 -0
  177. package/src/file-provider-inmemory/index.ts +3 -0
  178. package/src/file-provider-s3/__tests__/feature.test.ts +54 -0
  179. package/src/file-provider-s3/feature.ts +169 -0
  180. package/src/file-provider-s3/index.ts +3 -0
  181. package/src/files-provider-s3/__tests__/env-helper.test.ts +161 -0
  182. package/src/files-provider-s3/__tests__/s3-provider.integration.ts +134 -0
  183. package/src/files-provider-s3/__tests__/s3-provider.test.ts +36 -0
  184. package/src/files-provider-s3/env-helper.ts +49 -0
  185. package/src/files-provider-s3/index.ts +3 -0
  186. package/src/files-provider-s3/s3-provider.ts +114 -0
  187. package/src/foundation-shared/config-helpers.ts +67 -0
  188. package/src/foundation-shared/index.ts +4 -0
  189. package/src/jobs/__tests__/job-system-user.integration.ts +194 -0
  190. package/src/jobs/__tests__/jobs-events.integration.ts +143 -0
  191. package/src/jobs/__tests__/jobs-feature.integration.ts +342 -0
  192. package/src/jobs/constants.ts +21 -0
  193. package/src/jobs/events.ts +39 -0
  194. package/src/jobs/feature.ts +150 -0
  195. package/src/jobs/handlers/detail.query.ts +30 -0
  196. package/src/jobs/handlers/list.query.ts +36 -0
  197. package/src/jobs/handlers/retry.write.ts +69 -0
  198. package/src/jobs/handlers/trigger.write.ts +39 -0
  199. package/src/jobs/index.ts +5 -0
  200. package/src/jobs/job-run-logger.ts +213 -0
  201. package/src/jobs/job-run-table.ts +55 -0
  202. package/src/legal-pages/README.md +195 -0
  203. package/src/legal-pages/__tests__/legal-pages.integration.ts +361 -0
  204. package/src/legal-pages/constants.ts +36 -0
  205. package/src/legal-pages/feature.ts +187 -0
  206. package/src/legal-pages/index.ts +13 -0
  207. package/src/legal-pages/markdown.ts +69 -0
  208. package/src/mail-foundation/__tests__/feature.test.ts +46 -0
  209. package/src/mail-foundation/__tests__/mail-foundation.integration.ts +247 -0
  210. package/src/mail-foundation/feature.ts +160 -0
  211. package/src/mail-foundation/index.ts +14 -0
  212. package/src/mail-transport-inmemory/__tests__/feature.test.ts +37 -0
  213. package/src/mail-transport-inmemory/feature.ts +90 -0
  214. package/src/mail-transport-inmemory/index.ts +3 -0
  215. package/src/mail-transport-smtp/__tests__/feature.test.ts +61 -0
  216. package/src/mail-transport-smtp/feature.ts +182 -0
  217. package/src/mail-transport-smtp/index.ts +3 -0
  218. package/src/rate-limiting/__tests__/rate-limiting.integration.ts +84 -0
  219. package/src/rate-limiting/constants.ts +9 -0
  220. package/src/rate-limiting/feature.ts +16 -0
  221. package/src/rate-limiting/handlers/status.query.ts +52 -0
  222. package/src/rate-limiting/index.ts +2 -0
  223. package/src/renderer-simple/__tests__/simple-renderer.test.ts +97 -0
  224. package/src/renderer-simple/feature.ts +12 -0
  225. package/src/renderer-simple/index.ts +2 -0
  226. package/src/renderer-simple/simple-renderer.ts +72 -0
  227. package/src/secrets/__tests__/rotate.integration.ts +176 -0
  228. package/src/secrets/__tests__/secrets-events.integration.ts +125 -0
  229. package/src/secrets/__tests__/secrets.integration.ts +118 -0
  230. package/src/secrets/feature.ts +84 -0
  231. package/src/secrets/handlers/delete.write.ts +20 -0
  232. package/src/secrets/handlers/list.query.ts +38 -0
  233. package/src/secrets/handlers/rotate.job.ts +193 -0
  234. package/src/secrets/handlers/set.write.ts +50 -0
  235. package/src/secrets/index.ts +16 -0
  236. package/src/secrets/secrets-context.ts +296 -0
  237. package/src/secrets/table.ts +68 -0
  238. package/src/sessions/__tests__/cleanup.integration.ts +175 -0
  239. package/src/sessions/__tests__/password-auto-revoke.integration.ts +202 -0
  240. package/src/sessions/__tests__/sessions.integration.ts +472 -0
  241. package/src/sessions/__tests__/test-helpers.ts +66 -0
  242. package/src/sessions/constants.ts +43 -0
  243. package/src/sessions/feature.ts +84 -0
  244. package/src/sessions/handlers/cleanup.job.ts +109 -0
  245. package/src/sessions/handlers/list.query.ts +35 -0
  246. package/src/sessions/handlers/mine.query.ts +37 -0
  247. package/src/sessions/handlers/revoke-all-others.write.ts +42 -0
  248. package/src/sessions/handlers/revoke.write.ts +76 -0
  249. package/src/sessions/index.ts +17 -0
  250. package/src/sessions/schema/index.ts +5 -0
  251. package/src/sessions/schema/user-session.ts +67 -0
  252. package/src/sessions/session-callbacks.ts +110 -0
  253. package/src/sessions/testing.ts +42 -0
  254. package/src/subscription-mollie/__tests__/feature.test.ts +106 -0
  255. package/src/subscription-mollie/__tests__/mollie-foundation.integration.ts +421 -0
  256. package/src/subscription-mollie/__tests__/verify-webhook.test.ts +388 -0
  257. package/src/subscription-mollie/constants.ts +33 -0
  258. package/src/subscription-mollie/feature.ts +144 -0
  259. package/src/subscription-mollie/index.ts +13 -0
  260. package/src/subscription-mollie/plugin-methods.ts +79 -0
  261. package/src/subscription-mollie/verify-webhook.ts +244 -0
  262. package/src/subscription-stripe/__tests__/feature.test.ts +98 -0
  263. package/src/subscription-stripe/__tests__/plugin-methods.test.ts +161 -0
  264. package/src/subscription-stripe/__tests__/stripe-foundation.integration.ts +315 -0
  265. package/src/subscription-stripe/__tests__/verify-webhook.test.ts +306 -0
  266. package/src/subscription-stripe/constants.ts +20 -0
  267. package/src/subscription-stripe/feature.ts +120 -0
  268. package/src/subscription-stripe/index.ts +14 -0
  269. package/src/subscription-stripe/plugin-methods.ts +91 -0
  270. package/src/subscription-stripe/verify-webhook.ts +235 -0
  271. package/src/tenant/__tests__/multi-tenant.integration.ts +278 -0
  272. package/src/tenant/__tests__/seed-testing.integration.ts +229 -0
  273. package/src/tenant/__tests__/tenant.integration.ts +347 -0
  274. package/src/tenant/command-schemas.ts +37 -0
  275. package/src/tenant/constants.ts +37 -0
  276. package/src/tenant/feature.ts +109 -0
  277. package/src/tenant/handlers/active-tenant-ids.query.ts +19 -0
  278. package/src/tenant/handlers/add-member.write.ts +53 -0
  279. package/src/tenant/handlers/cancel-invitation.write.ts +87 -0
  280. package/src/tenant/handlers/create.write.ts +21 -0
  281. package/src/tenant/handlers/disable.write.ts +18 -0
  282. package/src/tenant/handlers/invitations.query.ts +31 -0
  283. package/src/tenant/handlers/list.query.ts +17 -0
  284. package/src/tenant/handlers/me.query.ts +17 -0
  285. package/src/tenant/handlers/members.query.ts +22 -0
  286. package/src/tenant/handlers/memberships.query.ts +24 -0
  287. package/src/tenant/handlers/remove-member.write.ts +40 -0
  288. package/src/tenant/handlers/resolve-user-ids.query.ts +43 -0
  289. package/src/tenant/handlers/update-member-roles.write.ts +54 -0
  290. package/src/tenant/handlers/update.write.ts +20 -0
  291. package/src/tenant/index.ts +12 -0
  292. package/src/tenant/invitation-table.ts +93 -0
  293. package/src/tenant/membership-table.ts +35 -0
  294. package/src/tenant/schema/index.ts +5 -0
  295. package/src/tenant/schema/tenant.ts +27 -0
  296. package/src/tenant/seeding.ts +155 -0
  297. package/src/tenant/testing.ts +8 -0
  298. package/src/text-content/README.md +190 -0
  299. package/src/text-content/__tests__/text-content.integration.ts +415 -0
  300. package/src/text-content/api.ts +92 -0
  301. package/src/text-content/constants.ts +19 -0
  302. package/src/text-content/feature.ts +29 -0
  303. package/src/text-content/handlers/by-slug.query.ts +55 -0
  304. package/src/text-content/handlers/set.write.ts +118 -0
  305. package/src/text-content/index.ts +14 -0
  306. package/src/text-content/seeding.ts +91 -0
  307. package/src/text-content/table.ts +45 -0
  308. package/src/tier-engine/__tests__/compose-app.test.ts +182 -0
  309. package/src/tier-engine/__tests__/drift.test.ts +42 -0
  310. package/src/tier-engine/__tests__/tier-engine.integration.ts +241 -0
  311. package/src/tier-engine/aggregate-id.ts +27 -0
  312. package/src/tier-engine/compose-app.ts +150 -0
  313. package/src/tier-engine/constants.ts +15 -0
  314. package/src/tier-engine/entity.ts +30 -0
  315. package/src/tier-engine/feature.ts +72 -0
  316. package/src/tier-engine/handlers/active-tier.query.ts +23 -0
  317. package/src/tier-engine/index.ts +22 -0
  318. package/src/user/__tests__/seed-testing.integration.ts +127 -0
  319. package/src/user/__tests__/user.integration.ts +198 -0
  320. package/src/user/command-schemas.ts +15 -0
  321. package/src/user/constants.ts +23 -0
  322. package/src/user/feature.ts +32 -0
  323. package/src/user/handlers/create.write.ts +54 -0
  324. package/src/user/handlers/detail.query.ts +9 -0
  325. package/src/user/handlers/find-for-auth.query.ts +38 -0
  326. package/src/user/handlers/list.query.ts +8 -0
  327. package/src/user/handlers/me.query.ts +15 -0
  328. package/src/user/handlers/update.write.ts +54 -0
  329. package/src/user/index.ts +4 -0
  330. package/src/user/schema/index.ts +5 -0
  331. package/src/user/schema/user.ts +69 -0
  332. package/src/user/seeding.ts +93 -0
  333. package/src/user/testing.ts +5 -0
@@ -0,0 +1,102 @@
1
+ // @runtime client
2
+ // VerifyEmailScreen — liest `?token=...` aus URL, schickt ihn auto an
3
+ // /api/auth/verify-email beim Mount, zeigt success/error. Idempotent
4
+ // auf Server-Seite (mehrfaches Klicken setzt emailVerified=true erneut),
5
+ // also kein Re-Submit-Risiko.
6
+ //
7
+ // useEffect mit empty-deps + once-Guard damit React Strict-Mode den
8
+ // Verify-Call nur einmal feuert (Strict-Mode dispatched useEffect 2x in
9
+ // dev). Token-roundtrip ist server-side single-use, aber wir wollen
10
+ // nicht den ersten valid-call beim Mount und den zweiten als invalid-
11
+ // Banner sehen.
12
+
13
+ import { useTranslation } from "@cosmicdrift/kumiko-renderer";
14
+ import { type ReactNode, useEffect, useRef, useState } from "react";
15
+ import { verifyEmail } from "./auth-client";
16
+ import {
17
+ AuthCard,
18
+ authButtonClass,
19
+ authMutedLinkClass,
20
+ parseUrlToken,
21
+ } from "./auth-form-primitives";
22
+
23
+ export type VerifyEmailScreenProps = {
24
+ readonly title?: string;
25
+ /** Override für Token aus URL. Default: parsed aus `?token=...`. */
26
+ readonly token?: string;
27
+ /** href für "Zum Login"-Link. Default "/login". */
28
+ readonly loginHref?: string;
29
+ };
30
+
31
+ type Status = "verifying" | "success" | "error" | "missing-token";
32
+
33
+ export function VerifyEmailScreen({
34
+ title,
35
+ token: tokenProp,
36
+ loginHref = "/login",
37
+ }: VerifyEmailScreenProps): ReactNode {
38
+ const t = useTranslation();
39
+ const [token] = useState(() => tokenProp ?? parseUrlToken());
40
+ const [status, setStatus] = useState<Status>(token === "" ? "missing-token" : "verifying");
41
+ const startedRef = useRef(false);
42
+
43
+ useEffect(() => {
44
+ if (status !== "verifying") return;
45
+ if (startedRef.current) return;
46
+ startedRef.current = true;
47
+ void (async () => {
48
+ const res = await verifyEmail(token);
49
+ setStatus(res.ok ? "success" : "error");
50
+ })();
51
+ }, [status, token]);
52
+
53
+ if (status === "missing-token") {
54
+ return (
55
+ <AuthCard title={title ?? t("auth.verifyEmail.errorTitle")}>
56
+ <div className="p-6 pt-0 flex flex-col gap-4">
57
+ <p className="text-sm text-muted-foreground">{t("auth.verifyEmail.missingToken")}</p>
58
+ <a href={loginHref} className={authMutedLinkClass}>
59
+ {t("auth.verifyEmail.goToLogin")}
60
+ </a>
61
+ </div>
62
+ </AuthCard>
63
+ );
64
+ }
65
+
66
+ if (status === "verifying") {
67
+ return (
68
+ <AuthCard>
69
+ <div className="p-6">
70
+ <p className="text-sm text-muted-foreground" role="status">
71
+ {t("auth.verifyEmail.verifying")}
72
+ </p>
73
+ </div>
74
+ </AuthCard>
75
+ );
76
+ }
77
+
78
+ if (status === "success") {
79
+ return (
80
+ <AuthCard title={title ?? t("auth.verifyEmail.successTitle")}>
81
+ <div className="p-6 pt-0 flex flex-col gap-4">
82
+ <p className="text-sm text-muted-foreground">{t("auth.verifyEmail.successBody")}</p>
83
+ <a href={loginHref} className={authButtonClass}>
84
+ {t("auth.verifyEmail.goToLogin")}
85
+ </a>
86
+ </div>
87
+ </AuthCard>
88
+ );
89
+ }
90
+
91
+ // status === "error"
92
+ return (
93
+ <AuthCard title={title ?? t("auth.verifyEmail.errorTitle")}>
94
+ <div className="p-6 pt-0 flex flex-col gap-4">
95
+ <p className="text-sm text-muted-foreground">{t("auth.verifyEmail.errorBody")}</p>
96
+ <a href={loginHref} className={authMutedLinkClass}>
97
+ {t("auth.verifyEmail.goToLogin")}
98
+ </a>
99
+ </div>
100
+ </AuthCard>
101
+ );
102
+ }
@@ -0,0 +1,568 @@
1
+ // Integration-test for subscription-foundation. Treibt den process-
2
+ // event-handler durch den full Dispatcher + DB.
3
+ //
4
+ // **Mock-Plugin-Strategie (analog ai-foundation):** wir testen NICHT
5
+ // die Stripe-/Mollie-spezifischen Plugins (das passiert in deren
6
+ // eigenen feature.test.ts in Phase 5.2/5.3). Hier: direkter ctx.write
7
+ // auf process-event mit normalisiertem SubscriptionEvent als payload —
8
+ // beweist die Foundation-eigene Verdrahtung (atomic insert + upsert,
9
+ // Idempotency via deterministic aggregate-ids, tenant-isolation).
10
+ //
11
+ // Webhook-Handler-Factory (createSubscriptionWebhookHandler) wird in
12
+ // einem separaten Test mit Hono-mock geprüft.
13
+
14
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
15
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
16
+ import { createEventsTable, loadAggregate } from "@cosmicdrift/kumiko-framework/event-store";
17
+ import {
18
+ createTestUser,
19
+ setupTestStack,
20
+ type TestStack,
21
+ testTenantId,
22
+ } from "@cosmicdrift/kumiko-framework/stack";
23
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
24
+ import { subscriptionAggregateId } from "../aggregate-id";
25
+ import {
26
+ SubscriptionEventTypes,
27
+ SubscriptionFoundationHandlers,
28
+ SubscriptionStatuses,
29
+ } from "../constants";
30
+ import { billingFoundationFeature } from "../feature";
31
+ import type { SubscriptionProviderPlugin } from "../types";
32
+
33
+ // =============================================================================
34
+ // Mock-plugin für create-checkout-session + create-portal-session-Tests.
35
+ // **Pattern-Vorbild:** ai-foundation.integration.ts mit zwei inline-mock-
36
+ // plugins. Vermeidet zweiten beforeAll/setupTestStack — selber stack,
37
+ // einfach extra-feature im features-array.
38
+ // =============================================================================
39
+
40
+ const mockCheckoutCalls: Array<{
41
+ priceId: string;
42
+ tenantId: string;
43
+ successUrl: string;
44
+ cancelUrl: string;
45
+ providerCustomerId?: string;
46
+ }> = [];
47
+ const mockPortalCalls: Array<{ providerCustomerId: string; returnUrl: string }> = [];
48
+
49
+ const mockProviderFeature = defineFeature("test-mock-provider", (r) => {
50
+ r.requires("billing-foundation");
51
+ const plugin: SubscriptionProviderPlugin = {
52
+ verifyAndParseWebhook: async () => null,
53
+ createCheckoutSession: async (_ctx, options) => {
54
+ mockCheckoutCalls.push({
55
+ priceId: options.priceId,
56
+ tenantId: options.tenantId,
57
+ successUrl: options.successUrl,
58
+ cancelUrl: options.cancelUrl,
59
+ ...(options.providerCustomerId && { providerCustomerId: options.providerCustomerId }),
60
+ });
61
+ return { url: `https://mock.example/checkout/${options.priceId}` };
62
+ },
63
+ createPortalSession: async (_ctx, options) => {
64
+ mockPortalCalls.push({
65
+ providerCustomerId: options.providerCustomerId,
66
+ returnUrl: options.returnUrl,
67
+ });
68
+ return { url: `https://mock.example/portal/${options.providerCustomerId}` };
69
+ },
70
+ };
71
+ r.useExtension("subscriptionProvider", "mock", plugin);
72
+ });
73
+
74
+ // =============================================================================
75
+ // Setup
76
+ // =============================================================================
77
+
78
+ let stack: TestStack;
79
+ let db: DbConnection;
80
+
81
+ beforeAll(async () => {
82
+ stack = await setupTestStack({
83
+ features: [billingFoundationFeature, mockProviderFeature],
84
+ });
85
+ db = stack.db;
86
+ // subscriptionsProjectionTable wird von setupTestStack automatisch
87
+ // gepusht (r.projection mit `table`-Property → auto-push).
88
+ await createEventsTable(db);
89
+ });
90
+
91
+ afterAll(async () => {
92
+ await stack.cleanup();
93
+ });
94
+
95
+ function adminFor(tenantNumber: number) {
96
+ return createTestUser({
97
+ id: tenantNumber,
98
+ tenantId: testTenantId(tenantNumber),
99
+ roles: ["TenantAdmin", "SystemAdmin"],
100
+ });
101
+ }
102
+
103
+ function buildEvent(
104
+ overrides: Partial<{
105
+ providerEventId: string;
106
+ type: string;
107
+ status: string;
108
+ tier: string;
109
+ providerCustomerId: string;
110
+ providerSubscriptionId: string;
111
+ currentPeriodEndIso: string;
112
+ rawPayload: string;
113
+ }> = {},
114
+ ) {
115
+ return {
116
+ providerEventId: overrides.providerEventId ?? "evt_test_default",
117
+ providerName: "stripe",
118
+ type: overrides.type ?? SubscriptionEventTypes.created,
119
+ providerCustomerId: overrides.providerCustomerId ?? "cus_default",
120
+ providerSubscriptionId: overrides.providerSubscriptionId ?? "sub_default",
121
+ status: overrides.status ?? SubscriptionStatuses.active,
122
+ tier: overrides.tier ?? "pro",
123
+ currentPeriodEndIso: overrides.currentPeriodEndIso ?? "2026-06-01T00:00:00Z",
124
+ rawPayload: overrides.rawPayload ?? '{"raw":"payload"}',
125
+ };
126
+ }
127
+
128
+ // =============================================================================
129
+ // Scenarios
130
+ // =============================================================================
131
+
132
+ describe("scenario 1: webhook-event creates subscription + audit-row", () => {
133
+ test("first event for tenant → subscription-row + subscription-event-row erzeugt", async () => {
134
+ const admin = adminFor(3001);
135
+ const result = (await stack.http.writeOk(
136
+ SubscriptionFoundationHandlers.processEvent,
137
+ buildEvent({
138
+ providerEventId: "evt_3001_create",
139
+ providerCustomerId: "cus_3001",
140
+ providerSubscriptionId: "sub_3001",
141
+ }),
142
+ admin,
143
+ )) as Record<string, unknown>;
144
+
145
+ expect(result["duplicate"]).toBe(false);
146
+ expect(result["subscriptionAggregateId"]).toBe(subscriptionAggregateId(admin.tenantId));
147
+
148
+ // subscription-row sichtbar via list-query
149
+ const subs = (await stack.http.queryOk(
150
+ "billing-foundation:query:subscription:list",
151
+ {},
152
+ admin,
153
+ )) as { rows: Array<Record<string, unknown>> };
154
+ expect(subs.rows).toHaveLength(1);
155
+ expect(subs.rows[0]?.["tier"]).toBe("pro");
156
+ expect(subs.rows[0]?.["providerCustomerId"]).toBe("cus_3001");
157
+
158
+ // ES-event archiviert (= audit lebt im event-store, kein separate
159
+ // subscription-event-Tabelle mehr).
160
+ const esEvents = await loadAggregate(
161
+ db,
162
+ subscriptionAggregateId(admin.tenantId),
163
+ admin.tenantId,
164
+ );
165
+ expect(esEvents).toHaveLength(1);
166
+ expect(esEvents[0]?.type).toBe("billing-foundation:event:subscription-created");
167
+ expect(esEvents[0]?.metadata.headers?.["providerEventId"]).toBe("evt_3001_create");
168
+ });
169
+ });
170
+
171
+ describe("scenario 2: webhook-update upserts subscription, archiviert weiteren event", () => {
172
+ test("zweiter event für selben Tenant → subscription geupdated, beide events in audit", async () => {
173
+ const admin = adminFor(3002);
174
+
175
+ // create
176
+ await stack.http.writeOk(
177
+ SubscriptionFoundationHandlers.processEvent,
178
+ buildEvent({
179
+ providerEventId: "evt_3002_create",
180
+ providerCustomerId: "cus_3002",
181
+ providerSubscriptionId: "sub_3002",
182
+ tier: "pro",
183
+ }),
184
+ admin,
185
+ );
186
+
187
+ // update — same subscription, neuer tier
188
+ await stack.http.writeOk(
189
+ SubscriptionFoundationHandlers.processEvent,
190
+ buildEvent({
191
+ providerEventId: "evt_3002_update",
192
+ type: SubscriptionEventTypes.updated,
193
+ providerCustomerId: "cus_3002",
194
+ providerSubscriptionId: "sub_3002",
195
+ tier: "business", // upgrade
196
+ }),
197
+ admin,
198
+ );
199
+
200
+ const subs = (await stack.http.queryOk(
201
+ "billing-foundation:query:subscription:list",
202
+ {},
203
+ admin,
204
+ )) as { rows: Array<Record<string, unknown>> };
205
+ expect(subs.rows).toHaveLength(1); // immer noch 1 row, geupdated
206
+ expect(subs.rows[0]?.["tier"]).toBe("business");
207
+
208
+ const esEvents = await loadAggregate(
209
+ db,
210
+ subscriptionAggregateId(admin.tenantId),
211
+ admin.tenantId,
212
+ );
213
+ expect(esEvents).toHaveLength(2); // create + update beide im stream
214
+ });
215
+ });
216
+
217
+ describe("scenario 3: idempotency — webhook-retry mit selber providerEventId", () => {
218
+ test("zweiter call mit gleichem providerEventId → duplicate=true, kein zweiter event-row", async () => {
219
+ const admin = adminFor(3003);
220
+
221
+ const first = (await stack.http.writeOk(
222
+ SubscriptionFoundationHandlers.processEvent,
223
+ buildEvent({
224
+ providerEventId: "evt_3003_retry",
225
+ providerCustomerId: "cus_3003",
226
+ providerSubscriptionId: "sub_3003",
227
+ }),
228
+ admin,
229
+ )) as Record<string, unknown>;
230
+ expect(first["duplicate"]).toBe(false);
231
+
232
+ const second = (await stack.http.writeOk(
233
+ SubscriptionFoundationHandlers.processEvent,
234
+ buildEvent({
235
+ providerEventId: "evt_3003_retry",
236
+ providerCustomerId: "cus_3003",
237
+ providerSubscriptionId: "sub_3003",
238
+ tier: "business", // anderer tier — sollte IGNORIERT werden weil duplicate
239
+ }),
240
+ admin,
241
+ )) as Record<string, unknown>;
242
+ expect(second["duplicate"]).toBe(true);
243
+
244
+ // Drift-Pin: subscription bleibt beim ersten tier — der duplicate-
245
+ // event hat den state NICHT überschrieben (wäre data-loss bei
246
+ // out-of-order webhook-retries).
247
+ const subs = (await stack.http.queryOk(
248
+ "billing-foundation:query:subscription:list",
249
+ {},
250
+ admin,
251
+ )) as { rows: Array<Record<string, unknown>> };
252
+ expect(subs.rows[0]?.["tier"]).toBe("pro");
253
+
254
+ const esEvents = await loadAggregate(
255
+ db,
256
+ subscriptionAggregateId(admin.tenantId),
257
+ admin.tenantId,
258
+ );
259
+ expect(esEvents).toHaveLength(1); // dedup'd
260
+ });
261
+ });
262
+
263
+ describe("scenario 4: tenant-isolation", () => {
264
+ test("Tenant A's subscription leakt nicht in die Liste von Tenant B", async () => {
265
+ const adminA = adminFor(3004);
266
+ const adminB = adminFor(3005);
267
+
268
+ await stack.http.writeOk(
269
+ SubscriptionFoundationHandlers.processEvent,
270
+ buildEvent({
271
+ providerEventId: "evt_A",
272
+ providerCustomerId: "cus_A",
273
+ providerSubscriptionId: "sub_A",
274
+ tier: "pro",
275
+ }),
276
+ adminA,
277
+ );
278
+ await stack.http.writeOk(
279
+ SubscriptionFoundationHandlers.processEvent,
280
+ buildEvent({
281
+ providerEventId: "evt_B",
282
+ providerCustomerId: "cus_B",
283
+ providerSubscriptionId: "sub_B",
284
+ tier: "business",
285
+ }),
286
+ adminB,
287
+ );
288
+
289
+ const subsA = (await stack.http.queryOk(
290
+ "billing-foundation:query:subscription:list",
291
+ {},
292
+ adminA,
293
+ )) as { rows: Array<Record<string, unknown>> };
294
+ const subsB = (await stack.http.queryOk(
295
+ "billing-foundation:query:subscription:list",
296
+ {},
297
+ adminB,
298
+ )) as { rows: Array<Record<string, unknown>> };
299
+
300
+ expect(subsA.rows).toHaveLength(1);
301
+ expect(subsA.rows[0]?.["tier"]).toBe("pro");
302
+ expect(subsB.rows).toHaveLength(1);
303
+ expect(subsB.rows[0]?.["tier"]).toBe("business");
304
+ });
305
+
306
+ test("Idempotency-Anker ist tenant-scoped — selber providerEventId für ZWEI Tenants ist NICHT duplicate", async () => {
307
+ // Edge-case: Stripe verteilt eventIds global eindeutig. Aber
308
+ // theoretisch könnte ein App-Owner mehrere Stripe-Accounts haben
309
+ // (z.B. test/prod-Mix in dev) und gleiche eventIds sehen. Unser
310
+ // aggregate-id ist (tenantId, providerName, providerEventId) — also
311
+ // ist eventId+tenant-A unabhängig von eventId+tenant-B.
312
+ const adminA = adminFor(3006);
313
+ const adminB = adminFor(3007);
314
+ const SHARED_EVT = "evt_shared_id";
315
+
316
+ const a = (await stack.http.writeOk(
317
+ SubscriptionFoundationHandlers.processEvent,
318
+ buildEvent({
319
+ providerEventId: SHARED_EVT,
320
+ providerCustomerId: "cus_a_shared",
321
+ providerSubscriptionId: "sub_a_shared",
322
+ }),
323
+ adminA,
324
+ )) as Record<string, unknown>;
325
+ const b = (await stack.http.writeOk(
326
+ SubscriptionFoundationHandlers.processEvent,
327
+ buildEvent({
328
+ providerEventId: SHARED_EVT,
329
+ providerCustomerId: "cus_b_shared",
330
+ providerSubscriptionId: "sub_b_shared",
331
+ }),
332
+ adminB,
333
+ )) as Record<string, unknown>;
334
+
335
+ expect(a["duplicate"]).toBe(false);
336
+ expect(b["duplicate"]).toBe(false); // anderer tenant → kein duplicate
337
+ });
338
+ });
339
+
340
+ describe("scenario 5: Provider-Wechsel mid-period (Disney+-Pattern)", () => {
341
+ test("Tenant switcht von Stripe zu PayPal: subscription-row updated providerName, subscription-event-history zeigt beide", async () => {
342
+ // **Disney+-Use-Case:** Tenant hat Stripe-sub, will umsteigen auf
343
+ // PayPal. Cancel des Stripe-sub + neue subscription via PayPal sind
344
+ // zwei getrennte Webhook-events vom Endkunden-Action ausgelöst.
345
+ // Foundation muss damit umgehen können — eine subscription-row
346
+ // pro Tenant, providerName tracked welcher Provider gerade hält.
347
+ const admin = adminFor(3009);
348
+
349
+ // Stripe-Sub erzeugt
350
+ await stack.http.writeOk(
351
+ SubscriptionFoundationHandlers.processEvent,
352
+ buildEvent({
353
+ providerEventId: "evt_stripe_create",
354
+ providerCustomerId: "cus_stripe",
355
+ providerSubscriptionId: "sub_stripe",
356
+ tier: "pro",
357
+ }),
358
+ admin,
359
+ );
360
+
361
+ let subs = (await stack.http.queryOk(
362
+ "billing-foundation:query:subscription:list",
363
+ {},
364
+ admin,
365
+ )) as { rows: Array<Record<string, unknown>> };
366
+ expect(subs.rows).toHaveLength(1);
367
+ expect(subs.rows[0]?.["providerName"]).toBe("stripe");
368
+
369
+ // Stripe-Cancel + PayPal-Create kommen — PayPal-Plugin liefert
370
+ // SubscriptionEvent mit providerName="paypal".
371
+ const paypalEvent = {
372
+ ...buildEvent({
373
+ providerEventId: "I-PAYPAL-NEW",
374
+ providerCustomerId: "PP-CUST-001",
375
+ providerSubscriptionId: "I-PAYPAL-NEW",
376
+ tier: "pro",
377
+ }),
378
+ providerName: "paypal",
379
+ };
380
+ await stack.http.writeOk(SubscriptionFoundationHandlers.processEvent, paypalEvent, admin);
381
+
382
+ // subscription-row geupdated, providerName ist jetzt "paypal".
383
+ // **Drift-Pin:** Eine subscription-row pro Tenant — der Wechsel
384
+ // überschreibt die alte Provider-Daten, history liegt in
385
+ // subscription-event-rows.
386
+ subs = (await stack.http.queryOk("billing-foundation:query:subscription:list", {}, admin)) as {
387
+ rows: Array<Record<string, unknown>>;
388
+ };
389
+ expect(subs.rows).toHaveLength(1);
390
+ expect(subs.rows[0]?.["providerName"]).toBe("paypal");
391
+ expect(subs.rows[0]?.["providerCustomerId"]).toBe("PP-CUST-001");
392
+ expect(subs.rows[0]?.["providerSubscriptionId"]).toBe("I-PAYPAL-NEW");
393
+
394
+ // History: beide events im subscription-stream archiviert
395
+ const esEvents = await loadAggregate(
396
+ db,
397
+ subscriptionAggregateId(admin.tenantId),
398
+ admin.tenantId,
399
+ );
400
+ expect(esEvents).toHaveLength(2);
401
+ const providerNames = esEvents
402
+ .map((e) => e.metadata.headers?.["providerName"] as string | undefined)
403
+ .filter((p): p is string => p !== undefined)
404
+ .sort();
405
+ expect(providerNames).toEqual(["paypal", "stripe"]);
406
+ });
407
+ });
408
+
409
+ // =============================================================================
410
+ // Scenarios 6+7 — Phase-5.2b write-handlers (create-checkout-session +
411
+ // create-portal-session). Foundation-routing-tests; provider-spezifisches
412
+ // Verhalten (echte Stripe-checkout-URL) wird in subscription-stripe getestet.
413
+ // =============================================================================
414
+
415
+ describe("scenario 6: create-checkout-session — Plugin-routing", () => {
416
+ test("happy-path: valid provider → URL durchgereicht + plugin mit korrekten args aufgerufen", async () => {
417
+ mockCheckoutCalls.length = 0;
418
+ const admin = adminFor(3009);
419
+ const result = (await stack.http.writeOk(
420
+ "billing-foundation:write:create-checkout-session",
421
+ {
422
+ providerName: "mock",
423
+ priceId: "price_pro_test",
424
+ successUrl: "https://example.com/success",
425
+ cancelUrl: "https://example.com/cancel",
426
+ },
427
+ admin,
428
+ )) as Record<string, unknown>;
429
+
430
+ expect(result["url"]).toBe("https://mock.example/checkout/price_pro_test");
431
+ expect(result["providerName"]).toBe("mock");
432
+
433
+ // Drift-pin: foundation-handler reicht alle payload-Felder + die
434
+ // resolved tenantId an den Plugin durch. Wenn jemand silent
435
+ // umbenennt (z.B. successUrl → success_url im handler), würde
436
+ // mockCheckoutCalls die alten Felder vermissen.
437
+ expect(mockCheckoutCalls).toHaveLength(1);
438
+ expect(mockCheckoutCalls[0]).toEqual({
439
+ priceId: "price_pro_test",
440
+ tenantId: admin.tenantId,
441
+ successUrl: "https://example.com/success",
442
+ cancelUrl: "https://example.com/cancel",
443
+ });
444
+ });
445
+
446
+ test("provider not registered → klarer error mit known-list", async () => {
447
+ const admin = adminFor(3010);
448
+ const error = await stack.http.writeErr(
449
+ "billing-foundation:write:create-checkout-session",
450
+ {
451
+ providerName: "non-existent-provider",
452
+ priceId: "price_test",
453
+ successUrl: "https://example.com/success",
454
+ cancelUrl: "https://example.com/cancel",
455
+ },
456
+ admin,
457
+ );
458
+ expect(JSON.stringify(error)).toMatch(/not registered/);
459
+ });
460
+
461
+ test("optional providerCustomerId wird durchgereicht (Plan-Wechsel-Flow)", async () => {
462
+ mockCheckoutCalls.length = 0;
463
+ const admin = adminFor(3012);
464
+ await stack.http.writeOk(
465
+ "billing-foundation:write:create-checkout-session",
466
+ {
467
+ providerName: "mock",
468
+ priceId: "price_business_test",
469
+ successUrl: "https://example.com/s",
470
+ cancelUrl: "https://example.com/c",
471
+ providerCustomerId: "cus_existing_xyz",
472
+ },
473
+ admin,
474
+ );
475
+ expect(mockCheckoutCalls[0]?.providerCustomerId).toBe("cus_existing_xyz");
476
+ });
477
+ });
478
+
479
+ describe("scenario 7: create-portal-session — Plugin-routing", () => {
480
+ test("happy-path: tenant hat subscription → portal-URL durchgereicht", async () => {
481
+ mockPortalCalls.length = 0;
482
+ const admin = adminFor(3013);
483
+
484
+ // Erst: subscription via process-event erzeugen — providerName "mock"
485
+ // damit Foundation-handler den Plugin via lookup findet.
486
+ await stack.http.writeOk(
487
+ SubscriptionFoundationHandlers.processEvent,
488
+ {
489
+ ...buildEvent({
490
+ providerEventId: "evt_3013_create",
491
+ providerCustomerId: "cus_3013",
492
+ providerSubscriptionId: "sub_3013",
493
+ }),
494
+ providerName: "mock",
495
+ },
496
+ admin,
497
+ );
498
+
499
+ const result = (await stack.http.writeOk(
500
+ "billing-foundation:write:create-portal-session",
501
+ { returnUrl: "https://example.com/return" },
502
+ admin,
503
+ )) as Record<string, unknown>;
504
+
505
+ expect(result["url"]).toBe("https://mock.example/portal/cus_3013");
506
+ expect(result["providerName"]).toBe("mock");
507
+
508
+ // Drift-pin: portal-handler liest providerCustomerId AUS DER DB
509
+ // (subscription-row), nicht aus der payload. Wenn ein Refactor das
510
+ // umstellt (= Tenant könnte fremde portal-sessions öffnen), würde
511
+ // mockPortalCalls den falschen customer-id sehen.
512
+ expect(mockPortalCalls).toHaveLength(1);
513
+ expect(mockPortalCalls[0]).toEqual({
514
+ providerCustomerId: "cus_3013",
515
+ returnUrl: "https://example.com/return",
516
+ });
517
+ });
518
+
519
+ test("Tenant ohne subscription → 'no active subscription'-error", async () => {
520
+ const admin = adminFor(3011);
521
+ const error = await stack.http.writeErr(
522
+ "billing-foundation:write:create-portal-session",
523
+ { returnUrl: "https://example.com/return" },
524
+ admin,
525
+ );
526
+ expect(JSON.stringify(error)).toMatch(/no active subscription/);
527
+ });
528
+ });
529
+
530
+ describe("scenario 8: cancel-event setzt status auf canceled, behält subscription-row", () => {
531
+ test("subscription.canceled event flippt status, subscription-row bleibt", async () => {
532
+ const admin = adminFor(3008);
533
+
534
+ await stack.http.writeOk(
535
+ SubscriptionFoundationHandlers.processEvent,
536
+ buildEvent({
537
+ providerEventId: "evt_3008_create",
538
+ providerCustomerId: "cus_3008",
539
+ providerSubscriptionId: "sub_3008",
540
+ status: SubscriptionStatuses.active,
541
+ tier: "pro",
542
+ }),
543
+ admin,
544
+ );
545
+
546
+ await stack.http.writeOk(
547
+ SubscriptionFoundationHandlers.processEvent,
548
+ buildEvent({
549
+ providerEventId: "evt_3008_cancel",
550
+ type: SubscriptionEventTypes.canceled,
551
+ providerCustomerId: "cus_3008",
552
+ providerSubscriptionId: "sub_3008",
553
+ status: SubscriptionStatuses.canceled,
554
+ tier: "free", // downgrade auf free
555
+ }),
556
+ admin,
557
+ );
558
+
559
+ const subs = (await stack.http.queryOk(
560
+ "billing-foundation:query:subscription:list",
561
+ {},
562
+ admin,
563
+ )) as { rows: Array<Record<string, unknown>> };
564
+ expect(subs.rows).toHaveLength(1); // row bleibt für audit-history
565
+ expect(subs.rows[0]?.["status"]).toBe(SubscriptionStatuses.canceled);
566
+ expect(subs.rows[0]?.["tier"]).toBe("free");
567
+ });
568
+ });