@omega.js/backend 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 (993) hide show
  1. package/LICENSE +98 -0
  2. package/README.md +1053 -0
  3. package/bin/omega +2 -0
  4. package/bin/omega-backend +2 -0
  5. package/bin/omg +2 -0
  6. package/cli.js +3 -0
  7. package/dist/cli/command-table.js +268 -0
  8. package/dist/cli/commands/auth.js +259 -0
  9. package/dist/cli/commands/base-command.js +451 -0
  10. package/dist/cli/commands/build.js +27 -0
  11. package/dist/cli/commands/clean.js +13 -0
  12. package/dist/cli/commands/clear.js +11 -0
  13. package/dist/cli/commands/cwd.js +9 -0
  14. package/dist/cli/commands/deploy.js +205 -0
  15. package/dist/cli/commands/emulator-orphans.js +440 -0
  16. package/dist/cli/commands/emulator.js +1829 -0
  17. package/dist/cli/commands/firebase-init.js +101 -0
  18. package/dist/cli/commands/firestore.js +261 -0
  19. package/dist/cli/commands/indexes.js +51 -0
  20. package/dist/cli/commands/install.js +151 -0
  21. package/dist/cli/commands/logs.js +358 -0
  22. package/dist/cli/commands/mcp.js +41 -0
  23. package/dist/cli/commands/migrate-markers.js +176 -0
  24. package/dist/cli/commands/migrate-rules.js +90 -0
  25. package/dist/cli/commands/migrate.js +59 -0
  26. package/dist/cli/commands/serve.js +208 -0
  27. package/dist/cli/commands/setup-tests/base-test.js +174 -0
  28. package/dist/cli/commands/setup-tests/emulator-config.js +109 -0
  29. package/dist/cli/commands/setup-tests/env-runtime-config-deprecated.js +47 -0
  30. package/dist/cli/commands/setup-tests/firebase-admin.js +49 -0
  31. package/dist/cli/commands/setup-tests/firebase-auth.js +26 -0
  32. package/dist/cli/commands/setup-tests/firebase-cli.js +26 -0
  33. package/dist/cli/commands/setup-tests/firebase-functions.js +49 -0
  34. package/dist/cli/commands/setup-tests/firestore-indexes-file.js +72 -0
  35. package/dist/cli/commands/setup-tests/firestore-indexes-in-json.js +20 -0
  36. package/dist/cli/commands/setup-tests/firestore-indexes-required.js +108 -0
  37. package/dist/cli/commands/setup-tests/firestore-indexes-synced.js +236 -0
  38. package/dist/cli/commands/setup-tests/firestore-rules-file.js +115 -0
  39. package/dist/cli/commands/setup-tests/firestore-rules-in-json.js +49 -0
  40. package/dist/cli/commands/setup-tests/functions-package.js +44 -0
  41. package/dist/cli/commands/setup-tests/gcloud-cli.js +26 -0
  42. package/dist/cli/commands/setup-tests/gitignore.js +122 -0
  43. package/dist/cli/commands/setup-tests/helpers/merge-line-files.js +28 -0
  44. package/dist/cli/commands/setup-tests/helpers/required-indexes.js +109 -0
  45. package/dist/cli/commands/setup-tests/helpers/seed-campaigns.js +246 -0
  46. package/dist/cli/commands/setup-tests/helpers.js +72 -0
  47. package/dist/cli/commands/setup-tests/hosting-folder.js +23 -0
  48. package/dist/cli/commands/setup-tests/hosting-rewrites.js +52 -0
  49. package/dist/cli/commands/setup-tests/index.js +111 -0
  50. package/dist/cli/commands/setup-tests/is-firebase-project.js +21 -0
  51. package/dist/cli/commands/setup-tests/java-installed.js +27 -0
  52. package/dist/cli/commands/setup-tests/legacy-tests-cleanup.js +43 -0
  53. package/dist/cli/commands/setup-tests/marketing-campaigns-seeded.js +230 -0
  54. package/dist/cli/commands/setup-tests/node-version.js +60 -0
  55. package/dist/cli/commands/setup-tests/npm-project-scripts.js +42 -0
  56. package/dist/cli/commands/setup-tests/nvmrc-version.js +36 -0
  57. package/dist/cli/commands/setup-tests/omega-backend.js +42 -0
  58. package/dist/cli/commands/setup-tests/omega-config.js +84 -0
  59. package/dist/cli/commands/setup-tests/project-directories.js +34 -0
  60. package/dist/cli/commands/setup-tests/project-id-consistency.js +201 -0
  61. package/dist/cli/commands/setup-tests/public-html-files.js +22 -0
  62. package/dist/cli/commands/setup-tests/realtime-rules-file.js +69 -0
  63. package/dist/cli/commands/setup-tests/realtime-rules-in-json.js +20 -0
  64. package/dist/cli/commands/setup-tests/remoteconfig-template-file.js +32 -0
  65. package/dist/cli/commands/setup-tests/remoteconfig-template-in-json.js +31 -0
  66. package/dist/cli/commands/setup-tests/service-account.js +86 -0
  67. package/dist/cli/commands/setup-tests/storage-lifecycle-policy.js +81 -0
  68. package/dist/cli/commands/setup-tests/storage-rules-file.js +32 -0
  69. package/dist/cli/commands/setup-tests/storage-rules-in-json.js +20 -0
  70. package/dist/cli/commands/stripe.js +14 -0
  71. package/dist/cli/commands/test-lanes/stripe-live.js +440 -0
  72. package/dist/cli/commands/test.js +976 -0
  73. package/dist/cli/commands/update.js +27 -0
  74. package/dist/cli/commands/version.js +10 -0
  75. package/dist/cli/commands/watch.js +196 -0
  76. package/dist/cli/flags.js +13 -0
  77. package/dist/cli/index.js +173 -0
  78. package/dist/cli/run.js +33 -0
  79. package/dist/cli/utils/attach-log-file.js +4 -0
  80. package/dist/cli/utils/compile-rules.js +1155 -0
  81. package/dist/cli/utils/ensure-target.js +314 -0
  82. package/dist/cli/utils/project-type.js +140 -0
  83. package/dist/cli/utils/public-files.js +49 -0
  84. package/dist/cli/utils/safe-install.js +4 -0
  85. package/dist/cli/utils/spawn-shell.js +94 -0
  86. package/dist/cli/utils/stage-functions.js +338 -0
  87. package/dist/cli/utils/stage-local-packages.js +210 -0
  88. package/dist/cli/utils/target-checks.js +161 -0
  89. package/dist/cli/utils/target.js +24 -0
  90. package/dist/cli/utils/ui.js +292 -0
  91. package/dist/defaults/AGENTS.md +125 -0
  92. package/dist/defaults/CHANGELOG.md +15 -0
  93. package/dist/defaults/CLAUDE.md +1 -0
  94. package/dist/defaults/_.gitignore +65 -0
  95. package/dist/defaults/docs/README.md +17 -0
  96. package/dist/defaults/test/README.md +65 -0
  97. package/dist/defaults/test/_init.js +14 -0
  98. package/dist/defaults/test/helpers/connect-trap.js +72 -0
  99. package/dist/defaults/test/unit/registration.test.js +143 -0
  100. package/dist/defaults/test/unit/rules-posture.test.js +101 -0
  101. package/dist/defaults/test/unit/socket-free.test.js +43 -0
  102. package/dist/manager/events/auth/before-create.js +89 -0
  103. package/dist/manager/events/auth/before-signin.js +76 -0
  104. package/dist/manager/events/auth/on-create.js +112 -0
  105. package/dist/manager/events/auth/on-delete.js +141 -0
  106. package/dist/manager/events/auth/utils.js +93 -0
  107. package/dist/manager/events/cron/daily/blog-auto-publisher.js +287 -0
  108. package/dist/manager/events/cron/daily/data-requests.js +57 -0
  109. package/dist/manager/events/cron/daily/expire-paypal-cancellations.js +191 -0
  110. package/dist/manager/events/cron/daily/marketing-prune.js +486 -0
  111. package/dist/manager/events/cron/daily/reset-usage.js +188 -0
  112. package/dist/manager/events/cron/daily/trial-lapse-sweep.js +355 -0
  113. package/dist/manager/events/cron/daily.js +3 -0
  114. package/dist/manager/events/cron/frequent/abandoned-carts.js +204 -0
  115. package/dist/manager/events/cron/frequent/email-queue.js +71 -0
  116. package/dist/manager/events/cron/frequent/marketing-campaigns.js +463 -0
  117. package/dist/manager/events/cron/frequent/retry-failed-webhooks.js +70 -0
  118. package/dist/manager/events/cron/frequent.js +3 -0
  119. package/dist/manager/events/cron/runner.js +64 -0
  120. package/dist/manager/events/firestore/notifications/on-write.js +123 -0
  121. package/dist/manager/events/firestore/payments-disputes/on-write.js +256 -0
  122. package/dist/manager/events/firestore/payments-disputes/providers/stripe.js +218 -0
  123. package/dist/manager/events/firestore/payments-disputes/providers/test.js +231 -0
  124. package/dist/manager/events/firestore/payments-webhooks/analytics.js +687 -0
  125. package/dist/manager/events/firestore/payments-webhooks/on-write.js +1224 -0
  126. package/dist/manager/events/firestore/payments-webhooks/transitions/index.js +395 -0
  127. package/dist/manager/events/firestore/payments-webhooks/transitions/one-time/purchase-completed.js +58 -0
  128. package/dist/manager/events/firestore/payments-webhooks/transitions/one-time/purchase-failed.js +28 -0
  129. package/dist/manager/events/firestore/payments-webhooks/transitions/one-time/purchase-refunded.js +30 -0
  130. package/dist/manager/events/firestore/payments-webhooks/transitions/send-email.js +74 -0
  131. package/dist/manager/events/firestore/payments-webhooks/transitions/subscription/cancellation-removed.js +14 -0
  132. package/dist/manager/events/firestore/payments-webhooks/transitions/subscription/cancellation-requested.js +28 -0
  133. package/dist/manager/events/firestore/payments-webhooks/transitions/subscription/checkout-declined.js +18 -0
  134. package/dist/manager/events/firestore/payments-webhooks/transitions/subscription/new-subscription.js +67 -0
  135. package/dist/manager/events/firestore/payments-webhooks/transitions/subscription/payment-failed.js +25 -0
  136. package/dist/manager/events/firestore/payments-webhooks/transitions/subscription/payment-recovered.js +26 -0
  137. package/dist/manager/events/firestore/payments-webhooks/transitions/subscription/payment-refunded.js +34 -0
  138. package/dist/manager/events/firestore/payments-webhooks/transitions/subscription/plan-changed.js +42 -0
  139. package/dist/manager/events/firestore/payments-webhooks/transitions/subscription/subscription-cancelled.js +33 -0
  140. package/dist/manager/events/firestore/payments-webhooks/transitions/subscription/subscription-winback.js +17 -0
  141. package/dist/manager/functions/_legacy/actions/create-post-handler.js +188 -0
  142. package/dist/manager/functions/_legacy/actions/generate-uuid.js +63 -0
  143. package/dist/manager/functions/_legacy/actions/sign-up-handler.js +205 -0
  144. package/dist/manager/functions/_legacy/admin/create-post.js +207 -0
  145. package/dist/manager/functions/_legacy/admin/firestore-write.js +72 -0
  146. package/dist/manager/functions/_legacy/admin/get-stats.js +218 -0
  147. package/dist/manager/functions/_legacy/admin/query.js +198 -0
  148. package/dist/manager/functions/_legacy/admin/send-notification.js +206 -0
  149. package/dist/manager/functions/_legacy/template.js +33 -0
  150. package/dist/manager/functions/_legacy/test/authenticate.js +40 -0
  151. package/dist/manager/functions/_legacy/test/webhook.js +37 -0
  152. package/dist/manager/functions/wrappers/mailchimp/addToList.js +25 -0
  153. package/dist/manager/helpers/analytics.js +498 -0
  154. package/dist/manager/helpers/api-manager.js +313 -0
  155. package/dist/manager/helpers/backend-router.js +37 -0
  156. package/dist/manager/helpers/context/authenticate.js +215 -0
  157. package/dist/manager/helpers/context/client-info.js +91 -0
  158. package/dist/manager/helpers/context/index.js +228 -0
  159. package/dist/manager/helpers/context/logging.js +150 -0
  160. package/dist/manager/helpers/context/parse.js +160 -0
  161. package/dist/manager/helpers/context/respond.js +247 -0
  162. package/dist/manager/helpers/event-middleware.js +109 -0
  163. package/dist/manager/helpers/metadata.js +32 -0
  164. package/dist/manager/helpers/middleware.js +541 -0
  165. package/dist/manager/helpers/redact-secret.js +25 -0
  166. package/dist/manager/helpers/resolved-config.js +34 -0
  167. package/dist/manager/helpers/roles.js +69 -0
  168. package/dist/manager/helpers/safe-compare.js +31 -0
  169. package/dist/manager/helpers/schema-engine.js +314 -0
  170. package/dist/manager/helpers/schema-zod.js +203 -0
  171. package/dist/manager/helpers/settings.js +215 -0
  172. package/dist/manager/helpers/usage.js +541 -0
  173. package/dist/manager/helpers/user.js +57 -0
  174. package/dist/manager/helpers/utilities.js +565 -0
  175. package/dist/manager/index.js +1404 -0
  176. package/dist/manager/libraries/abandoned-cart-config.js +12 -0
  177. package/dist/manager/libraries/ai/index.js +321 -0
  178. package/dist/manager/libraries/ai/prompt.js +85 -0
  179. package/dist/manager/libraries/ai/providers/anthropic-format.js +297 -0
  180. package/dist/manager/libraries/ai/providers/anthropic.js +169 -0
  181. package/dist/manager/libraries/ai/providers/claude-code.js +183 -0
  182. package/dist/manager/libraries/ai/providers/openai.js +1272 -0
  183. package/dist/manager/libraries/ai/providers/test.js +257 -0
  184. package/dist/manager/libraries/ai/tokens.js +54 -0
  185. package/dist/manager/libraries/analytics/conversions.js +478 -0
  186. package/dist/manager/libraries/analytics/match-data.js +573 -0
  187. package/dist/manager/libraries/analytics/signup.js +120 -0
  188. package/dist/manager/libraries/auth-user.js +46 -0
  189. package/dist/manager/libraries/content/feed-parser.js +198 -0
  190. package/dist/manager/libraries/content/ghostii.js +191 -0
  191. package/dist/manager/libraries/content/source-resolver.js +629 -0
  192. package/dist/manager/libraries/email/constants.js +532 -0
  193. package/dist/manager/libraries/email/data/blocked-local-parts.json +55 -0
  194. package/dist/manager/libraries/email/data/blocked-local-patterns.js +14 -0
  195. package/dist/manager/libraries/email/data/corporate-domains.json +23 -0
  196. package/dist/manager/libraries/email/data/custom-disposable-domains.json +58 -0
  197. package/dist/manager/libraries/email/data/disposable-domains.json +8184 -0
  198. package/dist/manager/libraries/email/data/typo-domains.js +83 -0
  199. package/dist/manager/libraries/email/disposable-domains.js +92 -0
  200. package/dist/manager/libraries/email/generators/lib/filter.js +179 -0
  201. package/dist/manager/libraries/email/generators/lib/image-host.js +344 -0
  202. package/dist/manager/libraries/email/generators/lib/image-illustrator.js +154 -0
  203. package/dist/manager/libraries/email/generators/lib/markdown-renderer.js +294 -0
  204. package/dist/manager/libraries/email/generators/lib/mjml-template.js +120 -0
  205. package/dist/manager/libraries/email/generators/lib/structure.js +299 -0
  206. package/dist/manager/libraries/email/generators/lib/svg-illustrator.js +197 -0
  207. package/dist/manager/libraries/email/generators/lib/templates/base.js +229 -0
  208. package/dist/manager/libraries/email/generators/lib/templates/card.js +33 -0
  209. package/dist/manager/libraries/email/generators/lib/templates/classic-schema.js +59 -0
  210. package/dist/manager/libraries/email/generators/lib/templates/clean.js +82 -0
  211. package/dist/manager/libraries/email/generators/lib/templates/editorial/helpers.js +100 -0
  212. package/dist/manager/libraries/email/generators/lib/templates/editorial/index.js +318 -0
  213. package/dist/manager/libraries/email/generators/lib/templates/feedback.js +101 -0
  214. package/dist/manager/libraries/email/generators/lib/templates/field-report/helpers.js +138 -0
  215. package/dist/manager/libraries/email/generators/lib/templates/field-report/index.js +494 -0
  216. package/dist/manager/libraries/email/generators/lib/templates/index.js +47 -0
  217. package/dist/manager/libraries/email/generators/lib/templates/newsletter-shared.js +610 -0
  218. package/dist/manager/libraries/email/generators/lib/templates/order.js +461 -0
  219. package/dist/manager/libraries/email/generators/lib/templates/plain.js +83 -0
  220. package/dist/manager/libraries/email/generators/lib/templates/shared-campaign.js +64 -0
  221. package/dist/manager/libraries/email/generators/newsletter.js +899 -0
  222. package/dist/manager/libraries/email/index.js +146 -0
  223. package/dist/manager/libraries/email/marketing/index.js +754 -0
  224. package/dist/manager/libraries/email/prepare.js +365 -0
  225. package/dist/manager/libraries/email/providers/beehiiv.js +589 -0
  226. package/dist/manager/libraries/email/providers/sendgrid.js +845 -0
  227. package/dist/manager/libraries/email/transactional/index.js +510 -0
  228. package/dist/manager/libraries/email/utm.js +86 -0
  229. package/dist/manager/libraries/email/validation-provider-neverbounce.js +70 -0
  230. package/dist/manager/libraries/email/validation-provider-zerobounce.js +42 -0
  231. package/dist/manager/libraries/email/validation.js +255 -0
  232. package/dist/manager/libraries/env.js +198 -0
  233. package/dist/manager/libraries/infer-contact.js +101 -0
  234. package/dist/manager/libraries/load-provider.js +65 -0
  235. package/dist/manager/libraries/notification.js +256 -0
  236. package/dist/manager/libraries/openai.js +13 -0
  237. package/dist/manager/libraries/payment/discount-codes.js +204 -0
  238. package/dist/manager/libraries/payment/fetch-failure.js +56 -0
  239. package/dist/manager/libraries/payment/license.js +44 -0
  240. package/dist/manager/libraries/payment/order-id.js +57 -0
  241. package/dist/manager/libraries/payment/provider-errors.js +71 -0
  242. package/dist/manager/libraries/payment/providers/chargebee.js +817 -0
  243. package/dist/manager/libraries/payment/providers/coinbase.js +330 -0
  244. package/dist/manager/libraries/payment/providers/paypal.js +987 -0
  245. package/dist/manager/libraries/payment/providers/stripe.js +755 -0
  246. package/dist/manager/libraries/payment/providers/test.js +250 -0
  247. package/dist/manager/libraries/payment/refund-linkage.js +78 -0
  248. package/dist/manager/libraries/payment/refund-policy.js +113 -0
  249. package/dist/manager/libraries/payment/winback.js +55 -0
  250. package/dist/manager/libraries/prompts/infer-contact.md +78 -0
  251. package/dist/manager/libraries/rate-limits.js +17 -0
  252. package/dist/manager/libraries/recaptcha.js +58 -0
  253. package/dist/manager/libraries/user-doc.js +234 -0
  254. package/dist/manager/routes/admin/backup/post.js +106 -0
  255. package/dist/manager/routes/admin/cron/post.js +37 -0
  256. package/dist/manager/routes/admin/database/get.js +35 -0
  257. package/dist/manager/routes/admin/database/post.js +34 -0
  258. package/dist/manager/routes/admin/email/post.js +74 -0
  259. package/dist/manager/routes/admin/firestore/get.js +35 -0
  260. package/dist/manager/routes/admin/firestore/post.js +56 -0
  261. package/dist/manager/routes/admin/firestore/query/post.js +128 -0
  262. package/dist/manager/routes/admin/hook/post.js +104 -0
  263. package/dist/manager/routes/admin/infer-contact/post.js +35 -0
  264. package/dist/manager/routes/admin/notification/post.js +37 -0
  265. package/dist/manager/routes/admin/payment/post.js +56 -0
  266. package/dist/manager/routes/admin/post/deduplicate-image-alts.js +52 -0
  267. package/dist/manager/routes/admin/post/dispatch-deploy.js +36 -0
  268. package/dist/manager/routes/admin/post/post.js +487 -0
  269. package/dist/manager/routes/admin/post/put.js +146 -0
  270. package/dist/manager/routes/admin/post/templates/post.html +14 -0
  271. package/dist/manager/routes/admin/repo/content/post.js +106 -0
  272. package/dist/manager/routes/admin/stats/get.js +222 -0
  273. package/dist/manager/routes/admin/users/disable/post.js +50 -0
  274. package/dist/manager/routes/admin/users/list/get.js +110 -0
  275. package/dist/manager/routes/admin/users/sync/post.js +119 -0
  276. package/dist/manager/routes/brand/get.js +26 -0
  277. package/dist/manager/routes/content/post/get.js +110 -0
  278. package/dist/manager/routes/general/email/post.js +89 -0
  279. package/dist/manager/routes/general/email/templates/general/download-app-link.js +42 -0
  280. package/dist/manager/routes/general/uuid/post.js +31 -0
  281. package/dist/manager/routes/handler/post/post.js +142 -0
  282. package/dist/manager/routes/health/get.js +43 -0
  283. package/dist/manager/routes/index.js +11 -0
  284. package/dist/manager/routes/marketing/campaign/delete.js +45 -0
  285. package/dist/manager/routes/marketing/campaign/get.js +69 -0
  286. package/dist/manager/routes/marketing/campaign/post.js +124 -0
  287. package/dist/manager/routes/marketing/campaign/put.js +86 -0
  288. package/dist/manager/routes/marketing/campaign/utils.js +59 -0
  289. package/dist/manager/routes/marketing/contact/delete.js +100 -0
  290. package/dist/manager/routes/marketing/contact/post.js +149 -0
  291. package/dist/manager/routes/marketing/contact/put.js +37 -0
  292. package/dist/manager/routes/marketing/email-preferences/post.js +275 -0
  293. package/dist/manager/routes/marketing/webhook/forward/post.js +170 -0
  294. package/dist/manager/routes/marketing/webhook/post.js +123 -0
  295. package/dist/manager/routes/marketing/webhook/providers/beehiiv.js +197 -0
  296. package/dist/manager/routes/marketing/webhook/providers/sendgrid.js +194 -0
  297. package/dist/manager/routes/payments/cancel/_is-trialing.js +40 -0
  298. package/dist/manager/routes/payments/cancel/post.js +149 -0
  299. package/dist/manager/routes/payments/cancel/providers/chargebee.js +41 -0
  300. package/dist/manager/routes/payments/cancel/providers/paypal.js +56 -0
  301. package/dist/manager/routes/payments/cancel/providers/stripe.js +33 -0
  302. package/dist/manager/routes/payments/cancel/providers/test.js +118 -0
  303. package/dist/manager/routes/payments/discount/get.js +25 -0
  304. package/dist/manager/routes/payments/dispute-alert/post.js +117 -0
  305. package/dist/manager/routes/payments/dispute-alert/providers/chargeblast.js +55 -0
  306. package/dist/manager/routes/payments/intent/post.js +341 -0
  307. package/dist/manager/routes/payments/intent/providers/chargebee.js +197 -0
  308. package/dist/manager/routes/payments/intent/providers/coinbase.js +94 -0
  309. package/dist/manager/routes/payments/intent/providers/paypal.js +244 -0
  310. package/dist/manager/routes/payments/intent/providers/stripe.js +141 -0
  311. package/dist/manager/routes/payments/intent/providers/test.js +341 -0
  312. package/dist/manager/routes/payments/plan/post.js +147 -0
  313. package/dist/manager/routes/payments/plan/providers/chargebee.js +62 -0
  314. package/dist/manager/routes/payments/plan/providers/paypal.js +62 -0
  315. package/dist/manager/routes/payments/plan/providers/stripe.js +59 -0
  316. package/dist/manager/routes/payments/plan/providers/test.js +113 -0
  317. package/dist/manager/routes/payments/portal/post.js +113 -0
  318. package/dist/manager/routes/payments/portal/providers/chargebee.js +62 -0
  319. package/dist/manager/routes/payments/portal/providers/paypal.js +25 -0
  320. package/dist/manager/routes/payments/portal/providers/stripe.js +62 -0
  321. package/dist/manager/routes/payments/portal/providers/test.js +18 -0
  322. package/dist/manager/routes/payments/refund/post.js +207 -0
  323. package/dist/manager/routes/payments/refund/providers/chargebee.js +147 -0
  324. package/dist/manager/routes/payments/refund/providers/coinbase.js +40 -0
  325. package/dist/manager/routes/payments/refund/providers/paypal.js +239 -0
  326. package/dist/manager/routes/payments/refund/providers/stripe.js +160 -0
  327. package/dist/manager/routes/payments/refund/providers/test.js +204 -0
  328. package/dist/manager/routes/payments/trial-eligibility/get.js +29 -0
  329. package/dist/manager/routes/payments/uncancel/post.js +106 -0
  330. package/dist/manager/routes/payments/uncancel/providers/chargebee.js +30 -0
  331. package/dist/manager/routes/payments/uncancel/providers/paypal.js +25 -0
  332. package/dist/manager/routes/payments/uncancel/providers/stripe.js +27 -0
  333. package/dist/manager/routes/payments/uncancel/providers/test.js +114 -0
  334. package/dist/manager/routes/payments/webhook/post.js +157 -0
  335. package/dist/manager/routes/payments/webhook/providers/chargebee.js +218 -0
  336. package/dist/manager/routes/payments/webhook/providers/coinbase.js +91 -0
  337. package/dist/manager/routes/payments/webhook/providers/paypal.js +214 -0
  338. package/dist/manager/routes/payments/webhook/providers/stripe.js +182 -0
  339. package/dist/manager/routes/payments/webhook/providers/test.js +18 -0
  340. package/dist/manager/routes/payments/winback/post.js +276 -0
  341. package/dist/manager/routes/payments/winback/providers/chargebee.js +24 -0
  342. package/dist/manager/routes/payments/winback/providers/paypal.js +22 -0
  343. package/dist/manager/routes/payments/winback/providers/stripe.js +41 -0
  344. package/dist/manager/routes/payments/winback/providers/test.js +42 -0
  345. package/dist/manager/routes/restart/index.js +37 -0
  346. package/dist/manager/routes/special/electron-client/post.js +59 -0
  347. package/dist/manager/routes/test/authenticate/get.js +11 -0
  348. package/dist/manager/routes/test/health/get.js +8 -0
  349. package/dist/manager/routes/test/index.js +16 -0
  350. package/dist/manager/routes/test/lab/post.js +17 -0
  351. package/dist/manager/routes/test/redirect/get.js +27 -0
  352. package/dist/manager/routes/test/reset-account/post.js +79 -0
  353. package/dist/manager/routes/test/roster/get.js +40 -0
  354. package/dist/manager/routes/test/schema/post.js +20 -0
  355. package/dist/manager/routes/test/usage/post.js +44 -0
  356. package/dist/manager/routes/test/webhook/post.js +21 -0
  357. package/dist/manager/routes/user/api-keys/post.js +51 -0
  358. package/dist/manager/routes/user/connections/_context.js +194 -0
  359. package/dist/manager/routes/user/connections/_grant.js +247 -0
  360. package/dist/manager/routes/user/connections/_lease.js +184 -0
  361. package/dist/manager/routes/user/connections/_providers.js +191 -0
  362. package/dist/manager/routes/user/connections/_state.js +117 -0
  363. package/dist/manager/routes/user/connections/delete.js +55 -0
  364. package/dist/manager/routes/user/connections/get.js +167 -0
  365. package/dist/manager/routes/user/connections/post.js +370 -0
  366. package/dist/manager/routes/user/connections/providers/discord.js +41 -0
  367. package/dist/manager/routes/user/connections/providers/google.js +55 -0
  368. package/dist/manager/routes/user/connections/providers/kick.js +68 -0
  369. package/dist/manager/routes/user/connections/providers/spotify.js +40 -0
  370. package/dist/manager/routes/user/connections/providers/twitch.js +52 -0
  371. package/dist/manager/routes/user/data-request/delete.js +84 -0
  372. package/dist/manager/routes/user/data-request/get.js +229 -0
  373. package/dist/manager/routes/user/data-request/post.js +136 -0
  374. package/dist/manager/routes/user/delete.js +149 -0
  375. package/dist/manager/routes/user/feedback/post.js +81 -0
  376. package/dist/manager/routes/user/get.js +14 -0
  377. package/dist/manager/routes/user/orders/get.js +95 -0
  378. package/dist/manager/routes/user/sessions/delete.js +95 -0
  379. package/dist/manager/routes/user/sessions/get.js +40 -0
  380. package/dist/manager/routes/user/settings/validate/post.js +86 -0
  381. package/dist/manager/routes/user/signup/post.js +662 -0
  382. package/dist/manager/routes/user/subscription/get.js +77 -0
  383. package/dist/manager/routes/user/token/post.js +30 -0
  384. package/dist/manager/routes/verts/delete.js +43 -0
  385. package/dist/manager/routes/verts/get.js +49 -0
  386. package/dist/manager/routes/verts/post.js +61 -0
  387. package/dist/manager/routes/verts/put.js +75 -0
  388. package/dist/manager/routes/verts/redirect/get.js +36 -0
  389. package/dist/manager/routes/verts/serve/get.js +67 -0
  390. package/dist/manager/routes/verts/utils.js +436 -0
  391. package/dist/manager/schemas/admin/backup/post.js +8 -0
  392. package/dist/manager/schemas/admin/cron/post.js +8 -0
  393. package/dist/manager/schemas/admin/database/get.js +5 -0
  394. package/dist/manager/schemas/admin/database/post.js +6 -0
  395. package/dist/manager/schemas/admin/email/post.js +31 -0
  396. package/dist/manager/schemas/admin/firestore/get.js +5 -0
  397. package/dist/manager/schemas/admin/firestore/post.js +8 -0
  398. package/dist/manager/schemas/admin/firestore/query/post.js +5 -0
  399. package/dist/manager/schemas/admin/hook/post.js +8 -0
  400. package/dist/manager/schemas/admin/infer-contact/post.js +6 -0
  401. package/dist/manager/schemas/admin/notification/post.js +23 -0
  402. package/dist/manager/schemas/admin/payment/post.js +8 -0
  403. package/dist/manager/schemas/admin/post/post.js +23 -0
  404. package/dist/manager/schemas/admin/post/put.js +13 -0
  405. package/dist/manager/schemas/admin/repo/content/post.js +10 -0
  406. package/dist/manager/schemas/admin/stats/get.js +5 -0
  407. package/dist/manager/schemas/admin/users/disable/post.js +9 -0
  408. package/dist/manager/schemas/admin/users/list/get.js +10 -0
  409. package/dist/manager/schemas/admin/users/sync/post.js +7 -0
  410. package/dist/manager/schemas/brand/get.js +3 -0
  411. package/dist/manager/schemas/content/post/get.js +8 -0
  412. package/dist/manager/schemas/general/email/post.js +10 -0
  413. package/dist/manager/schemas/general/uuid/post.js +9 -0
  414. package/dist/manager/schemas/handler/post/post.js +13 -0
  415. package/dist/manager/schemas/health/get.js +3 -0
  416. package/dist/manager/schemas/marketing/campaign/delete.js +8 -0
  417. package/dist/manager/schemas/marketing/campaign/get.js +13 -0
  418. package/dist/manager/schemas/marketing/campaign/post.js +43 -0
  419. package/dist/manager/schemas/marketing/campaign/put.js +37 -0
  420. package/dist/manager/schemas/marketing/contact/delete.js +8 -0
  421. package/dist/manager/schemas/marketing/contact/post.js +14 -0
  422. package/dist/manager/schemas/marketing/contact/put.js +8 -0
  423. package/dist/manager/schemas/marketing/email-preferences/post.js +15 -0
  424. package/dist/manager/schemas/marketing/webhook/forward/post.js +8 -0
  425. package/dist/manager/schemas/marketing/webhook/post.js +7 -0
  426. package/dist/manager/schemas/payments/cancel/post.js +15 -0
  427. package/dist/manager/schemas/payments/discount/get.js +8 -0
  428. package/dist/manager/schemas/payments/dispute-alert/post.js +6 -0
  429. package/dist/manager/schemas/payments/intent/post.js +28 -0
  430. package/dist/manager/schemas/payments/plan/post.js +11 -0
  431. package/dist/manager/schemas/payments/portal/post.js +12 -0
  432. package/dist/manager/schemas/payments/refund/post.js +14 -0
  433. package/dist/manager/schemas/payments/trial-eligibility/get.js +7 -0
  434. package/dist/manager/schemas/payments/uncancel/post.js +9 -0
  435. package/dist/manager/schemas/payments/webhook/post.js +6 -0
  436. package/dist/manager/schemas/payments/winback/post.js +12 -0
  437. package/dist/manager/schemas/restart/index.js +5 -0
  438. package/dist/manager/schemas/special/electron-client/post.js +11 -0
  439. package/dist/manager/schemas/test/authenticate/get.js +3 -0
  440. package/dist/manager/schemas/test/health/get.js +3 -0
  441. package/dist/manager/schemas/test/index.js +5 -0
  442. package/dist/manager/schemas/test/lab/post.js +3 -0
  443. package/dist/manager/schemas/test/redirect/get.js +7 -0
  444. package/dist/manager/schemas/test/reset-account/post.js +3 -0
  445. package/dist/manager/schemas/test/roster/get.js +3 -0
  446. package/dist/manager/schemas/test/schema/post.js +70 -0
  447. package/dist/manager/schemas/test/usage/post.js +8 -0
  448. package/dist/manager/schemas/test/webhook/post.js +7 -0
  449. package/dist/manager/schemas/user/api-keys/post.js +6 -0
  450. package/dist/manager/schemas/user/connections/delete.js +8 -0
  451. package/dist/manager/schemas/user/connections/get.js +21 -0
  452. package/dist/manager/schemas/user/connections/post.js +12 -0
  453. package/dist/manager/schemas/user/data-request/delete.js +3 -0
  454. package/dist/manager/schemas/user/data-request/get.js +5 -0
  455. package/dist/manager/schemas/user/data-request/post.js +6 -0
  456. package/dist/manager/schemas/user/delete.js +6 -0
  457. package/dist/manager/schemas/user/feedback/post.js +8 -0
  458. package/dist/manager/schemas/user/get.js +3 -0
  459. package/dist/manager/schemas/user/orders/get.js +6 -0
  460. package/dist/manager/schemas/user/sessions/delete.js +6 -0
  461. package/dist/manager/schemas/user/sessions/get.js +6 -0
  462. package/dist/manager/schemas/user/settings/validate/post.js +8 -0
  463. package/dist/manager/schemas/user/signup/post.js +27 -0
  464. package/dist/manager/schemas/user/subscription/get.js +5 -0
  465. package/dist/manager/schemas/user/token/post.js +5 -0
  466. package/dist/manager/schemas/verts/delete.js +8 -0
  467. package/dist/manager/schemas/verts/get.js +9 -0
  468. package/dist/manager/schemas/verts/post.js +23 -0
  469. package/dist/manager/schemas/verts/put.js +20 -0
  470. package/dist/manager/schemas/verts/redirect/get.js +10 -0
  471. package/dist/manager/schemas/verts/serve/get.js +14 -0
  472. package/dist/manager/server-manager.js +69 -0
  473. package/dist/mcp/client.js +98 -0
  474. package/dist/mcp/handler.js +521 -0
  475. package/dist/mcp/index.js +157 -0
  476. package/dist/mcp/tools.js +555 -0
  477. package/dist/mcp/utils.js +108 -0
  478. package/dist/omega-bin.js +6 -0
  479. package/dist/require.js +3 -0
  480. package/dist/test/fixtures/firebase-project/.firebaserc +5 -0
  481. package/dist/test/fixtures/firebase-project/config/omega.json5 +131 -0
  482. package/dist/test/fixtures/firebase-project/database.rules.json +6 -0
  483. package/dist/test/fixtures/firebase-project/firebase.json +49 -0
  484. package/dist/test/fixtures/firebase-project/firestore.indexes.json +4 -0
  485. package/dist/test/fixtures/firebase-project/package.json +14 -0
  486. package/dist/test/fixtures/firebase-project/src/index.js +11 -0
  487. package/dist/test/fixtures/firebase-project/storage.rules +8 -0
  488. package/dist/test/parse-audit.js +25 -0
  489. package/dist/test/run-tests.js +90 -0
  490. package/dist/test/runner.js +982 -0
  491. package/dist/test/seed.js +242 -0
  492. package/dist/test/test-accounts.js +2660 -0
  493. package/dist/test/utils/assertions.js +189 -0
  494. package/dist/test/utils/email-capture.js +252 -0
  495. package/dist/test/utils/extended-mode-warning.js +11 -0
  496. package/dist/test/utils/firestore-rules-client.js +277 -0
  497. package/dist/test/utils/http-client.js +228 -0
  498. package/dist/test/utils/test-mode-file.js +192 -0
  499. package/dist/utils/merge-line-files.js +6 -0
  500. package/dist/utils/scaffold-defaults.js +71 -0
  501. package/dist/utils/test-lanes.js +62 -0
  502. package/dist/vendor/account/engine.js +182 -0
  503. package/dist/vendor/account/features.js +220 -0
  504. package/dist/vendor/account/index.js +53 -0
  505. package/dist/vendor/account/schema.js +272 -0
  506. package/dist/vendor/account/subscription.js +38 -0
  507. package/dist/vendor/analytics/adapters/ga4.js +26 -0
  508. package/dist/vendor/analytics/adapters/meta.js +26 -0
  509. package/dist/vendor/analytics/adapters/resolve.js +130 -0
  510. package/dist/vendor/analytics/adapters/tiktok.js +27 -0
  511. package/dist/vendor/analytics/catalog.js +908 -0
  512. package/dist/vendor/analytics/identity.js +136 -0
  513. package/dist/vendor/config/company.js +31 -0
  514. package/dist/vendor/config/defaults.js +173 -0
  515. package/dist/vendor/config/demo.js +18 -0
  516. package/dist/vendor/config/desktop-artifacts.js +110 -0
  517. package/dist/vendor/config/edit.js +769 -0
  518. package/dist/vendor/config/env-delivery.js +145 -0
  519. package/dist/vendor/config/env-rules.js +93 -0
  520. package/dist/vendor/config/env-schema.js +1078 -0
  521. package/dist/vendor/config/env.js +445 -0
  522. package/dist/vendor/config/hooks.js +97 -0
  523. package/dist/vendor/config/index.js +237 -0
  524. package/dist/vendor/config/instances.js +208 -0
  525. package/dist/vendor/config/load.js +490 -0
  526. package/dist/vendor/config/merge.js +68 -0
  527. package/dist/vendor/config/order.js +139 -0
  528. package/dist/vendor/config/ports.js +374 -0
  529. package/dist/vendor/config/providers.js +32 -0
  530. package/dist/vendor/config/repo.js +142 -0
  531. package/dist/vendor/config/retired-keys.js +430 -0
  532. package/dist/vendor/config/schema.js +1610 -0
  533. package/dist/vendor/config/secrets.js +50 -0
  534. package/dist/vendor/config/seed.js +34 -0
  535. package/dist/vendor/config/site-global.js +205 -0
  536. package/dist/vendor/config/validate.js +554 -0
  537. package/dist/vendor/config/winback.js +61 -0
  538. package/dist/vendor/devkit/attach-log-file.js +262 -0
  539. package/dist/vendor/devkit/bare-requires.js +153 -0
  540. package/dist/vendor/devkit/command-path.js +46 -0
  541. package/dist/vendor/devkit/defaults-engine.js +372 -0
  542. package/dist/vendor/devkit/deploy-record.js +180 -0
  543. package/dist/vendor/devkit/deploy.js +286 -0
  544. package/dist/vendor/devkit/license.js +155 -0
  545. package/dist/vendor/devkit/local-https.js +360 -0
  546. package/dist/vendor/devkit/local.js +1905 -0
  547. package/dist/vendor/devkit/logger.js +128 -0
  548. package/dist/vendor/devkit/merge-line-files.js +296 -0
  549. package/dist/vendor/devkit/npm-registry.js +52 -0
  550. package/dist/vendor/devkit/omega-bin.js +345 -0
  551. package/dist/vendor/devkit/parse-audit.js +74 -0
  552. package/dist/vendor/devkit/safe-install.js +18 -0
  553. package/dist/vendor/devkit/scaffold-guard.js +96 -0
  554. package/dist/vendor/devkit/stop-signals.js +28 -0
  555. package/dist/vendor/devkit/test/assert.js +120 -0
  556. package/dist/vendor/devkit/test/define-cases.js +104 -0
  557. package/dist/vendor/devkit/test/runner-core.js +554 -0
  558. package/dist/vendor/devkit/test/scope.js +162 -0
  559. package/dist/vendor/devkit/update.js +569 -0
  560. package/dist/vendor/monitoring/core.js +180 -0
  561. package/dist/vendor/monitoring/env.js +49 -0
  562. package/dist/vendor/monitoring/logger.js +39 -0
  563. package/dist/vendor/monitoring/node.js +72 -0
  564. package/docs/admin-post-route.md +57 -0
  565. package/docs/ai-library.md +171 -0
  566. package/docs/architecture.md +62 -0
  567. package/docs/audit.md +70 -0
  568. package/docs/auth-hooks.md +74 -0
  569. package/docs/build-system.md +65 -0
  570. package/docs/cdp-debugging.md +29 -0
  571. package/docs/cli-firestore-auth.md +85 -0
  572. package/docs/cli-logs.md +67 -0
  573. package/docs/cli-output.md +146 -0
  574. package/docs/code-patterns.md +77 -0
  575. package/docs/common-mistakes.md +12 -0
  576. package/docs/common-operations.md +60 -0
  577. package/docs/connections.md +212 -0
  578. package/docs/consent.md +362 -0
  579. package/docs/directory-structure.md +150 -0
  580. package/docs/email-system.md +459 -0
  581. package/docs/environment-detection.md +93 -0
  582. package/docs/file-naming.md +10 -0
  583. package/docs/firestore.md +134 -0
  584. package/docs/ghostii.md +240 -0
  585. package/docs/index.md +299 -0
  586. package/docs/key-files.md +37 -0
  587. package/docs/logging.md +60 -0
  588. package/docs/marketing-campaigns.md +407 -0
  589. package/docs/marketing-fields.md +25 -0
  590. package/docs/mcp.md +222 -0
  591. package/docs/migration.md +129 -0
  592. package/docs/payment-system.md +856 -0
  593. package/docs/paypal-sandbox-qa.md +105 -0
  594. package/docs/response-headers.md +9 -0
  595. package/docs/routes.md +211 -0
  596. package/docs/sanitization.md +71 -0
  597. package/docs/schemas.md +163 -0
  598. package/docs/shared/agent-docs.md +89 -0
  599. package/docs/shared/analytics.md +612 -0
  600. package/docs/shared/brands.md +51 -0
  601. package/docs/shared/breaking-changes.md +497 -0
  602. package/docs/shared/config.md +1387 -0
  603. package/docs/shared/deploys.md +215 -0
  604. package/docs/shared/icons.md +201 -0
  605. package/docs/shared/local-dev.md +147 -0
  606. package/docs/shared/logging.md +202 -0
  607. package/docs/shared/monitoring.md +153 -0
  608. package/docs/shared/publishing.md +183 -0
  609. package/docs/shared/rulings.md +34 -0
  610. package/docs/shared/testing.md +147 -0
  611. package/docs/shared/theming.md +604 -0
  612. package/docs/shared/translation.md +291 -0
  613. package/docs/shared/updates.md +61 -0
  614. package/docs/stripe-webhook-forwarding.md +20 -0
  615. package/docs/test-boot-layer.md +67 -0
  616. package/docs/test-framework.md +583 -0
  617. package/docs/usage-rate-limiting.md +121 -0
  618. package/docs/verts.md +29 -0
  619. package/package.json +143 -0
  620. package/templates/config/omega.json5 +322 -0
  621. package/templates/database.rules.json +82 -0
  622. package/templates/firebase.json +67 -0
  623. package/templates/firestore.framework.rules +191 -0
  624. package/templates/firestore.indexes.json +4 -0
  625. package/templates/firestore.rules +59 -0
  626. package/templates/index.js +12 -0
  627. package/templates/public/404.html +26 -0
  628. package/templates/public/index.html +24 -0
  629. package/templates/remoteconfig.template.json +1 -0
  630. package/templates/storage-lifecycle-config-1-day.json +9 -0
  631. package/templates/storage-lifecycle-config-30-days.json +9 -0
  632. package/templates/storage.rules +11 -0
  633. package/test/_init/accounts-validation.js +58 -0
  634. package/test/ai/tools-live.test.js +171 -0
  635. package/test/analytics/conversion-delivery.test.js +1013 -0
  636. package/test/analytics/match-normalization.test.js +238 -0
  637. package/test/analytics/signup-conversion.test.js +135 -0
  638. package/test/boot/cli-dispatch.test.js +142 -0
  639. package/test/boot/defaults-scaffold.test.js +248 -0
  640. package/test/boot/deploy-staging.test.js +446 -0
  641. package/test/boot/disposable-domains.test.js +154 -0
  642. package/test/boot/emulator-boots.test.js +39 -0
  643. package/test/boot/emulator-port-preflight.test.js +126 -0
  644. package/test/boot/emulator-ready-timeout.test.js +46 -0
  645. package/test/boot/project-id-refresh.test.js +73 -0
  646. package/test/boot/suite-portability.test.js +278 -0
  647. package/test/boot/update-command.test.js +63 -0
  648. package/test/cli/cross-platform.test.js +168 -0
  649. package/test/cli/custom-project-type.test.js +426 -0
  650. package/test/cli/deploy-license-env.test.js +100 -0
  651. package/test/cli/emulator-adoption.test.js +254 -0
  652. package/test/cli/emulator-orphans.test.js +658 -0
  653. package/test/cli/emulator-ownership.test.js +177 -0
  654. package/test/cli/emulator-port-retry.test.js +395 -0
  655. package/test/cli/emulator-shutdown.test.js +788 -0
  656. package/test/cli/emulator-stale-reap.test.js +574 -0
  657. package/test/cli/ensure-target.test.js +325 -0
  658. package/test/cli/flags.test.js +64 -0
  659. package/test/cli/https-trust.test.js +38 -0
  660. package/test/cli/install-update-dispatch.test.js +47 -0
  661. package/test/cli/lane-environments.test.js +159 -0
  662. package/test/cli/marketing-campaigns-seeded.test.js +194 -0
  663. package/test/cli/migrate-bare-requires.test.js +128 -0
  664. package/test/cli/migrate-markers.test.js +530 -0
  665. package/test/cli/required-indexes.test.js +64 -0
  666. package/test/cli/rules-compile.test.js +622 -0
  667. package/test/cli/rules-migration-deferral.test.js +259 -0
  668. package/test/cli/rules-version.test.js +104 -0
  669. package/test/cli/setup-load-files.test.js +66 -0
  670. package/test/cli/setup-offline-mode.test.js +276 -0
  671. package/test/cli/setup-retired.test.js +64 -0
  672. package/test/cli/setup-shared-project-indexes.test.js +262 -0
  673. package/test/cli/setup-tests-requires.test.js +45 -0
  674. package/test/cli/stage-env-compose.test.js +316 -0
  675. package/test/cli/stripe-live-lane.test.js +303 -0
  676. package/test/cli/target.test.js +182 -0
  677. package/test/cli/templates.test.js +66 -0
  678. package/test/cli/test-runner-env.test.js +115 -0
  679. package/test/cli/test-stack-shutdown.test.js +275 -0
  680. package/test/cli/test-target-no-match.test.js +128 -0
  681. package/test/cli/verb-logs.test.js +118 -0
  682. package/test/cli/version-dispatch.test.js +39 -0
  683. package/test/content/blog-generate.test.js +164 -0
  684. package/test/email/campaign-config-fault.test.js +376 -0
  685. package/test/email/campaign-cron-pipeline.test.js +536 -0
  686. package/test/email/campaign-send.test.js +47 -0
  687. package/test/email/consent-lifecycle.test.js +258 -0
  688. package/test/email/content-html-policy.test.js +270 -0
  689. package/test/email/feedback-and-plain-send.test.js +54 -0
  690. package/test/email/fixtures/clean.json +30 -0
  691. package/test/email/fixtures/editorial.json +30 -0
  692. package/test/email/fixtures/field-report.json +53 -0
  693. package/test/email/identity.test.js +321 -0
  694. package/test/email/marketing/consent-gate.test.js +265 -0
  695. package/test/email/marketing/custom-fields-catalog.test.js +259 -0
  696. package/test/email/marketing/prune-per-provider.test.js +762 -0
  697. package/test/email/marketing/remove-log-privacy.test.js +225 -0
  698. package/test/email/marketing-lifecycle.test.js +139 -0
  699. package/test/email/newsletter-generate.test.js +853 -0
  700. package/test/email/newsletter-svg-tokens.test.js +51 -0
  701. package/test/email/newsletter-templates.test.js +492 -0
  702. package/test/email/order-one-time-cta.test.js +97 -0
  703. package/test/email/render-content.test.js +248 -0
  704. package/test/email/safe-url.test.js +0 -0
  705. package/test/email/sanitize-images.test.js +66 -0
  706. package/test/email/send-log-privacy.test.js +107 -0
  707. package/test/email/templates.test.js +288 -0
  708. package/test/email/testing-capture.test.js +260 -0
  709. package/test/email/transactional-send.test.js +36 -0
  710. package/test/email/transactional.test.js +562 -0
  711. package/test/email/unsubscribe-groups.test.js +155 -0
  712. package/test/email/unsubscribe-key.test.js +91 -0
  713. package/test/email/validation-cases.test.js +152 -0
  714. package/test/email/validation.test.js +746 -0
  715. package/test/events/auth-delete-conversion.test.js +138 -0
  716. package/test/events/auth-delete-race.test.js +211 -0
  717. package/test/events/auth-on-create-log.test.js +164 -0
  718. package/test/events/auth-signup-conversion.test.js +141 -0
  719. package/test/events/auth-signup-limit.test.js +142 -0
  720. package/test/events/auth-trigger-log-privacy.test.js +256 -0
  721. package/test/events/cron-job-doc-shape.test.js +101 -0
  722. package/test/events/cron-reset-usage.test.js +116 -0
  723. package/test/events/notification-conversion.test.js +182 -0
  724. package/test/events/payments/_webhook-harness.js +307 -0
  725. package/test/events/payments/abandoned-cart-activity.test.js +135 -0
  726. package/test/events/payments/analytics-payment-events.test.js +945 -0
  727. package/test/events/payments/dispute-email-status.test.js +55 -0
  728. package/test/events/payments/journey-payments-abandoned.test.js +126 -0
  729. package/test/events/payments/journey-payments-cancel-endpoint.test.js +102 -0
  730. package/test/events/payments/journey-payments-cancel-no-order.test.js +76 -0
  731. package/test/events/payments/journey-payments-cancel.test.js +184 -0
  732. package/test/events/payments/journey-payments-decline.test.js +225 -0
  733. package/test/events/payments/journey-payments-discount.test.js +91 -0
  734. package/test/events/payments/journey-payments-dispute.test.js +190 -0
  735. package/test/events/payments/journey-payments-failure.test.js +148 -0
  736. package/test/events/payments/journey-payments-legacy-product.test.js +151 -0
  737. package/test/events/payments/journey-payments-one-time-decline.test.js +127 -0
  738. package/test/events/payments/journey-payments-one-time-failure.test.js +113 -0
  739. package/test/events/payments/journey-payments-one-time-refund.test.js +186 -0
  740. package/test/events/payments/journey-payments-one-time.test.js +175 -0
  741. package/test/events/payments/journey-payments-plan-change.test.js +146 -0
  742. package/test/events/payments/journey-payments-plan-switch-trial.test.js +161 -0
  743. package/test/events/payments/journey-payments-plan-switch.test.js +182 -0
  744. package/test/events/payments/journey-payments-refund-no-order.test.js +76 -0
  745. package/test/events/payments/journey-payments-refund-webhook.test.js +225 -0
  746. package/test/events/payments/journey-payments-suspend.test.js +183 -0
  747. package/test/events/payments/journey-payments-trial-cancel.test.js +132 -0
  748. package/test/events/payments/journey-payments-trial.test.js +173 -0
  749. package/test/events/payments/journey-payments-uid-resolution.test.js +134 -0
  750. package/test/events/payments/journey-payments-uncancel.test.js +157 -0
  751. package/test/events/payments/journey-payments-upgrade.test.js +137 -0
  752. package/test/events/payments/journey-payments-winback-decline.test.js +171 -0
  753. package/test/events/payments/journey-payments-winback.test.js +195 -0
  754. package/test/events/payments/paypal-expiry-cron.test.js +166 -0
  755. package/test/events/payments/purchase-failed-handler.test.js +80 -0
  756. package/test/events/payments/purchase-refunded-handler.test.js +108 -0
  757. package/test/events/payments/test-processor-doc-shape.test.js +162 -0
  758. package/test/events/payments/transition-order-emails.test.js +185 -0
  759. package/test/events/payments/transition-promo-lines.test.js +192 -0
  760. package/test/events/payments/transitions-detect.test.js +767 -0
  761. package/test/events/payments/trial-lapse-sweep-staleness.test.js +173 -0
  762. package/test/events/payments/trial-lapse-sweep.test.js +359 -0
  763. package/test/events/payments/webhook-atomic-writes.test.js +69 -0
  764. package/test/events/payments/webhook-chargebee-unreachable.test.js +106 -0
  765. package/test/events/payments/webhook-discount-clear.test.js +189 -0
  766. package/test/events/payments/webhook-failure-intent.test.js +93 -0
  767. package/test/events/payments/webhook-hosted-page-uid-trust.test.js +212 -0
  768. package/test/events/payments/webhook-ordering.test.js +326 -0
  769. package/test/events/payments/webhook-provider-lookup-trust.test.js +206 -0
  770. package/test/events/payments/webhook-refund-amount-trust.test.js +223 -0
  771. package/test/events/payments/webhook-refund-envelope-shape.test.js +148 -0
  772. package/test/events/payments/webhook-refund-linkage-trust.test.js +404 -0
  773. package/test/events/payments/webhook-refund-transaction-fallback.test.js +184 -0
  774. package/test/events/payments/webhook-refund-without-order.test.js +170 -0
  775. package/test/events/payments/webhook-refusal-reporting.test.js +194 -0
  776. package/test/events/payments/webhook-refused-intent.test.js +142 -0
  777. package/test/events/payments/webhook-retry-sweep.test.js +252 -0
  778. package/test/events/payments/webhook-transition-claim.test.js +389 -0
  779. package/test/events/payments/webhook-uid-trust.test.js +203 -0
  780. package/test/events/payments/webhook-user-without-auth.test.js +161 -0
  781. package/test/fixtures/chargebee/invoice-one-time.json +27 -0
  782. package/test/fixtures/chargebee/subscription-active.json +44 -0
  783. package/test/fixtures/chargebee/subscription-cancelled.json +42 -0
  784. package/test/fixtures/chargebee/subscription-in-trial.json +41 -0
  785. package/test/fixtures/chargebee/subscription-legacy-plan.json +41 -0
  786. package/test/fixtures/chargebee/subscription-non-renewing.json +41 -0
  787. package/test/fixtures/chargebee/subscription-paused.json +42 -0
  788. package/test/fixtures/chargebee/webhook-payment-failed.json +51 -0
  789. package/test/fixtures/chargebee/webhook-subscription-created.json +47 -0
  790. package/test/fixtures/coinbase/charge-confirmed.json +50 -0
  791. package/test/fixtures/coinbase/charge-failed.json +34 -0
  792. package/test/fixtures/coinbase/charge-pending.json +34 -0
  793. package/test/fixtures/migrate/ported-route.js.txt +11 -0
  794. package/test/fixtures/paypal/capture-completed.json +51 -0
  795. package/test/fixtures/paypal/capture-refunded.json +43 -0
  796. package/test/fixtures/paypal/order-approved.json +62 -0
  797. package/test/fixtures/paypal/order-completed.json +110 -0
  798. package/test/fixtures/paypal/sale-refunded.json +38 -0
  799. package/test/fixtures/paypal/subscription-active.json +76 -0
  800. package/test/fixtures/paypal/subscription-cancelled.json +50 -0
  801. package/test/fixtures/paypal/subscription-suspended.json +65 -0
  802. package/test/fixtures/stripe/checkout-session-completed.json +130 -0
  803. package/test/fixtures/stripe/invoice-payment-failed.json +148 -0
  804. package/test/fixtures/stripe/invoice-subscription-payment-failed.json +28 -0
  805. package/test/fixtures/stripe/invoice-subscription-payment-succeeded.json +28 -0
  806. package/test/fixtures/stripe/subscription-active.json +161 -0
  807. package/test/fixtures/stripe/subscription-canceled.json +161 -0
  808. package/test/fixtures/stripe/subscription-trialing.json +161 -0
  809. package/test/helpers/_shared-config.js +15 -0
  810. package/test/helpers/ai-request-payload.test.js +619 -0
  811. package/test/helpers/ai-schema-resolve.test.js +125 -0
  812. package/test/helpers/ai-test-provider.test.js +227 -0
  813. package/test/helpers/ai-token-accounting.test.js +150 -0
  814. package/test/helpers/ai-tools-format.test.js +384 -0
  815. package/test/helpers/analytics-no-id-notice.test.js +146 -0
  816. package/test/helpers/analytics-user-data.test.js +194 -0
  817. package/test/helpers/api-manager.test.js +311 -0
  818. package/test/helpers/backend-router.test.js +136 -0
  819. package/test/helpers/content/blog-auto-publisher.test.js +482 -0
  820. package/test/helpers/content/feed-parser.test.js +529 -0
  821. package/test/helpers/content/ghostii-blocks.test.js +135 -0
  822. package/test/helpers/content/ghostii-feed-integration.test.js +405 -0
  823. package/test/helpers/content/ghostii-write-article.test.js +244 -0
  824. package/test/helpers/dev-only-routes.test.js +225 -0
  825. package/test/helpers/env-reader.test.js +362 -0
  826. package/test/helpers/environment.test.js +263 -0
  827. package/test/helpers/event-middleware.test.js +407 -0
  828. package/test/helpers/infer-contact.test.js +157 -0
  829. package/test/helpers/lane-url.test.js +90 -0
  830. package/test/helpers/merge-line-files.test.js +272 -0
  831. package/test/helpers/metadata.test.js +122 -0
  832. package/test/helpers/middleware-request-log.test.js +134 -0
  833. package/test/helpers/middleware-user-log.test.js +124 -0
  834. package/test/helpers/payment/chargebee/parse-webhook.test.js +490 -0
  835. package/test/helpers/payment/chargebee/refund-details.test.js +198 -0
  836. package/test/helpers/payment/chargebee/to-unified-one-time.test.js +148 -0
  837. package/test/helpers/payment/chargebee/to-unified-subscription.test.js +649 -0
  838. package/test/helpers/payment/coinbase/create-intent.test.js +247 -0
  839. package/test/helpers/payment/coinbase/parse-webhook.test.js +195 -0
  840. package/test/helpers/payment/coinbase/refund-unsupported.test.js +141 -0
  841. package/test/helpers/payment/coinbase/to-unified-one-time.test.js +201 -0
  842. package/test/helpers/payment/discount-codes.test.js +141 -0
  843. package/test/helpers/payment/extract-resource.test.js +113 -0
  844. package/test/helpers/payment/fetch-failure.test.js +215 -0
  845. package/test/helpers/payment/license-gate.test.js +108 -0
  846. package/test/helpers/payment/order-id.test.js +99 -0
  847. package/test/helpers/payment/paypal/create-intent.test.js +382 -0
  848. package/test/helpers/payment/paypal/fetch-capture.test.js +123 -0
  849. package/test/helpers/payment/paypal/fetch-sale.test.js +183 -0
  850. package/test/helpers/payment/paypal/parse-webhook.test.js +678 -0
  851. package/test/helpers/payment/paypal/refund-details.test.js +202 -0
  852. package/test/helpers/payment/paypal/resolve-plan-id.test.js +108 -0
  853. package/test/helpers/payment/paypal/switch-plan.test.js +92 -0
  854. package/test/helpers/payment/paypal/to-unified-one-time.test.js +383 -0
  855. package/test/helpers/payment/paypal/to-unified-subscription.test.js +884 -0
  856. package/test/helpers/payment/stripe/fetch-charge.test.js +147 -0
  857. package/test/helpers/payment/stripe/parse-webhook.test.js +448 -0
  858. package/test/helpers/payment/stripe/refund-details.test.js +147 -0
  859. package/test/helpers/payment/stripe/to-unified-one-time.test.js +307 -0
  860. package/test/helpers/payment/stripe/to-unified-subscription.test.js +709 -0
  861. package/test/helpers/persona-domain.test.js +87 -0
  862. package/test/helpers/recaptcha.test.js +166 -0
  863. package/test/helpers/resolved-config.test.js +106 -0
  864. package/test/helpers/response-log-redaction.test.js +262 -0
  865. package/test/helpers/roles.test.js +155 -0
  866. package/test/helpers/route-context-debug-gate.test.js +118 -0
  867. package/test/helpers/route-context-logging.test.js +214 -0
  868. package/test/helpers/route-context.test.js +129 -0
  869. package/test/helpers/safe-compare.test.js +114 -0
  870. package/test/helpers/sanitize.test.js +223 -0
  871. package/test/helpers/schema-engine.test.js +537 -0
  872. package/test/helpers/schema-zod.test.js +554 -0
  873. package/test/helpers/seed-accounts-load.test.js +163 -0
  874. package/test/helpers/seed-google-personas.test.js +114 -0
  875. package/test/helpers/seeded-personas.test.js +823 -0
  876. package/test/helpers/settings.test.js +292 -0
  877. package/test/helpers/setup-engines-pin.test.js +68 -0
  878. package/test/helpers/setup-manifest-sync.test.js +116 -0
  879. package/test/helpers/slugify.test.js +395 -0
  880. package/test/helpers/storage.test.js +195 -0
  881. package/test/helpers/test-banner-latch.test.js +139 -0
  882. package/test/helpers/usage-consume.test.js +460 -0
  883. package/test/helpers/usage-log-privacy.test.js +121 -0
  884. package/test/helpers/user-doc-heal.test.js +540 -0
  885. package/test/helpers/user.test.js +771 -0
  886. package/test/helpers/webhook-forward.test.js +419 -0
  887. package/test/helpers/wipe-auth-project.test.js +143 -0
  888. package/test/mcp/discovery.test.js +52 -0
  889. package/test/mcp/oauth.test.js +160 -0
  890. package/test/mcp/protocol.test.js +278 -0
  891. package/test/mcp/roles.test.js +203 -0
  892. package/test/mcp/utils.test.js +245 -0
  893. package/test/notification/identity.test.js +155 -0
  894. package/test/routes/admin/create-post.test.js +362 -0
  895. package/test/routes/admin/database.test.js +133 -0
  896. package/test/routes/admin/deduplicate-image-alts.test.js +191 -0
  897. package/test/routes/admin/email-content-html.test.js +58 -0
  898. package/test/routes/admin/email-request-log.test.js +86 -0
  899. package/test/routes/admin/email.test.js +116 -0
  900. package/test/routes/admin/firestore-query.test.js +206 -0
  901. package/test/routes/admin/firestore.test.js +129 -0
  902. package/test/routes/admin/infer-contact.test.js +220 -0
  903. package/test/routes/admin/notification.test.js +199 -0
  904. package/test/routes/admin/post-convert-image.test.js +159 -0
  905. package/test/routes/admin/post-deploy-flag.test.js +81 -0
  906. package/test/routes/admin/post-download-error.test.js +90 -0
  907. package/test/routes/admin/post-resize-image.test.js +185 -0
  908. package/test/routes/admin/post.test.js +369 -0
  909. package/test/routes/admin/repo-content.test.js +223 -0
  910. package/test/routes/admin/stats.test.js +114 -0
  911. package/test/routes/admin/users-disable.test.js +63 -0
  912. package/test/routes/admin/users-list.test.js +71 -0
  913. package/test/routes/content/post.test.js +60 -0
  914. package/test/routes/general/uuid.test.js +133 -0
  915. package/test/routes/health.test.js +110 -0
  916. package/test/routes/marketing/campaign.test.js +184 -0
  917. package/test/routes/marketing/contact.test.js +416 -0
  918. package/test/routes/marketing/email-preferences.test.js +293 -0
  919. package/test/routes/marketing/push-send.test.js +34 -0
  920. package/test/routes/marketing/webhook-forward.test.js +63 -0
  921. package/test/routes/marketing/webhook.test.js +641 -0
  922. package/test/routes/payments/_route-harness.js +145 -0
  923. package/test/routes/payments/cancel-provider-errors.test.js +227 -0
  924. package/test/routes/payments/cancel-skip-guards.test.js +126 -0
  925. package/test/routes/payments/cancel-trialing.test.js +428 -0
  926. package/test/routes/payments/cancel.test.js +165 -0
  927. package/test/routes/payments/dedup-race.test.js +186 -0
  928. package/test/routes/payments/discount.test.js +82 -0
  929. package/test/routes/payments/dispute-alert.test.js +324 -0
  930. package/test/routes/payments/intent-discount-amounts.test.js +550 -0
  931. package/test/routes/payments/intent-discount-coupons.test.js +293 -0
  932. package/test/routes/payments/intent-one-time-metadata.test.js +122 -0
  933. package/test/routes/payments/intent-purchaser-guard.test.js +139 -0
  934. package/test/routes/payments/intent-zero-total.test.js +260 -0
  935. package/test/routes/payments/intent.test.js +413 -0
  936. package/test/routes/payments/plan.test.js +367 -0
  937. package/test/routes/payments/portal-return-url.test.js +107 -0
  938. package/test/routes/payments/portal.test.js +94 -0
  939. package/test/routes/payments/refund-one-time.test.js +254 -0
  940. package/test/routes/payments/refund-paypal-proration.test.js +190 -0
  941. package/test/routes/payments/refund.test.js +181 -0
  942. package/test/routes/payments/trial-eligibility.test.js +73 -0
  943. package/test/routes/payments/uncancel.test.js +270 -0
  944. package/test/routes/payments/webhook-stripe-invoice.test.js +129 -0
  945. package/test/routes/payments/webhook-stripe-refund-one-time.test.js +130 -0
  946. package/test/routes/payments/webhook-test-provider.test.js +156 -0
  947. package/test/routes/payments/webhook.test.js +115 -0
  948. package/test/routes/payments/winback-stripe-coupon.test.js +126 -0
  949. package/test/routes/payments/winback.test.js +597 -0
  950. package/test/routes/test/authenticate.test.js +79 -0
  951. package/test/routes/test/redirect.test.js +66 -0
  952. package/test/routes/test/reset-account.test.js +126 -0
  953. package/test/routes/test/roster.test.js +101 -0
  954. package/test/routes/test/schema.test.js +556 -0
  955. package/test/routes/test/usage.test.js +374 -0
  956. package/test/routes/user/api-keys.test.js +158 -0
  957. package/test/routes/user/connections-grant.test.js +759 -0
  958. package/test/routes/user/connections-identity.test.js +260 -0
  959. package/test/routes/user/connections-log-privacy.test.js +206 -0
  960. package/test/routes/user/connections-refresh-lease.test.js +696 -0
  961. package/test/routes/user/connections-return.test.js +261 -0
  962. package/test/routes/user/connections-uid.test.js +516 -0
  963. package/test/routes/user/delete.test.js +138 -0
  964. package/test/routes/user/feedback.test.js +106 -0
  965. package/test/routes/user/orders.test.js +233 -0
  966. package/test/routes/user/sessions.test.js +231 -0
  967. package/test/routes/user/settings-validate.test.js +84 -0
  968. package/test/routes/user/signup-emails.test.js +113 -0
  969. package/test/routes/user/signup-location.test.js +84 -0
  970. package/test/routes/user/signup-log-privacy.test.js +112 -0
  971. package/test/routes/user/signup.test.js +579 -0
  972. package/test/routes/user/subscription.test.js +101 -0
  973. package/test/routes/user/token.test.js +111 -0
  974. package/test/routes/user/user.test.js +158 -0
  975. package/test/routes/verts/cache.test.js +78 -0
  976. package/test/routes/verts/click-destination.test.js +76 -0
  977. package/test/routes/verts/crud.test.js +194 -0
  978. package/test/routes/verts/redirect.test.js +147 -0
  979. package/test/routes/verts/selection.test.js +237 -0
  980. package/test/routes/verts/serve.test.js +185 -0
  981. package/test/routes/verts/unit-document.test.js +117 -0
  982. package/test/rules/_environment.js +66 -0
  983. package/test/rules/brand-merge.test.js +195 -0
  984. package/test/rules/field-helpers.test.js +309 -0
  985. package/test/rules/notifications.test.js +577 -0
  986. package/test/rules/payments-carts.test.js +406 -0
  987. package/test/rules/sessions.test.js +170 -0
  988. package/test/rules/user.test.js +517 -0
  989. package/test/rules/verts.test.js +101 -0
  990. package/test/security/fetch-log-secrets.test.js +124 -0
  991. package/test/security/primitives.test.js +105 -0
  992. package/test/security/repo-pinning.test.js +77 -0
  993. package/test/stripe-live/subscription-lifecycle.test.js +141 -0
@@ -0,0 +1,856 @@
1
+ # Payment System
2
+
3
+ This document covers the full payment system: pipeline architecture, subscription model + statuses, transition handlers, provider interface, product configuration, and the test provider.
4
+
5
+ ## Pipeline
6
+
7
+ The payment system follows a linear pipeline: **Intent → Webhook → On-Write → Transition**.
8
+
9
+ 1. **Intent** (`POST /payments/intent`): Client requests a payment session. @omega.js/backend verifies the purchaser is one of this project's users (an auth user AND a user doc — [below](#a-payment-never-creates-a-user-doc)), validates the product, generates an order ID (`XXXX-XXXX-XXXX`), and delegates to the provider module (e.g., Stripe creates a Checkout Session). Saves to `payments-intents/{orderId}`.
10
+
11
+ 2. **Webhook** (`POST /payments/webhook?provider=X&key=Y`): Provider sends event data. @omega.js/backend parses and categorizes the event (`subscription` or `one-time`), extracts the UID, and saves to `payments-webhooks/{eventId}` with `status: 'pending'`.
12
+
13
+ 3. **On-Write** (Firestore trigger on `payments-webhooks/{eventId}`): Fetches the resource from the provider API — [the only trusted source](#a-lookup-the-provider-cannot-answer-never-processes-the-payload), never the object the webhook body carried — transforms it into a unified object, detects state transitions, dispatches handlers, tracks analytics, and writes to `users/{uid}.subscription` (subscriptions) and `payments-orders/{orderId}`.
14
+
15
+ 4. **Transitions** (fire-and-forget): Handler files run asynchronously after detection. Failures never block webhook processing. Skipped during tests unless `TEST_EXTENDED_MODE` is set.
16
+
17
+ ### A lookup the provider cannot answer never processes the payload
18
+
19
+ **The provider's lookup response is the only trusted source.** A webhook body is whatever the caller posted — the endpoint authenticates on the query key by design, and nothing in the body is verified — so `fetchResource()` used to swallow a failed lookup and hand that body back as the resource, flagged `_stale`. Unverified data then drove real subscription state and real conversion events ([#506](https://github.com/Omega-JS-Stack/omega/issues/506)). There is no fallback any more, for any provider: a lookup that fails produces no resource at all, and the pipeline branches on WHICH failure it was.
20
+
21
+ | Outcome | What it means | What the pipeline does |
22
+ |---|---|---|
23
+ | **Not found** | The provider affirmatively does not have the resource (Stripe `resource_missing`/404, a Chargebee 404, a Coinbase Commerce 404, `PayPal API 404`) | **Refuse and acknowledge.** Nothing is written — no subscription, no order, no intent, no conversion. The event doc completes with `refusal` = `{ reason: 'resource-not-found', resourceType, resourceId }` and a loud `RESOURCE NOT FOUND` error naming the provider and the resource. Completed, not failed: no retry could ever turn a resource the provider does not have into one it does, so the ladder is not burned and the provider stops redelivering |
24
+ | **Unreachable** | A timeout, a 5xx, an expired key — the answer exists and this attempt could not read it | **Defer.** The throw marks the doc `failed`, which is what the [retry sweep](#payments-webhooks-retry-state) re-pends: the redelivery IS the reconciliation mechanism. Still nothing is written off the payload |
25
+ | **Permanent** | The lookup can never even be ATTEMPTED — a malformed envelope, a parser error. Today's one instance: a Stripe refund envelope carrying no charge id at `data.object.id` | **Fail terminally.** The doc is marked `failed` AND `deadLetter: true` on its FIRST attempt, with a loud `PERMANENT FAILURE` line and the envelope's actual shape (`type=`, `data.object.object=`, `data.object keys=[…]`) in the message. Handing `undefined` to the SDK threw something that is not a 404, so this used to classify as unreachable and burn the whole ladder ten minutes at a time before dead-lettering something the first attempt already knew was unprocessable ([#536](https://github.com/Omega-JS-Stack/omega/issues/536)) |
26
+
27
+ The split is one rule for every provider, in `libraries/payment/provider-errors.js` — the same classifier the cancel route and the trial-lapse sweep force-write against — and `libraries/payment/fetch-failure.js` is what every library's lookup throws through — `fetchResource()` and `getRefundDetails()` alike, each naming itself in the message so the failure points at the call that actually missed — so no provider names its own not-found shape twice. Anything unrecognized is unreachable by construction: an unknown error must never be the one that drops an event.
28
+
29
+ The webhook body is still read for **identifiers** — which event, which resource id, which order a failed event belonged to. What it may never do is say what STATE a resource is in. The one library that reads its own body as an answer is the [test provider](#test-provider): there is no test provider out there to ask, so the emulator's records plus the event body are its API — and the webhook route refuses `provider=test` in production.
30
+
31
+ ### A lookup that DOES answer says whose event it is
32
+
33
+ The same rule, one step further in: on a successful lookup the uid comes from `library.getUid(resource)` — the record the provider answered with — and the payload's uid is only what that is **cross-checked** against. Reading the payload's uid first meant an event naming a REAL subscription id with a different `metadata.uid` moved that subscription onto whatever uid the caller typed ([#509](https://github.com/Omega-JS-Stack/omega/issues/509)). Every provider's resource carries the uid this framework put on it: Stripe `metadata.uid`, PayPal `custom_id`, Chargebee `meta_data`/`cf_uid`, Coinbase Commerce `metadata.uid`.
34
+
35
+ | The provider's record | The payload | What the pipeline does |
36
+ |---|---|---|
37
+ | Carries a uid | Claims the same one, or claims none | **Write, steered by the provider's.** A payload that claimed none has the resolved uid persisted on the event doc, exactly as the PayPal `PAYMENT.SALE` path always did |
38
+ | Carries a uid | Claims a DIFFERENT one | **Refuse and acknowledge.** Nothing is written — not under the claimed uid, and not under the provider's either: an event that lies about its owner has nothing left in it worth acting on, and writing it quietly under the real owner would hide the forgery. The doc completes with `refusal` = `{ reason: 'uid-mismatch', payloadUid, providerUid, source }` and a loud `UID MISMATCH` error naming both |
39
+ | Carries none, but the **hosted page** does | Claims anything | The hosted page is a record of the PROVIDER's, so it steers and the payload is cross-checked against it — a match writes, a mismatch earns the same `uid-mismatch` refusal, stamped `source: 'hosted-page'`. A hit also brings its `orderId` and backfills `meta_data` onto the subscription, so the next event resolves directly |
40
+ | Carries none, and no hosted page answers | Claims one | **Write, steered by the payload's** — nothing is left to check it against. A loud `UID FALLBACK` warning names the fallback, because that write is the one that was never cross-checked |
41
+ | Carries none, and no hosted page answers | Claims none | Unchanged: an event with no uid fails |
42
+
43
+ **The hosted-page lookup runs BEFORE the payload's claim is believed.** A Chargebee hosted-page checkout does not forward `subscription[meta_data]`, so the subscription answers no uid until the backfill runs — but `pass_thru_content` on the hosted page holds ours, and that lookup used to be gated on `!uid`: it was consulted only when the payload claimed nothing, i.e. never in the one case where a claim needed checking ([#533](https://github.com/Omega-JS-Stack/omega/issues/533)).
44
+
45
+ A miss **falls back, never refuses**: the scan covers only the last 25 hosted pages (`GET /hosted_pages?limit=25&sort_by[desc]=created_at`), so a null honestly means "not in the window", not "not this uid" — refusing on it would break every hosted-page checkout older than 25 pages.
46
+
47
+ `provider=test` is exempt in practice, not by a branch: its `fetchResource()` may legitimately answer with the payload object, so the two uids are the same value and no mismatch can arise. Its protection is the production refusal above.
48
+
49
+ ### The writes are one batch
50
+
51
+ The three writes an event produces — `users/{uid}.subscription` (subscriptions only), `payments-orders/{orderId}`, and `payments-intents/{orderId}` — land in **ONE Firestore batch**. As separate awaits, anything that threw between them left the state split: a user who paid with no order behind it, or an order whose intent still said `pending`. The batch makes it all-or-nothing. Transition dispatch and analytics stay **outside** the batch — they are fire-and-forget and must never gate the writes.
52
+
53
+ ### A refund merges into the purchase
54
+
55
+ A refund **updates** a purchase record; it does not redefine it. A one-time refund's resource is the bare charge that moved the money back — it names no product and no price — so re-deriving the order from it degraded a completed purchase to `product: 'unknown'` at price 0 and replaced the checkout `resourceId` with the charge id. The pipeline merges instead:
56
+
57
+ - `unified.status` → `refunded`
58
+ - `unified.payment.refund` → `{ amount, currency, reason, date }` (from the provider library's `getRefundDetails()`)
59
+ - product, price, and the purchase's own `resourceId` stay exactly what the completed purchase wrote
60
+
61
+ **What came back is the provider's number.** `getRefundDetails()` used to read the amount, the currency and the reason out of the webhook envelope, so an event claiming an inflated refund booked that number onto the order record, into the customer's refund email and into the refund conversion ([#510](https://github.com/Omega-JS-Stack/omega/issues/510)). It takes the resource the pipeline already looked up (`getRefundDetails(resource, { raw, refundId, eventType, resourceType, ctx })` — `resourceType` says which of the provider's back-pointers to read, [below](#a-refund-record-has-to-belong-to-the-event-that-named-it)) and answers from provider data only — the envelope is read for the lookup KEY and nothing else, the same trust level as the `resourceId` every lookup starts from. The providers are not symmetric about where a refund lives:
62
+
63
+ | Provider | Where the refund's numbers come from |
64
+ |---|---|
65
+ | **Stripe** | The **charge**. The one-time path already fetched it (`resourceType: 'charge'`); the subscription path — whose resource is the subscription the refunded charge belongs to — reads it back by the charge id the payload names |
66
+ | **PayPal** | The **refund's own record**: `GET /v2/payments/refunds/{id}` for `PAYMENT.CAPTURE.REFUNDED`, `GET /v1/payments/refund/{id}` for the v1 sale refunds. The sale or capture already in hand is the ORIGINAL payment — its amount is the purchase price, which a partial refund makes plainly wrong. The refund's own id rides on the parsed event as `refundId`, kept there because `resourceId` is reassigned to the sale/capture the refund reversed |
67
+ | **Chargebee** | The **credit note**: `GET /credit_notes/{id}`, keyed by the id in the envelope. The subscription or invoice the event resolves to carries no credit-note fields at all, and the envelope's own `content.transaction` amount is no longer a fallback |
68
+ | **Chargebee, no credit note** | The **transaction**: `GET /transactions/{id}`, keyed by `content.transaction.id` — a lookup KEY only, exactly the trust level the credit-note id has. A gateway refund issued without a credit note is a real refund, and dropping the untrusted envelope fallback without putting a trusted one in its place wrote `amount: null` onto the order and into the customer's refund email ([#534](https://github.com/Omega-JS-Stack/omega/issues/534)). Amount comes off `transaction.amount` (cents, like the credit note's `total`); `reason` stays null, because a transaction carries no `reason_code` |
69
+ | **Coinbase Commerce** | **Nothing** — there is no refund record to read, so no coinbase event is ever a refund. `getRefundDetails()` is unreachable by construction and answers with no amount rather than throwing, since throwing inside the pipeline would redeliver an event forever |
70
+ | **Test** | Stripe's reader over the charge it already has — the fetched resource when the event resolved to the charge, otherwise the event body. Never a real Stripe lookup: there is no Stripe account behind a test-provider event to answer one |
71
+
72
+ A refund lookup that fails is classified by the same seam as any other ([above](#a-lookup-the-provider-cannot-answer-never-processes-the-payload)): not-found refuses the event (the stamp names the lookup that actually missed — `charge`, `refund`, `credit_note`, `transaction`), unreachable defers it, and a Stripe envelope naming no charge id at all fails permanently before the call is made. An event that names no refund record at all — no refund id, or for Chargebee neither a credit note nor a transaction — records **no amount** rather than the payload's, and says so in the log.
73
+
74
+ ### A refund record has to belong to the event that named it
75
+
76
+ The lookup key is the payload's, and that is where the [#510](https://github.com/Omega-JS-Stack/omega/issues/510) trust argument stopped one step short: the event's own `resourceId` is SELF-CONSISTENT (whatever it names is what gets fetched and what gets written), while a refund id imports numbers ACROSS records. An attacker holding the webhook key could pair a real sale of their own — which passes the uid check above — with an UNRELATED refund id from the same merchant account, and another customer's amount, currency and reason landed on this order, its email and its refund conversion ([#532](https://github.com/Omega-JS-Stack/omega/issues/532)).
77
+
78
+ So every refund lookup is linked back. Each provider reads its own back-pointer and hands the pair to `libraries/payment/refund-linkage.js`, where the decision is written once:
79
+
80
+ | Provider | The event's resource | The record's back-pointer |
81
+ |---|---|---|
82
+ | **PayPal** | `sale` (v1 one-time refund) | `sale_id` |
83
+ | **PayPal** | `capture` (v2 `PAYMENT.CAPTURE.REFUNDED`) | `links[rel=up]` — the URL's last segment is the capture id |
84
+ | **PayPal** | `subscription` (a v1 sale refund behind a billing agreement) | none — the refund names the SALE, never the agreement, so there is nothing to compare |
85
+ | **Chargebee** | `subscription` | `subscription_id` (credit note or transaction) |
86
+ | **Chargebee** | `invoice` | `reference_invoice_id` (credit note), else `linked_invoices[].invoice_id` (transaction — the event's own invoice counts as the link when it is among them) |
87
+ | **Stripe** | `subscription` | the charge's `subscription`, else its `metadata.uid` against the subscription's own uid |
88
+ | **Stripe** | `charge` | none needed — the charge IS the resource, fetched by the event's `resourceId`, so it is self-consistent |
89
+ | **Coinbase Commerce** | any | none — the provider has no refund record to link back from |
90
+ | **Test** | any | none — the test provider is its own API and looks nothing up |
91
+
92
+ | The record's back-pointer | What the pipeline does |
93
+ |---|---|
94
+ | **Disagrees** | **Refuse and acknowledge.** Nothing is written; the doc completes with `refusal` = `{ reason: 'refund-not-linked', refundType, refundId, resourceType, resourceId, linkedTo }` and a loud `REFUND NOT LINKED` error naming what the record really belongs to. Completed, not failed: no retry can relate two unrelated records |
95
+ | **Agrees** | Proceed — the numbers are recorded exactly as before |
96
+ | **Absent** | **Proceed, loudly.** A record carrying no back-pointer for this kind of resource (the PayPal subscription row above; a Stripe charge with neither `subscription` nor `metadata.uid`) is UNPROVEN, not wrong — the rule is refuse on mismatch, and absence is not a mismatch. A `REFUND LINK UNPROVEN` warning names the refund that was recorded without being linked back, the same posture the `UID FALLBACK` warning takes (ratified 2026-08-23 on [#532](https://github.com/Omega-JS-Stack/omega/issues/532)) |
97
+
98
+ ### A refund with no purchase behind it is refused, not minted
99
+
100
+ A refund can only UPDATE a purchase — it can never DEFINE one. When the refund event named an order that did not exist, the merge above could not run and the event was read as a fresh purchase definition instead: `payments-orders/{orderId}` was created with `unified.status: 'completed'` and the REFUND's id as the resource, so a reversal was booked as revenue while the transition trail said `one-time/purchase-refunded`. Reachable whenever the purchase write is missing — a lost or failed purchase webhook, a webhook registered after the sale, or PayPal delivering `PAYMENT.CAPTURE.REFUNDED` before the capture.
101
+
102
+ So the pipeline **refuses**, and writes nothing to `payments-orders` or `payments-intents`: no transition is detected, no analytics fire, and the webhook doc completes with `transition: null` — the trail agrees with the record. The refusal is stamped on the event's OWN doc, alongside that transition: `payments-webhooks/{eventId}.refusal` = `{ reason: 'refund-without-order', captureId }`, with a loud `REFUND WITHOUT ORDER` error line carrying the money from `getRefundDetails()`. The doc already holds the refund payload as delivered (`raw`), the owner and the order the refund named; what it adds is the id of the capture the refund reversed — read off the payload's HATEOAS `up` link, which PayPal points at the capture and other providers omit (`null`, never a guess). That capture id is the pointer a human reconciles the missing purchase from ([#335](https://github.com/Omega-JS-Stack/omega/issues/335)).
103
+
104
+ The webhook is **completed**, not failed: the event reached a terminal decision, so it must not burn the retry ladder or dead-letter. The event doc is keyed by the provider's event id, so a redelivery re-decides the same document rather than piling up duplicates — and `refusal` is written on every completion (`null` when nothing was refused), so a reprocess that now finds its order clears the flag instead of leaving a stale one behind.
105
+
106
+ ### A payment never creates a user doc
107
+
108
+ A user doc is born at **signup**, behind a real Firebase auth user. A payment event can update one and can never mint one, at either seam ([#399](https://github.com/Omega-JS-Stack/omega/issues/399)):
109
+
110
+ - **The webhook pipeline** looks the uid up in Auth when `users/{uid}` does not exist. With no auth user it refuses: nothing is written — no user doc, no order, no intent — and the event completes with `refusal` = `{ reason: 'user-without-auth' }` plus a loud `USER WITHOUT AUTH` warning naming the uid and the event. Completed, not failed, so the provider stops redelivering an event nothing here will ever act on. A uid whose doc already exists takes no lookup at all: updates and deletes behave exactly as before.
111
+ - The refusal also **reports**, at `warning` level, through the backend's one capture handle (`Manager.libraries.sentry`, null and therefore a no-op when no DSN is configured — [monitoring.md](../../../docs/shared/monitoring.md)). It carries the uid, the event id and type, the provider and the reason; no email is assembled, so there is nothing for the PII scrub to take out. The doc stamp is the record a human reconciles from; this is the alarm that tells them to look, since the whole problem is that nobody knows to (Ian, 2026-08-20).
112
+ - **The checkout route** verifies BOTH halves before it starts — missing either answers `403` with a warn line, so there is no provider session, no intent doc, and no half-written account.
113
+
114
+ The seam is real, not theoretical: a QA checkout run locally against the emulator with real test-mode keys has its webhooks delivered to the **deployed** backend (the emulator has no webhook path), and `customer.subscription.created` for an emulator-only uid used to mint a LIVE `users/{uid}` holding nothing but a subscription block. Residue that predates the guards is cleaned up by the users migration, which flags exactly this shape as an orphan.
115
+
116
+ The opposite direction is healed rather than refused: a real account whose user doc went missing gets it recreated when it next authenticates ([common-operations.md](common-operations.md#a-missing-user-doc-heals-here)), so a genuine customer never arrives at the checkout guard above without a doc.
117
+
118
+ ## 3-Layer Architecture
119
+
120
+ The payment system is cleanly separated into three independent layers:
121
+
122
+ | Layer | Purpose | Tests |
123
+ |-------|---------|-------|
124
+ | **Provider input** (Stripe, PayPal, Chargebee, Coinbase Commerce, Test) | Parse raw webhooks + transform to unified shape | Helper tests per provider (`payment/stripe/to-unified-subscription.js`, `payment/paypal/to-unified-one-time.js`, `payment/coinbase/parse-webhook.js`, etc.) |
125
+ | **Unified pipeline** (provider-agnostic) | Transition detection, Firestore writes, analytics | Journey tests (`journey-payments-*.js`) |
126
+ | **Transition handlers** (fire-and-forget) | Emails, notifications, side effects | Skipped during tests unless `TEST_EXTENDED_MODE` |
127
+
128
+ Each provider transforms its raw data into the **same unified shape**. Once data enters the pipeline, the code doesn't know or care which provider it came from. This means:
129
+ - Adding a new provider = implement the provider interface (below). The pipeline handles the rest.
130
+ - Journey tests use the `test` provider but exercise the full unified pipeline end-to-end.
131
+ - Provider-specific tests only need to verify correct transformation to the unified shape.
132
+
133
+ ## Subscription Statuses
134
+
135
+ | Status | Meaning | User can delete account? |
136
+ |--------|---------|--------------------------|
137
+ | `active` | Subscription is current and valid (includes trialing) | No (unless `product.id === 'basic'`) |
138
+ | `suspended` | Payment failed (Stripe: `past_due`, `unpaid`) | No |
139
+ | `cancelled` | Subscription terminated (Stripe: `canceled`, `incomplete`, `incomplete_expired`) | Yes |
140
+
141
+ `suspended` is the dunning entry state for **both** failure shapes: a renewal that fails on a live subscription, and a FIRST checkout that declines. The two are told apart by the transition, not the status — `payment-failed` for the renewal, `checkout-declined` for the first checkout ([Subscription Transitions](#subscription-transitions)).
142
+
143
+ ### Stripe Status Mapping
144
+
145
+ | Stripe Status | `subscription.status` | Notes |
146
+ |---|---|---|
147
+ | `active` | `active` | Normal active subscription |
148
+ | `trialing` | `active` | `trial.claimed = true` |
149
+ | `past_due` | `suspended` | Payment failed, retrying |
150
+ | `unpaid` | `suspended` | Payment failed |
151
+ | `canceled` | `cancelled` | Subscription terminated |
152
+ | `incomplete` | `cancelled` | Never completed initial payment |
153
+ | `incomplete_expired` | `cancelled` | Expired before completion |
154
+ | `active` + `cancel_at_period_end` | `active` | `cancellation.pending = true` |
155
+
156
+ ## Unified Subscription Object (`users/{uid}.subscription`)
157
+
158
+ ```javascript
159
+ subscription: {
160
+ product: {
161
+ id: 'basic', // product ID from config ('basic', 'premium', etc.)
162
+ name: 'Basic', // display name from config
163
+ },
164
+ status: 'active', // 'active' | 'suspended' | 'cancelled'
165
+ expires: { timestamp, timestampUNIX },
166
+ trial: {
167
+ claimed: false, // has user EVER used a trial
168
+ expires: { timestamp, timestampUNIX },
169
+ outcome: null, // 'converted' | 'lapsed' | null — stamped by whichever path sees the trial end, NOT by the unified transform
170
+ },
171
+ cancellation: {
172
+ pending: false, // true = cancel at period end
173
+ date: { timestamp, timestampUNIX },
174
+ },
175
+ payment: {
176
+ provider: null, // 'stripe' | 'paypal' | etc.
177
+ orderId: null, // @omega.js/backend order ID (e.g., '1234-5678-9012')
178
+ resourceId: null, // provider subscription ID (e.g., 'sub_xxx')
179
+ frequency: null, // 'monthly' | 'annually' | 'weekly' | 'daily'
180
+ price: 0, // resolved from config (number, e.g., 4.99)
181
+ startDate: { timestamp, timestampUNIX },
182
+ updatedBy: {
183
+ event: { name: null, id: null },
184
+ date: { timestamp, timestampUNIX },
185
+ },
186
+ },
187
+ }
188
+ ```
189
+
190
+ `trial.claimed` says only that a trial **happened** — a converted trial and a lapsed one carry identical dates — so `trial.outcome` is the one stored conversion signal. It is additive: the provider libraries never produce it (the unified transform's `trial` carries `claimed` + `expires` only, and the user-doc write is a merge), and it is mirrored in the `@omega.js/account` user schema. Whichever path sees the trial end stamps it: the webhook pipeline writes `converted` on the charge it books as the conversion ([Subscription Transitions](#subscription-transitions)), and the trial-lapse sweep stamps whatever the provider confirms when no webhook ever comes ([Payment Cron Jobs](#payment-cron-jobs)).
191
+
192
+ ## Access Check Patterns
193
+
194
+ ```javascript
195
+ // Is premium (paid)?
196
+ user.subscription.status === 'active' && user.subscription.product.id !== 'basic'
197
+
198
+ // Is on trial?
199
+ user.subscription.trial.claimed && user.subscription.status === 'active'
200
+
201
+ // Has pending cancellation?
202
+ user.subscription.cancellation.pending === true
203
+
204
+ // Payment failed?
205
+ user.subscription.status === 'suspended'
206
+ ```
207
+
208
+ ## resolveSubscription(account)
209
+
210
+ `User.resolveSubscription(account)` is a static method on the User helper that derives calculated subscription fields from raw account data. It returns only fields that require derivation logic — raw data (product.id, status, trial, cancellation) lives on the account object directly.
211
+
212
+ ```javascript
213
+ const User = require('@omega.js/backend/dist/manager/helpers/user');
214
+
215
+ const resolved = User.resolveSubscription(account);
216
+ // Returns: { plan, active, trialing, cancelling }
217
+ ```
218
+
219
+ | Field | Type | Description |
220
+ |-------|------|-------------|
221
+ | `plan` | `string` | Effective plan ID the user has access to RIGHT NOW (`'basic'` if cancelled/suspended) |
222
+ | `active` | `boolean` | User has active access (active, trialing, or cancelling) |
223
+ | `trialing` | `boolean` | In an active trial (status `'active'` + `trial.claimed` + unexpired `trial.expires`) |
224
+ | `cancelling` | `boolean` | Cancellation pending (status `'active'` + `cancellation.pending` + NOT trialing) |
225
+
226
+ Accepts either a raw Firestore account object or a resolved `User` instance (checks both `account.subscription` and `account.properties.subscription`).
227
+
228
+ **Unified with @omega.js/client**: The same function exists as `auth.resolveSubscription(account)` in @omega.js/client (`modules/auth.js`) with identical logic and return shape.
229
+
230
+ **Use this instead of manual access checks** — it centralizes all the derivation logic in one place:
231
+
232
+ ```javascript
233
+ // ✅ PREFERRED — use resolveSubscription
234
+ const resolved = User.resolveSubscription(user);
235
+ if (resolved.active) { /* has access */ }
236
+
237
+ // ❌ AVOID — manual checks that duplicate logic
238
+ if (user.subscription.status === 'active' && user.subscription.product.id !== 'basic') { /* ... */ }
239
+ ```
240
+
241
+ ## Transition Handlers
242
+
243
+ When a webhook changes a subscription or processes a one-time payment, @omega.js/backend detects the state transition and dispatches to a handler file. Handlers are fire-and-forget (non-blocking) — they run after the transition is detected but before or during the Firestore writes. Handler failures never block webhook processing.
244
+
245
+ Handlers are skipped during tests unless `TEST_EXTENDED_MODE` is set.
246
+
247
+ ### Transition Detection
248
+
249
+ The `transitions/index.js` module compares the **before** state (current `users/{uid}.subscription`) with the **after** state (new unified subscription) to detect what changed.
250
+
251
+ **A detected transition is CLAIMED before anything acts on it** ([#665](https://github.com/Omega-JS-Stack/omega/issues/665)): a transaction writes `payments-orders/{orderId}.transitions.<name>` = `{ eventId, status, timestamp, timestampUNIX }`, and an event that finds the field already there acts on nothing. Detection is a diff, so two webhooks for one checkout in flight together (Stripe's `customer.subscription.created` and `invoice.payment_succeeded`, one second apart) both read `basic` and both detect `new-subscription` — the claim is what makes only one of them act on it. One claim per order per transition NAME, so the order's later transitions still fire.
252
+
253
+ **A claim carries its outcome, and a `failed` one is retakeable.** `status` is `claimed` while the run holds it, `done` once that run got through all three effects below, and `failed` beside the webhook doc's own failure stamp. A winner can throw AFTER claiming — the batch that writes the subscription, the order and the intent comes after the dispatch — and the frequent cron then re-runs that same event id ([below](#payments-webhooks-retry-state)). Against a claim with no outcome on it, the retry was refused by its own claim forever: no order email, no analytics, no marketing sync, and no subscription, for a customer who paid. `failed` is the one reclaimable state here for the same reason it is the one reclaimable state on the webhook doc and on a dispute alert; a `claimed` or `done` record refuses whoever asks, including the event holding it.
254
+
255
+ **And a claim only speaks for ten minutes** (`CLAIM_WINDOW_MS`, exported by `transitions/index.js`). The race it settles is SECONDS wide and the retry that re-runs a failed event is minutes wide, while the order id is stable for the subscription's whole life — so every transition that can happen twice re-detects a name the order already carries: a second dunning cycle's `payment-failed`, a `cancellation-requested` after an uncancel, a second `plan-changed`. Unbounded, the first claim swallowed all of them for as long as the subscription lasted. A claim older than the window is retaken whatever its status; a fresher one from another event still refuses, which is what makes the concurrent pair above resolve to one.
256
+
257
+ The claim gates all THREE effects a transition drives, because the live sighting duplicated all three:
258
+
259
+ | Effect | Suppressed with |
260
+ |---|---|
261
+ | The handler dispatch (the customer's order email) | `Transition suppressed (claimed by <eventId>)` |
262
+ | The analytics fire (`trial_start`, `purchase`) | `Payment analytics suppressed (claimed by <eventId>)` |
263
+ | The marketing sync (`email.sync`) | `Marketing sync suppressed (claimed by <eventId>)` |
264
+
265
+ The analytics fire is dropped WHOLE rather than re-resolved without its transition: `resolvePaymentEvent()` has `!transitionName` branches, so a nulled transition would make the duplicate resolve as a RENEWAL and fire `subscription_payment` — worse than firing nothing. A run with no transition detected at all is untouched by any of this; a renewal still tracks and still syncs.
266
+
267
+ ### Subscription Transitions
268
+
269
+ | Transition | Before → After | File | Email event |
270
+ |---|---|---|---|
271
+ | `payment-refunded` | detected from the event type, not the state diff | `transitions/subscription/payment-refunded.js` | `refunded` |
272
+ | `new-subscription` | basic/null → active paid | `transitions/subscription/new-subscription.js` | `confirmation` |
273
+ | `subscription-winback` | cancelled paid → active paid | `transitions/subscription/subscription-winback.js` | `confirmation` (delegates to `new-subscription.js`) |
274
+ | `checkout-declined` | basic/null → suspended | `transitions/subscription/checkout-declined.js` | none — log only |
275
+ | `checkout-declined` | cancelled paid → suspended (a win-back that declines) | `transitions/subscription/checkout-declined.js` | none — log only |
276
+ | `payment-failed` | active → suspended | `transitions/subscription/payment-failed.js` | `payment-failed` |
277
+ | `payment-recovered` | suspended → active | `transitions/subscription/payment-recovered.js` | `payment-recovered` |
278
+ | `cancellation-requested` | pending=false → pending=true | `transitions/subscription/cancellation-requested.js` | `cancellation-requested` |
279
+ | `cancellation-removed` | pending=true → pending=false, same product, still active | `transitions/subscription/cancellation-removed.js` | none — log only |
280
+ | `subscription-cancelled` | non-cancelled → cancelled | `transitions/subscription/subscription-cancelled.js` | `cancelled` |
281
+ | `plan-changed` | active product A → active product B | `transitions/subscription/plan-changed.js` | `plan-changed` |
282
+
283
+ **Order is behavior.** `payment-refunded` is checked first, from the event type alone (a refund may not move the subscription's state at all). The rest are an ordered ladder — most specific first — and the table lists them in that order (`checkout-declined` is reached by two rules, hence two rows). Two placements carry the weight:
284
+
285
+ - `subscription-winback` and both `checkout-declined` rules sit **ahead of** `payment-failed`. Users are born active on `basic`, and a full cancellation leaves the paid product id in place, so without them a returning subscriber matched nothing (no confirmation email, and analytics read the checkout as a renewal) and a declined FIRST checkout read as active → suspended and sent renewal-dunning copy to someone who never had access to lose. The cancelled-paid → suspended rule closes the last branch of that pair: a win-back whose payment declines used to match no rule at all, so the webhook completed with no transition, no log and no analytics ([#223](https://github.com/Omega-JS-Stack/omega/issues/223)).
286
+ - `cancellation-removed` sits **behind** `payment-recovered` and requires the same product, so a recovery or a plan change that also clears the schedule keeps its own, more meaningful, name.
287
+
288
+ Two transitions are deliberately log-only:
289
+
290
+ - `checkout-declined` — the user is standing at the checkout watching the decline. No email, and no analytics either: no money moved.
291
+ - `cancellation-removed` — the `order` template has no copy for a withdrawn cancellation, and an unknown event falls back to the `confirmation` variant, which would show a subscriber a "total paid today" they were never charged. Sending the wrong email is worse than sending none, so the HANDLER stays a record until the template carries the copy. Analytics is a separate concern and does fire: a withdrawn cancellation is a retention win ([#407](https://github.com/Omega-JS-Stack/omega/issues/407)).
292
+
293
+ **`subscription-cancelled` inside the trial term is a lapse, not churn** ([#414](https://github.com/Omega-JS-Stack/omega/issues/414)). The transition and its email are unchanged (the subscription did cancel), but analytics books `trial_lapse`, because a customer who never paid cannot be lost revenue. It reaches that branch on every provider: Chargebee ends a trial with no card on file by cancelling it, and a Stripe or PayPal subscriber may simply quit mid-trial. The check reads the unified term the way the `payment-failed` lapse does, never a provider name. What stops the sweep telling the same story again is that a cancelled subscription drops out of its `status == active` candidate query; the subscription-keyed event id both paths derive only collapses a race on Meta and TikTok — GA4 deduplicates ecommerce on `transaction_id`, and `trial_lapse` is a custom event it never dedupes at all ([#656](https://github.com/Omega-JS-Stack/omega/issues/656)).
294
+
295
+ **A trial that already lapsed is not booked twice.** The provider suspends the subscription at the failed charge, which is where the lapse is reported, then exhausts dunning and cancels what it was holding. That second webhook books **nothing**: its prior state is a suspension whose term never moved past the trial's end, so the outcome it carries was told already, and booking it added paid-churn revenue plus a Meta/TikTok audience signal behind a customer who never paid. A subscriber who converted and lapsed on dunning months later is untouched by the rule, because their term did move.
296
+
297
+ **The cancelled payload's own evidence overrules the record on file.** The prior state is a stored delivery like any other, so a degraded one carries no term and reads as a long-past trial; a cancellation whose payload names a term reaching past the trial's end is a subscriber who paid, whatever the record lost. Neither the lapse nor the silence above applies then. Both rules read `before` from the user doc, so a cancellation with no prior subscription at all books plain churn.
298
+
299
+ Which leaves `subscription_cancel` for the cancellations a trial does not explain: a subscriber who left their trial behind, and one who never had a trial at all. It is no longer where a never-paid trialist lands.
300
+
301
+ **A conversion does not depend on which webhook arrives first** ([#697](https://github.com/Omega-JS-Stack/omega/issues/697)). The first real charge after a trial has no transition at all — it is a payment event, exactly like a renewal — and what tells the two apart used to be the stored doc's in-trial-ness at charge time. That is not a signal: Stripe delivered `customer.subscription.updated` (trial over, term pushed out) a full hour before `invoice.payment_succeeded`, so the doc had already left the trial by charge time and the conversion booked as month two. The two webhooks race in real time, so an arbitrary share of conversions mislabeled — revenue stayed right (both ride the same charge-keyed purchase), the funnel's most valuable step did not. So the label reads two DURABLE facts instead, and both read the same whichever webhook lands first: the subscription ever claimed a trial (`trial.claimed`, true for its whole life), and its trial has no `trial.outcome` on record yet. The charge that converts stamps `converted` in the same batch that writes the subscription, which is what makes the next charge an ordinary renewal — state, not reporting, so it is written whether or not the analytics fire ran, and `isTrialConversion()` is the one predicate the label and the write both read. Its limit: a subscription that converted before the stamp existed carries none, so its next renewal books one conversion.
302
+
303
+ `subscription-winback` sends the customer the same order confirmation a first subscription does — same template, same computed totals — by calling `new-subscription.js` rather than keeping a second copy of it. Analytics fires a **purchase** (`reason: 'winback-purchase'`, non-recurring, at what the customer actually paid) instead of the renewal the payment event would otherwise have been read as.
304
+
305
+ All email-sending transition handlers send via `template: 'order'` + `data.order.event: '<event>'`. The single `order.js` template handles all 9 event types — no per-event template files. See [docs/email-system.md](email-system.md) for the full template system reference.
306
+
307
+ Note: Trials are NOT a separate transition. The `new-subscription` handler checks `after.trial.claimed` to determine if the subscription started with a trial.
308
+
309
+ ### One-Time Transitions
310
+
311
+ | Transition | Event Type | File | Email event |
312
+ |---|---|---|---|
313
+ | `purchase-refunded` | `charge.refunded` (Stripe), `PAYMENT.SALE.REFUNDED` (PayPal), `payment_refunded` (Chargebee) | `transitions/one-time/purchase-refunded.js` | `refunded` |
314
+ | `purchase-completed` | `checkout.session.completed`, `CHECKOUT.ORDER.APPROVED`, `payment_succeeded` (Chargebee), `charge:confirmed` (Coinbase) | `transitions/one-time/purchase-completed.js` | `confirmation` |
315
+ | `purchase-failed` | `invoice.payment_failed`, `payment_failed` (Chargebee) | `transitions/one-time/purchase-failed.js` | `payment-failed` |
316
+
317
+ **Every one-time transition mails, through its subscription twin's generator** ([#673](https://github.com/Omega-JS-Stack/omega/issues/673)). `purchase-refunded` and `purchase-failed` were log-only stubs, so a customer refunded on a one-time purchase heard about it from their bank statement; both now send the same `order` template + event the subscription side sends (`subscription/payment-refunded.js`, `subscription/payment-failed.js`), and `purchase-refunded` keeps its structured amount/currency/reason log line as the operator's record.
318
+
319
+ **Two Coinbase events map to NO transition on purpose** ([#642](https://github.com/Omega-JS-Stack/omega/issues/642)). `charge:pending` and `charge:failed` are both parsed and both write their order — so the account page's Orders list tells the truth about a crypto purchase at every stage — but neither mails. `charge:pending` is crypto detected on-chain and not yet confirmed, so there is nothing settled to book; `charge:failed` is a hosted charge that expired unpaid, which is the ABANDONED-CHECKOUT population (the buyer walked away from the page in front of them), exactly the population `checkout-declined` refuses to mail. An abandoned Stripe session sends nothing either, because Stripe emits no event for it at all.
320
+
321
+ `purchase-failed` mails where the subscription side's first-checkout twin (`checkout-declined`) deliberately does not, because the populations are opposites. It is reached by an `invoice.payment_failed` whose `billing_reason` no subscription owns — a **manual invoice** (`routes/payments/webhook/providers/stripe.js` routes it to `one-time`; Chargebee's `payment_failed` with no subscription lands in the same category, though `detectOneTimeTransition()` only maps Stripe's event name today). Nobody is standing at a checkout watching a manual invoice fail, so the failure reaches the customer only if this handler tells them.
322
+
323
+ **Idempotency:** EVERY transition on this side is detected from the event type alone, so the `previouslyCompleted` guard covers the whole one-time side — a webhook doc that already completed once (a redelivery, or a doc put back to `pending`) detects nothing, and the customer is not emailed twice about the same purchase or refund. The subscription side applies the same guard to its one event-type-only path, `payment-refunded`.
324
+
325
+ ### Handler Interface
326
+
327
+ All handlers are in `src/manager/events/firestore/payments-webhooks/transitions/` and export a single async function:
328
+
329
+ ```javascript
330
+ module.exports = async function ({ before, after, uid, userDoc, admin, ctx, Manager, eventType, eventId }) {
331
+ // before: previous subscription state (null for new/one-time)
332
+ // after: new unified state (subscription or one-time)
333
+ // userDoc: full user document data
334
+ // eventType: original webhook event type (e.g., 'customer.subscription.updated')
335
+ // eventId: webhook event ID
336
+ };
337
+ ```
338
+
339
+ ### Creating a New Transition Handler
340
+
341
+ 1. Add detection logic in `transitions/index.js` (in priority order)
342
+ 2. Create handler file in `transitions/{category}/{name}.js`
343
+ 3. Handler receives full context — use `ctx.log()` for logging, `Manager.getApiUrl()` for API calls
344
+
345
+ ## Subscription Management Routes
346
+
347
+ Owned, cross-provider endpoints for managing a live subscription. They write **no subscription state** — they delegate to the provider and let the resulting webhook drive the pipeline.
348
+
349
+ ### POST /payments/cancel
350
+
351
+ Ends the caller's subscription: at the close of the current billing period normally, **immediately when the subscription is still in its free trial** (Ian's ruling, 2026-08-15 — we do not keep serving a trial we know will not convert). Input: `reason`, `feedback`, `confirmed`, and the privileged `skipGuards`.
352
+
353
+ Guards: authenticated, `confirmed: true`, an active or suspended paid subscription, no cancellation already pending, a known provider and resource id, and the **24-hour young-subscription guard**. `skipGuards` is honored only for an admin or outside a real deployment (the suites and the dev palette cancel seeded subscriptions minutes old); every other caller is ignored, loudly.
354
+
355
+ **A trial is exempt from the 24-hour guard.** That guard exists to stop a cancellation racing a PAID checkout that is still settling, and a trial has no payment to settle — blocking it told the most common trial behavior there is, cancelling the same day you started, that the subscription "is still being set up" ([#267](https://github.com/Omega-JS-Stack/omega/issues/267)).
356
+
357
+ **"Still inside the trial" has ONE definition**, `routes/payments/cancel/_is-trialing.js` — the trial is claimed, the subscription is `active`, and `expires` still equals `trial.expires` (conversion moves `expires` out to the end of the first paid period while `trial.expires` stays put, so the two stop matching the moment real money is involved; an expiry missing on both sides reads false, because a guard must never be waived by absent data). The route and all four cancel providers consult that one function, which is what keeps the guard waiver and the cancel mode from disagreeing — they were three per-provider copies of the same comparison before.
358
+
359
+ **Analytics consults it too, with one documented widening** (`events/firestore/payments-webhooks/analytics.js` `isInsideTrial()`, which the trial-lapse sweep also reads). Chargebee's in-trial payload names no `current_term_end` at all, so `expires` folds to the epoch and the timestamp match cannot see a Chargebee trial — every Chargebee conversion read as a renewal and every Chargebee lapse reported nothing ([#407](https://github.com/Omega-JS-Stack/omega/issues/407)). An epoch expiry is the ABSENCE of a term, not a term that ended in 1970, so for REPORTING a claimed trial with a real trial expiry and no term at all still counts. That widening stays out of `_is-trialing.js` deliberately: the shared predicate also waives the 24-hour guard, and a guard must never be waived by missing data. Reporting carries no such stake.
360
+
361
+ **The widening is bounded by the state the payload arrived FROM** ([#414](https://github.com/Omega-JS-Stack/omega/issues/414)). A degraded delivery carried no term either: the pipeline used to hand over the webhook's own body when the provider API was unreachable, and an active PAID subscription in that body is shaped exactly like a Chargebee trial — same claimed trial, same epoch expiry. Nothing inside such a payload separates them, so the prior state does. That degraded path no longer exists at all — a lookup the provider cannot answer now refuses or defers rather than processing the body ([above](#a-lookup-the-provider-cannot-answer-never-processes-the-payload)) — and the bound stays as the guard for any other object that reaches the transformers without a term. A subscription already paid through a real term did not lose that term by trialing, so a term missing there is a hole in the delivery and the widening does not apply. Unbounded, a renewal on that path matched no branch at all and booked nothing: real revenue, silently unreported. The bound needs one healthy delivery behind it, which is its limit: two degraded deliveries in a row leave no term on either side, so that renewal still books nothing. It fails toward silence, never toward a fabricated number.
362
+
363
+ The sweep passes no prior state and keeps the unbounded widening. Two other things bound it there: its candidate query only reaches trials whose `trial.expires` sits between 30 days and 24 hours ago, and `trial.outcome` is stamped once and never revisited, so a degraded record can be misread at most once and only inside that window.
364
+
365
+ How each provider performs the immediate half:
366
+
367
+ | Provider | Trial cancel | Paid cancel |
368
+ |---|---|---|
369
+ | **Stripe** | `subscriptions.cancel()` — the subscription ends now | `subscriptions.update({ cancel_at_period_end: true })` |
370
+ | **Chargebee** | `cancel_for_items` with `cancel_option: 'immediately'` | the same call with `end_of_term` |
371
+ | **PayPal** | the one cancel endpoint, with a trial `reason` — immediacy is enforced on OUR side (below) | the same endpoint; the remaining paid term rides as `cancellation.pending` |
372
+ | **test** | fabricates `customer.subscription.deleted` | fabricates `customer.subscription.updated` with `cancel_at_period_end` |
373
+
374
+ **PayPal has no second cancel mode**, so the immediacy lives in the unified transform: `calculatePeriodEnd()` returns null for a subscription still inside its trial WINDOW (the trial end computed from `start_time` + the plan's `TRIAL` cycle is still ahead), so the cancellation webhook resolves it to `cancelled` with nothing pending and no future expiry. Reading the payment record instead handed a cancelled trialer a full paid period whenever the plan charged a setup fee on day zero — `billing_info.last_payment` exists during a trial, and it is not a billing period.
375
+
376
+ ### POST /payments/uncancel
377
+
378
+ Withdraws a scheduled cancellation, so the subscription renews as normal instead of ending at the close of the current billing period. Input: `confirmed`.
379
+
380
+ Guards: authenticated, `confirmed: true`, an ACTIVE paid subscription whose `cancellation.pending === true`, and a known provider.
381
+
382
+ Delegates to the provider — Stripe clears `cancel_at_period_end`, Chargebee calls `remove_scheduled_cancellation` — and the webhook that follows fires the `cancellation-removed` transition. The only thing the route writes is the withdrawal of the request itself: `payments-orders/{orderId}.requests.cancellation` is cleared, because leaving the reason/feedback behind would misreport the order's state.
383
+
384
+ **PayPal is not supported.** PayPal cannot resume a cancelled subscription — its only reactivation verb (`/v1/billing/subscriptions/{id}/activate`) works on a SUSPENDED subscription, and a cancelled one is terminal. PayPal also has no cancel-at-period-end: our cancel route cancels the PayPal subscription outright and the pipeline represents the remaining paid term as `cancellation.pending`, so a PayPal subscriber reading "pending cancellation" is already cancelled at PayPal.
385
+
386
+ ### POST /payments/plan
387
+
388
+ Moves a live subscription to another product and/or billing frequency without cancel-and-resubscribe. Input: `productId`, `frequency`, `confirmed`.
389
+
390
+ Guards: authenticated, `confirmed: true`, an active paid subscription with no cancellation scheduled, and a target that is a subscription product the brand sells at that frequency and is not the plan the caller is already on.
391
+
392
+ Two of those refusals carry a **machine-readable code** on the `omega-properties` header's `additional.code`, the same shape the [capability gate](#capability-gating) ships under — the client branches on the code, never on the sentence:
393
+
394
+ | Code | When | Why it is a refusal |
395
+ |---|---|---|
396
+ | `already-on-plan` | Same product AND same frequency — **or** the same product when `payment.frequency` is unrecorded | A no-op costs a real provider call and a real webhook. Same product at a DIFFERENT *recorded* frequency is a real switch and stays allowed; with nothing recorded to compare, `'monthly' === undefined` is false and the no-op would sail through, so every cadence of the current product is refused until the backend records one. |
397
+ | `cancellation-pending` | `cancellation.pending === true` | The providers swap the PRICE, never the schedule — a switch here would land the caller on a new plan still set to end at period end, silently. Undo the cancellation first. |
398
+
399
+ **The guards never lean on the client's filtering.** The billing page hides Change while a cancellation is pending and renders the current plan disabled, but that is courtesy: the modal's own filter silently missed whenever `payment.frequency` was unrecorded, which is how a same-plan switch reached the provider in the first place ([#237](https://github.com/Omega-JS-Stack/omega/issues/237)).
400
+
401
+ **A switch never grants, resets, or extends a trial** (Ian 2026-08-14). A mid-trial switch CARRIES the trial over — same original end date, new plan — and `trial.claimed` stays claimed. The route writes no state, so each provider's `switchPlan()` preserves it through the swap — and each one restates the date from the **live provider object**, never from our own user doc, which can lag the provider and would make the preserving route the thing that MOVED the trial: Stripe restates `trial_end` off the subscription it already retrieved to find the item, and Chargebee GETs `/subscriptions/{id}` and restates that object's `trial_end` when its status is `in_trial`. A trial already over is left alone in both (Stripe rejects a past `trial_end`, and its dates stay on the object anyway). The test provider carries `trial_start`/`trial_end` onto the event it fabricates — and, while the trial is live, sets the current period TO the trial period, the way Stripe reports a trialing subscription: the cancel flow's shared classifier ([`_is-trialing.js`](#post-paymentscancel), read by the route and every provider) decides a cancellation is immediate on that `trial.expires === expires` equality, so a fabricated 30-day period would have broken trial-cancel immediacy after a switch. Fabricating the trial dates as null is exactly what ended a live trial on switch: the unified transform reads `trial.claimed` straight off the event. PayPal's `revise` takes no trial parameter — its trial is derived from the plan's `TRIAL` billing cycle anchored to the original `start_time`, so a revise cannot extend a trial past what it would have been from day one, but an unequal trial LENGTH on the target plan can still shift the end date.
402
+
403
+ | Provider | How it switches |
404
+ |---|---|
405
+ | **Stripe** | Updates the existing subscription ITEM with the new price, `proration_behavior: 'create_prorations'` (the item's id must be sent or Stripe adds a second item instead of replacing the first) |
406
+ | **Chargebee** | `update_for_items` with the deterministic `{itemId}-{frequency}` item price the checkout builds |
407
+ | **PayPal** | `revise` — PayPal's first-class plan change, prorated by its own rules |
408
+
409
+ The webhook that follows fires the existing `plan-changed` transition.
410
+
411
+ ### POST /payments/winback
412
+
413
+ Applies the cancel-flow **save offer** to a live subscription, so the cancel the customer started never happens ([#268](https://github.com/Omega-JS-Stack/omega/issues/268)). The billing card pitches it before the cancellation questionnaire; accepting calls this route, declining opens the questionnaire unchanged. Input: `confirmed`.
414
+
415
+ The offer is the **brand's**, not this route's: `payment.winback` in omega.json5, resolved by `@omega.js/config`'s `resolveWinbackOffer()` — 50% off the next cycle when a brand configures nothing, `enabled: false` to turn it off entirely ([docs/shared/config.md](../../../docs/shared/config.md#the-cancel-flow-save-offer-paymentwinback--268)). The web build resolves the same section through the same function into its client blob, so the dialog and the coupon can never name different numbers.
416
+
417
+ Guards: authenticated, `confirmed: true`, the offer enabled for this brand, an ACTIVE paid subscription that is **not** inside its free trial (`_is-trialing.js` — a trial cancel is immediate and nothing has been paid, so there is no next cycle to discount) and has no cancellation scheduled, a known provider, and **an order doc carrying no claim yet**.
418
+
419
+ The offer reaches the provider as a discount-codes `validate()` result (`libraries/payment/winback.js`), so the coupon plumbing is the checkout's: Stripe's `StripeLib.resolveCoupon()` builds the same deterministic, reused coupon a discount code does, and `subscriptions.update({ discounts: [...] })` attaches it. Stripe's `discounts` parameter **replaces** every discount already on the subscription rather than adding to them, so an existing coupon is dropped when the offer's lands. The subscription itself is untouched — same plan, same cadence, same renewal date — and the route writes no subscription state.
420
+
421
+ **Claimed once.** `payments-orders/{orderId}.requests.winback` records the discount and when it was taken, and a second call is refused against that same document — an offer takeable every time the cancel dialog opens is a permanent discount nobody agreed to. That refusal carries `offer-already-claimed` on `omega-properties` (`additional.code`), because the client reads the ACCOUNT and the claim lives on the order doc, so a past claimant is pitched the offer again and the billing card needs a code to retire it on ([#310](https://github.com/Omega-JS-Stack/omega/issues/310)). A subscription carrying no `payment.orderId` has nowhere to record the claim, so it is refused before dispatch with `offer-not-claimable` on `omega-properties` (`additional.code`) instead of being handed an offer this route cannot remember. The accept is also recorded server-side (`ctx.analytics.event('payments/winback', …)`), which is what the experiment is measured with against the existing `subscription-winback` transition baseline; the client counts offer-shown and offer-declined, which never reach a server.
422
+
423
+ **Every refusal is named.** Each 400 this route returns carries a machine-readable code on `omega-properties` (`additional.code`), in refusal order: `confirmation-required`, `offer-disabled`, `no-active-subscription`, `trial-not-eligible`, `cancellation-pending`, `missing-payment-details`, `unknown-provider`, `not-supported-by-provider`, `offer-not-claimable`, `offer-already-claimed` ([#311](https://github.com/Omega-JS-Stack/omega/issues/311)). The client pitches the offer off the ACCOUNT alone, so a state it cannot see reaches accept — and a refusal it cannot name leaves the customer in a dialog arming a retry that can never succeed. All but `confirmation-required` are dead ends for that account (the billing card's `deadEndCodes`): it retires the offer and opens the questionnaire on them, while an unconfirmed request — which the same button sending it again fixes — leaves the offer armed. The card also gates the PITCH on `subscription.payment.provider` and `payment.resourceId`, so an admin-granted or imported subscription with no provider details is never offered a discount the route could only refuse.
424
+
425
+ **Only Stripe and the test provider apply it.** PayPal has no coupon or discount object at all, and Chargebee has coupons but no existing plumbing that reaches a LIVE subscription with one (`update_for_items` REPLACES the subscription's items, so carrying a coupon through it would mean restating the live item set on every offer — a re-pricing risk taken for a discount). Both declare that by exporting nothing, and the billing card retires the offer for the session and opens the questionnaire on the refusal, so a subscriber on either can always still cancel.
426
+
427
+ ### Capability gating
428
+
429
+ All three routes are capability-gated the same way: **a provider module that supports the operation exports it; one that cannot lacks the export.** PayPal's `uncancel/providers/paypal.js` and both `winback/providers/{paypal,chargebee}.js` are deliberately empty for exactly this reason (the file still has to exist, or the route would answer "Unknown provider" — a different and wrong statement).
430
+
431
+ The route checks the export and refuses **before dispatch**, so the caller never discovers the limit as a provider error:
432
+
433
+ - HTTP **400**, with a sentence pointing at the billing portal
434
+ - `not-supported-by-provider` on the response's `omega-properties` header under `additional.code` — @omega.js/client surfaces it as `error.properties.additional.code`, so a client branches on the code, not the sentence
435
+
436
+ The billing portal (`POST /payments/portal`) stays the fallback for anything a provider will not do here.
437
+
438
+ ## Refunds
439
+
440
+ `POST /payments/refund` has **two subjects, one endpoint** — which one is decided by whether `orderId` is present. Both store the reason/feedback on `payments-orders/{orderId}.requests.refund` in one shape, and both let the resulting webhook drive the pipeline.
441
+
442
+ | Input | Subject | Guards |
443
+ |---|---|---|
444
+ | No `orderId` | The caller's **subscription** — refunds the latest payment and cancels immediately | Authenticated, `confirmed`, a paid subscription, already cancelled or pending cancellation, inside the 6-month window |
445
+ | With `orderId` | A **one-time purchase**, named by its `payments-orders` doc | Authenticated, `confirmed`, the order is the caller's own, `type: 'one-time'`, not already refunded, inside the 6-month window |
446
+
447
+ Notes on the one-time branch:
448
+
449
+ - A one-time purchase writes nothing to `users/{uid}.subscription`, so the order IS the subject — there is no subscription state to check and nothing to cancel.
450
+ - A missing order and somebody else's order answer **identically** ("Order not found"): an order id must never be a probe for whether another user's purchase exists.
451
+ - "Already refunded" covers both paths — `requests.refund` (the in-app path) and `unified.status === 'refunded'` (a refund issued from the provider dashboard, which arrives by webhook and writes no request).
452
+ - One-time refunds are always **FULL**. Stripe, PayPal, Chargebee and the test provider implement `processOneTimeRefund` ([Provider Interface](#provider-interface)).
453
+ - **A crypto purchase is refused before any provider is loaded** ([#642](https://github.com/Omega-JS-Stack/omega/issues/642)). Coinbase Commerce has no refund API at all: returning coins is a manual transfer the merchant makes from its dashboard, at whatever the coin is worth that day, and nothing about it is ever attached to the charge. So `refund-policy.js` carries a `NO_REFUND_PROVIDERS` list and answers `provider-cannot-refund` — in the SAME predicate the account page reads, so the Orders list never offers a button the route would 400. Reaching the provider instead would have answered the customer "try again shortly" for something no retry can fix. `refund/providers/coinbase.js` still exists and still throws from both halves: a missing file makes the route say "Unknown provider", a different and wrong statement ([Capability gating](#capability-gating)).
454
+
455
+ The refund window is 6 months on both subjects, measured from the subscription's `payment.startDate` or the order's created timestamp; an absent date cannot disqualify a refund.
456
+
457
+ **The one-time lane is reachable from the account page** ([#672](https://github.com/Omega-JS-Stack/omega/issues/672)). `GET /user/orders` is the read path — `payments-orders` is admin-only to clients and a one-time purchase writes nothing to `users/{uid}`, so the route reads the caller's own orders through the admin SDK and the Firestore rules stay untouched. It hands back a SUMMARY per order (what was bought, when, for how much, its status), never the provider's raw resource, the request context or the attribution. Each summary carries `refundable`, and that answer is `libraries/payment/refund-policy.js`'s `oneTimeRefundRefusal()` — the same predicate the guards above run — so a refund button the account page offers is a refund this route accepts. The account page's Orders section lists them and its Refund section posts the picked `orderId`.
458
+
459
+ What the pipeline then does with the refund webhook is in [A refund merges into the purchase](#a-refund-merges-into-the-purchase).
460
+
461
+ ## Provider Interface
462
+
463
+ **Which key a provider initializes with is the env chain's answer, and only the brand's overlay keeps a live key out of a local run.** Every provider reads its secret through the ONE env reader under the SAME name in every environment, and nothing inspects the value ([#586](https://github.com/Omega-JS-Stack/omega/issues/586)). The whole contract, and why the `.env.development` overlay is the brand's own opt-in, is one paragraph: [docs/backend/index.md](../../../docs/backend/index.md#the-env-reader-librariesenvjs--581).
464
+
465
+ A deploy without an OMEGA license runs no live payments: `libraries/payment/license.js` refuses Stripe/PayPal/Chargebee `init()` when the deploy stamped `OMEGA_LICENSE_STATUS=keyless` into the artifact's `.env`. The `test` provider is never gated, and an absent status — every local lane, the emulator, every test — behaves exactly as it always has. The check itself is a deploy-time question the CLI asks; nothing here phones home. Contract: [docs/shared/publishing.md](../../../docs/shared/publishing.md#the-license-check-320).
466
+
467
+ Each provider implements three modules:
468
+
469
+ A provider implements as much of this as its API HAS. Coinbase Commerce sells one-time charges and nothing else, so it ships an intent, a webhook, a refund refusal and a library, and no cancel/portal/plan/uncancel/winback module at all — every one of those routes acts on a subscription it can never have ([Coinbase Commerce (crypto) is one-time only](#coinbase-commerce-crypto-is-one-time-only)).
470
+
471
+ **Intent provider** (`routes/payments/intent/providers/{provider}.js`):
472
+
473
+ ```javascript
474
+ module.exports = {
475
+ async createIntent({ uid, orderId, product, productId, frequency, trial, discount, confirmationUrl, cancelUrl, Manager, ctx }) {
476
+ return { id, url, raw };
477
+ },
478
+ };
479
+ ```
480
+
481
+ **Webhook provider** (`routes/payments/webhook/providers/{provider}.js`):
482
+
483
+ ```javascript
484
+ module.exports = {
485
+ isSupported(eventType) { return boolean; },
486
+ parseWebhook(req) { return { eventId, eventType, category, resourceType, resourceId, refundId, raw, uid }; },
487
+ };
488
+ ```
489
+
490
+ **Cancel provider** (`routes/payments/cancel/providers/{provider}.js`):
491
+
492
+ ```javascript
493
+ module.exports = {
494
+ async cancelAtPeriodEnd({ resourceId, uid, subscription, ctx }) { /* cancel at end of period */ },
495
+ };
496
+ ```
497
+
498
+ **Refund provider** (`routes/payments/refund/providers/{provider}.js`):
499
+
500
+ ```javascript
501
+ module.exports = {
502
+ async processRefund({ resourceId, uid, subscription, ctx }) {
503
+ return { amount, currency, full };
504
+ },
505
+ // The ONE-TIME half. All four providers implement it, and a one-time refund is
506
+ // always FULL: Stripe refunds the session's payment_intent, PayPal refunds the
507
+ // completed capture, Chargebee refunds the invoice, test fabricates the webhook.
508
+ async processOneTimeRefund({ resourceId, uid, order, ctx }) {
509
+ return { amount, currency, full };
510
+ },
511
+ };
512
+ ```
513
+
514
+ **Uncancel provider** (`routes/payments/uncancel/providers/{provider}.js`) — optional; a missing export IS the capability declaration ([Capability gating](#capability-gating)):
515
+
516
+ ```javascript
517
+ module.exports = {
518
+ async uncancel({ resourceId, uid, subscription, ctx }) { /* clear the scheduled cancellation */ },
519
+ };
520
+ ```
521
+
522
+ **Plan provider** (`routes/payments/plan/providers/{provider}.js`) — optional, same gate:
523
+
524
+ ```javascript
525
+ module.exports = {
526
+ async switchPlan({ resourceId, uid, subscription, product, productType, frequency, ctx }) { /* move the subscription */ },
527
+ };
528
+ ```
529
+
530
+ **Winback provider** (`routes/payments/winback/providers/{provider}.js`) — optional, same gate; `discount` is a discount-codes `validate()` result built from the brand's `payment.winback`:
531
+
532
+ ```javascript
533
+ module.exports = {
534
+ async applyOffer({ resourceId, uid, subscription, discount, ctx }) { /* discount the next cycle */ },
535
+ };
536
+ ```
537
+
538
+ **Portal provider** (`routes/payments/portal/providers/{provider}.js`):
539
+
540
+ ```javascript
541
+ module.exports = {
542
+ async createPortalSession({ resourceId, uid, returnUrl, ctx }) {
543
+ return { url };
544
+ },
545
+ };
546
+ ```
547
+
548
+ **Shared library** (`libraries/payment/providers/{provider}.js`):
549
+
550
+ ```javascript
551
+ module.exports = {
552
+ init() { /* return SDK instance */ },
553
+ async fetchResource(resourceType, resourceId, context) { /* return the provider's answer, or throw */ },
554
+ extractResource(raw) { /* return the resource this provider's webhook envelope carries */ },
555
+ getOrderId(resource) { /* return orderId string or null */ },
556
+ getUid(resource) { /* return the uid the provider's record carries, or null */ },
557
+ async getRefundDetails(resource, options) { /* { raw, refundId, eventType, resourceType, ctx } → { amount, currency, reason } from provider data, linked back to `resource` */ },
558
+ toUnifiedSubscription(rawSubscription, options) { /* return unified object */ },
559
+ toUnifiedOneTime(rawResource, options) { /* return unified object */ },
560
+ };
561
+ ```
562
+
563
+ **`fetchResource()` never falls back.** It returns what the provider answered or throws the classified failure the pipeline branches on ([above](#a-lookup-the-provider-cannot-answer-never-processes-the-payload)) — a provider library that swallows its own lookup failure is the bug [#506](https://github.com/Omega-JS-Stack/omega/issues/506) closed.
564
+
565
+ **`getRefundDetails()` answers about the resource it was handed.** The `options` bag carries the lookup KEYS the envelope supplies (`raw`, `refundId`) and the `resourceType` that says which back-pointer to read; the record it fetches is asserted against `resource` through `libraries/payment/refund-linkage.js` before its numbers are returned ([above](#a-refund-record-has-to-belong-to-the-event-that-named-it)).
566
+
567
+ **Every library names its own envelope.** `extractResource(raw)` reads the resource out of the event body, for its IDENTIFIERS — which order a failed event belonged to, and the body the test provider answers its own lookups from. Each provider nests it somewhere else, so reading Stripe's shape for everyone resolved every other provider's to nothing ([#222](https://github.com/Omega-JS-Stack/omega/issues/222)):
568
+
569
+ | Provider | Envelope |
570
+ |---|---|
571
+ | **Stripe** | `data.object` |
572
+ | **Chargebee** | `content.<type>` — `content.subscription` first, then `content.invoice` (the same precedence its webhook parser categorizes on) |
573
+ | **PayPal** | `resource` |
574
+ | **Coinbase Commerce** | `event.data` — the delivery envelope wraps an EVENT, and the charge is that event's data |
575
+ | **Test** | delegates to Stripe's (it generates Stripe-shaped payloads) |
576
+
577
+ Stripe's `fetchResource()` also handles `'charge'`, the resource a one-time refund arrives as. It expands the `payment_intent`, because a charge inherits its metadata from the PaymentIntent that created it — when the charge itself carries none, the intent is the only place `uid`/`orderId`/`productId` live. (The checkout sets them there for exactly this reason: `payment_intent_data.metadata`, not the session's.)
578
+
579
+ ## Product Resolution
580
+
581
+ Products are resolved differently per provider, but always end up matching a product in `config.payment.products`:
582
+
583
+ | Provider | Resolution chain | Stable ID |
584
+ |-----------|-----------------|-----------|
585
+ | **Stripe** | `sub.items.data[0].price.product` or `raw.plan.product` → match `product.stripe.productId` or `legacyProductIds` | `prod_xxx` |
586
+ | **PayPal** | `sub → plan_id → plan → product_id` → match `product.paypal.productId` | PayPal catalog product ID |
587
+ | **Coinbase Commerce** | `charge.metadata.productId` → match `product.id` directly (the charge is created with our own metadata; there is no Coinbase catalog to reconcile against) | our own product id |
588
+ | **Test** | Uses `product.stripe.productId` in Stripe-shaped data | Same as Stripe |
589
+
590
+ Falls back to `{ id: 'basic' }` if no match found.
591
+
592
+ ## Provider-Specific Details
593
+
594
+ **Stripe:** Uses `metadata.uid` and `metadata.orderId` on subscriptions for UID/order resolution.
595
+
596
+ **PayPal:** Uses `custom_id` field on subscriptions with format `uid:{uid},orderId:{orderId}`. Product resolution fetches the plan from the subscription, then gets `product_id` from the plan. Plans are scoped by `product_id` query param to avoid cross-brand matches on shared PayPal accounts.
597
+
598
+ ### Coinbase Commerce (crypto) is one-time only
599
+
600
+ The crypto provider ([#642](https://github.com/Omega-JS-Stack/omega/issues/642)) is the first one that implements a SUBSET of the interface, because the API it wraps has a subset of the concepts. Coinbase Commerce sells a hosted **charge**: one payment, one price, one page. There is no subscription, plan, billing agreement, coupon, portal or refund object anywhere in it.
601
+
602
+ | Question | Coinbase Commerce's answer |
603
+ |---|---|
604
+ | **Switch** | `payment.providers.coinbase.enabled` — the ONLY provider whose switch is an explicit `enabled` (default OFF) rather than a public datum, because its whole credential is the secret `COINBASE_COMMERCE_API_KEY` and a secret never lives in config |
605
+ | **Auth** | `X-CC-Api-Key` + `X-CC-Version: 2018-03-22` on every call. There is no sandbox HOST — a test-mode key against the same endpoint is the test lane |
606
+ | **Intent** | `POST /charges` with `pricing_type: 'fixed_price'`, and the buyer is redirected to the charge's `hosted_url`. A validated discount code comes off HERE (`applyToAmount`), because the price is one we compute and Coinbase has no coupon object to apply it on its own page |
607
+ | **Identifiers** | `charge.metadata` — a flat string map carrying `uid`, `orderId`, `productId`. There is no `custom_id` string to pack, the way PayPal needs |
608
+ | **Resource** | `charge`, read back at `GET /charges/{id}` (the endpoint takes the id or the short code) |
609
+ | **Status** | the LAST entry of `charge.timeline` — a charge has no status field. `COMPLETED`/`RESOLVED` → `completed`, `NEW`/`PENDING` → `pending`, `EXPIRED`/`CANCELED` → `failed`, anything else passes through lowercased |
610
+ | **Webhook** | `charge:confirmed`, `charge:pending`, `charge:failed`, all category `one-time`. `charge:created` stays out (it is our own intent call answering, not a payment); `charge:delayed` and `charge:resolved` stay out until the merchant decision they encode is specified |
611
+ | **Signature** | **not verified**, like every other provider — the `?key=` check is the one boundary ([Webhook Verification](#webhook-verification)) |
612
+ | **Subscriptions** | `toUnifiedSubscription()` THROWS, and the intent provider refuses a subscription product before it calls Coinbase. Fabricating one would grant recurring access off a single crypto payment |
613
+ | **Refunds** | unsupported by the provider, refused by `refund-policy.js` before dispatch ([Refunds](#refunds)) |
614
+ | **Cancel / portal / plan / uncancel / winback** | no module at all: every one of those routes acts on a SUBSCRIPTION, and no coinbase subscription can exist to reach them |
615
+
616
+ The checkout hides the crypto button on a subscription product for the same reason the intent provider refuses one, so the two halves cannot disagree.
617
+
618
+ **The key is asked for through the shared setup contract** (#608): `COINBASE_COMMERCE_API_KEY` is declared in the manager's REQUIRES registry and offered by `services/payment/lib/provider-setup.js` — the only provider flow with no public field to collect, so its gate leads straight to the secret paste, and its "Disable permanently" lands on `payment.providers.coinbase.enabled` rather than on the provider block. The ask fires only when that switch is explicitly `true`; a brand that never turned crypto on is never asked. Beyond the key, the payment service reconciles nothing for Coinbase — there is no catalog, and its webhook endpoint is registered by hand in the Coinbase dashboard.
619
+
620
+ **Open product question — the buyer reaches the confirmation page before the coins confirm.** Coinbase redirects to `redirect_url` the moment the payment is submitted, while the charge is still `PENDING`, and a one-time confirmation page opens CONFIRMED without polling ([#668](https://github.com/Omega-JS-Stack/omega/issues/668)). So a crypto buyer sees "confirmed" first and their receipt email arrives when `charge:confirmed` lands, which can be minutes later. Nothing here decides that yet; it is the one-time confirmation contract, and changing it is Ian's call.
621
+
622
+ ### Subscriptionless refunds are one-time
623
+
624
+ A refund with no subscription behind it is the refund of a **one-time purchase**, and all three webhook parsers now categorize it as `one-time` instead of dropping it (`category = null`, which meant such a refund never entered the pipeline at all):
625
+
626
+ | Provider | Event | Resource it resolves to |
627
+ |---|---|---|
628
+ | **Stripe** | `charge.refunded` with no subscription and no invoice | `charge` — the charge itself (`data.object.id`) |
629
+ | **PayPal** | `PAYMENT.SALE.REFUNDED` with no billing agreement | `sale` — the sale it reversed (`resource.sale_id` or `resource.id`) |
630
+ | **Chargebee** | `payment_refunded` with no subscription in `content` | `invoice` — the invoice it refunded |
631
+
632
+ PayPal's `fetchResource()` handles `'sale'` the same way, and it is a two-step read: it GETs the v1 sale (`/v1/payments/sale/{id}`) the refund reversed, and a v1 sale carries **no `custom_id`** — so when the sale names a `parent_payment`, that payment is fetched too and its transaction's `custom` (or `custom_id`) is folded onto the sale, which is the only place `uid`/`orderId`/`productId` live. The fold is best-effort, the way the subscription case's plan fetch is: an unreadable parent payment still returns the LIVE sale rather than losing it, since only its identifiers were missed. Before this branch existed, every PayPal one-time refund threw "Unknown resource type" and logged a provider-unreachable error for a fetch that was never attempted ([#224](https://github.com/Omega-JS-Stack/omega/issues/224)).
633
+
634
+ ## Product Configuration
635
+
636
+ Products are defined in `config/omega.json5` under `payment.products`, and the features they meter in the sibling top-level `features` catalog (both shared top-level sections):
637
+
638
+ ```javascript
639
+ // A feature is DEFINED once in the top-level catalog, beside `payment` rather
640
+ // than inside it, and a product names only its VALUE
641
+ // ([#647](https://github.com/Omega-JS-Stack/omega/issues/647)).
642
+ features: {
643
+ requests: {
644
+ name: 'API requests',
645
+ icon: 'bolt',
646
+ definition: 'Calls to the API you can make per month.',
647
+ usage: { pace: 'daily' },
648
+ },
649
+ },
650
+
651
+ payment: {
652
+ providers: {
653
+ stripe: { publishableKey: 'pk_live_...' },
654
+ paypal: { clientId: 'ARvf...' },
655
+ },
656
+ products: [
657
+ {
658
+ id: 'basic', // Free tier (no prices, no provider keys)
659
+ name: 'Basic',
660
+ type: 'subscription',
661
+ features: { requests: 100 },
662
+ },
663
+ {
664
+ id: 'premium', // Paid subscription
665
+ name: 'Premium',
666
+ type: 'subscription',
667
+ features: { requests: 1000 },
668
+ trial: { days: 14 },
669
+ prices: { monthly: 4.99, annually: 49.99 }, // Flat numbers; also supports 'weekly' and 'daily'
670
+ stripe: { productId: 'prod_xxx', legacyProductIds: ['prod_OLD'] },
671
+ paypal: { productId: 'PROD-abc123' },
672
+ },
673
+ {
674
+ id: 'credits-100', // One-time purchase
675
+ name: '100 Credits',
676
+ type: 'one-time',
677
+ prices: { once: 9.99 },
678
+ stripe: { productId: 'prod_yyy' },
679
+ paypal: { productId: null },
680
+ },
681
+ ],
682
+ }
683
+ ```
684
+
685
+ Key rules:
686
+ - `features` on a product is a MAP of `<catalog id>: value` — the number is the monthly limit (`-1` unlimited), and the name, icon, definition and pacing live once in the top-level `features` catalog. The retired `limits`, the per-product `features` ARRAY and the product-wide `rateLimit` are validation errors naming their replacement ([docs/shared/config.md](../../../docs/shared/config.md#the-features-catalog-features-and-a-products-values--647))
687
+ - `prices` contains **flat numbers only** — no provider-specific IDs
688
+ - Provider IDs live at the product level: `stripe: { productId }`, `paypal: { productId }`
689
+ - `stripe.productId` is stable — never changes even when prices change
690
+ - `stripe.legacyProductIds` maps old pre-migration Stripe products to this product
691
+ - Price IDs (Stripe `price_xxx`, PayPal plan IDs) are **resolved at runtime** by matching amount + interval against active prices on the provider's product
692
+ - `basic` product has no `prices` and no provider keys — it's the free tier
693
+ - `archived: true` stops offering a product to new subscribers while keeping it resolvable for existing ones
694
+
695
+ ## Firestore Collections
696
+
697
+ | Collection | Key | Purpose |
698
+ |---|---|---|
699
+ | `payments-intents/{orderId}` | Order ID | Intent metadata (provider, product, status) |
700
+ | `payments-webhooks/{eventId}` | Provider event ID | Webhook processing state + transition result + `refusal`, the stamp on an event the pipeline REFUSED to act on ([above](#a-refund-with-no-purchase-behind-it-is-refused-not-minted)). One of five reasons: `resource-not-found`, `uid-mismatch`, `refund-not-linked`, `refund-without-order`, `user-without-auth` |
701
+ | `payments-orders/{orderId}` | Order ID | Unified order data (single source of truth for orders) |
702
+ | `users/{uid}.subscription` | User UID | Current subscription state (subscriptions only) |
703
+
704
+ ### payments-webhooks retry state
705
+
706
+ Two fields on the event doc carry the retry ladder:
707
+
708
+ | Field | Meaning |
709
+ |---|---|
710
+ | `retryCount` | How many times processing this event has failed. The on-write trigger increments it every time it marks the doc `failed`. |
711
+ | `deadLetter` | Terminal latch. `true` means the doc will never be re-flipped — because it burned its retries, or because the trigger stamped it on the FIRST attempt for a [permanent failure](#a-lookup-the-provider-cannot-answer-never-processes-the-payload). |
712
+
713
+ **A REFUSED event that could not record its own refusal writes nothing else.** If the refusal stamp's own Firestore write throws, the doc is marked `failed` (so the sweep below re-pends it and the next pass re-decides and re-stamps) and the failure path stops there: it does NOT fall back to resolving an orderId out of the payload and marking `payments-intents/{orderId}` failed. That was the one doc a refused forgery could still reach, named entirely by the caller ([#535](https://github.com/Omega-JS-Stack/omega/issues/535)). Failures that are not refusals still close their intent out, exactly as before.
714
+
715
+ The webhook route answers the provider `200` the moment the event is stored, so a doc the trigger marked `failed` is never delivered again — a transient fault (a provider API blip, a lost Firestore write) would drop the payment silently. The frequent cron `events/cron/frequent/retry-failed-webhooks.js` closes that: it re-flips `failed` docs back to `pending` (which is exactly what the trigger picks up) under a ceiling of **5** attempts, then stamps `deadLetter: true` **once, loudly**, and leaves the doc alone — something permanent is wrong with it and it needs a human, not another pass.
716
+
717
+ Reprocessing is safe by construction: the trigger's staleness guard and its `previouslyCompleted` guard make a second pass a no-op rather than a second charge or a second email.
718
+
719
+ **The escape hatch is a redelivery.** The webhook route's claim transaction treats `failed` as the one reclaimable state, and its write replaces the doc — so a provider redelivering the event resets the ladder (`retryCount` and `deadLetter` both go), and the dead-lettered doc gets a fresh set of attempts. That is the documented way back for an event that was dead-lettered for a fixable reason ([#220](https://github.com/Omega-JS-Stack/omega/issues/220)).
720
+
721
+ ## Payment Cron Jobs
722
+
723
+ | Job | Cadence | What it does |
724
+ |---|---|---|
725
+ | `cron/frequent/retry-failed-webhooks.js` | Frequent (10 min) | Re-flips failed webhook events to `pending` under the retry ceiling, then dead-letters ([above](#payments-webhooks-retry-state)) |
726
+ | `cron/frequent/abandoned-carts.js` | Frequent (10 min) | Mails the escalating cart reminders, and defers the ones whose checkout is still live ([below](#abandoned-cart-reminders)) |
727
+ | `cron/daily/trial-lapse-sweep.js` | Daily | Confirms expired trials with the provider and lapses the abandoned ones |
728
+ | `cron/daily/expire-paypal-cancellations.js` | Daily | Closes out PayPal pending cancellations whose term has ended |
729
+
730
+ ### Abandoned cart reminders
731
+
732
+ The checkout page writes `payments-carts/{uid}` on load (`status: 'pending'`, `reminderIndex: 0`, `nextReminderAt: now + 900`) and the frequent cron mails whatever is due, escalating through `REMINDER_DELAYS` — **15m, 3h, 24h, 48h, 72h** (`libraries/abandoned-cart-config.js`, the SSOT both sides read). A completed purchase marks the cart `completed` from the webhook pipeline, and so does a user who turns out to have an active subscription.
733
+
734
+ **A cart with checkout activity inside its window is not abandoned** ([#655](https://github.com/Omega-JS-Stack/omega/issues/655)). The reminder clock used to be set ONCE, when the page loaded, and nothing ever moved it — so a shopper who spent 20 minutes on the checkout was mailed "Complete your checkout" two minutes before their purchase went through. `POST /payments/intent` now stamps `lastActivityAt` on the cart (fire-and-forget, an `update` so a checkout reached without a cart tracker never creates one), and the sweep restarts the CURRENT reminder's own delay from that touch: the reminder is deferred, never spent, so the shopper still gets reminder #1 if this checkout goes nowhere. A cart nobody ever took to a checkout carries no activity and reads exactly as it did.
735
+
736
+ ### Trial lapse sweep
737
+
738
+ A trial that ends without converting should leave the user on `basic`. The providers say so with a webhook — and when that webhook is missed or never fires, the user keeps a paid product they never paid for, with nothing to notice it: `trial.claimed` means "this subscription HAD a trial", never "it converted".
739
+
740
+ So the sweep **asks the provider**. It never infers a lapse from dates:
741
+
742
+ 1. **Windowed candidate query** — trial claimed, subscription still `active`, and `trial.expires.timestampUNIX` between 30 days ago and 24 hours ago. The 24-hour grace exists because webhook lag at trial end is normal and PayPal's stored trial expiry is a computed estimate (PayPal fires no trial-end event); the 30-day floor keeps this a backstop for missed webhooks rather than a re-examination of every trial ever claimed.
743
+ 2. **Skip** the ones already stamped, already on `basic`, or with no provider to ask.
744
+ 3. **Fetch the live subscription** — the provider's answer is the only thing this sweep acts on, so a fetch that cannot answer waits for the next run instead of fabricating a cancellation. Only "no such subscription" counts as gone (the same `notFound` classification the webhook pipeline refuses on).
745
+ 4. **Decide.** Provider says active → the trial `converted`: stamp `trial.outcome` and touch nothing else. Gone or cancelled → the trial `lapsed`: the same end state the cancel route writes (status `cancelled`, back on `basic`, nothing pending) plus the stamp. Anything else (a suspended subscription still in dunning) → neither outcome is true yet, nothing is stamped, and the next run asks again.
746
+ 5. **Re-read and write in ONE transaction**, so a webhook that landed since the query is never clobbered. Both halves of that guard matter and both were wrong once ([#212](https://github.com/Omega-JS-Stack/omega/issues/212)): a bare read followed by a separate `set` left a window where a webhook committing between the two was overwritten anyway (the guard proved freshness at a moment that had already passed), and the comparison against the run's read stamp was a strict `>` — but both numbers are whole SECONDS, so a webhook writing inside the sweep's own second read as OLDER, which is exactly the write the guard exists to protect. Equal now counts as newer; the cost of being wrong is one candidate re-examined next run.
747
+ 6. **Report the outcome** — `trial_convert` or `trial_lapse`, through the same `deliverConversion` path the payment webhook uses.
748
+
749
+ No email is sent from here — the sweep is state correction.
750
+
751
+ **Why the sweep reports at all.** For PayPal this is the ONLY place a trial's outcome is ever known: PayPal fires no trial-end event, so the webhook pipeline is never told and the trial funnel had no signal whatsoever ([#407](https://github.com/Omega-JS-Stack/omega/issues/407)).
752
+
753
+ **And why it cannot double-count.** The guard is the term: a conversion the payment webhook already saw moved `expires` out past the trial's end — and stamps `trial.outcome` besides, which the sweep skips outright one step before the term guard ([#697](https://github.com/Omega-JS-Stack/omega/issues/697)) — while a lapse it saw left the subscription suspended or cancelled, which this sweep's `status == active` query never selects. So a candidate still inside its trial is exactly one no webhook resolved, and it is the only one the sweep reports — the outcome is still STAMPED either way, because that is state correction. Both paths key the event id on the subscription (`trial_convert.<resourceId>`), which collapses a genuine race on the two platforms that key on an event id; the term guard is what keeps GA4 honest, since GA4 deduplicates a `purchase` on `transaction_id` and the two paths cannot name the same one — the webhook has the invoice, this sweep has only the subscription ([#656](https://github.com/Omega-JS-Stack/omega/issues/656)).
754
+
755
+ The candidate query needs a composite index on `users`, registered in `src/cli/commands/setup-tests/helpers/required-indexes.js` (the SSOT for required indexes).
756
+
757
+ ### PayPal cancellation expiry
758
+
759
+ PayPal has no cancel-at-period-end, so a cancelled PayPal subscription serves out its paid term as `cancellation.pending` and this cron closes it once `expires` passes. It writes the user's subscription **and** the order doc's `unified` mirror — the mirror is what handlers and the UI read, and leaving it behind meant an expiry produced a stale order. Both land in one batch, as the delta this cron owns (never a whole map read from a snapshot, which would restore every other field to its read-time value).
760
+
761
+ It takes the pipeline's staleness discipline too: each candidate is re-read at its turn, and anything a webhook has since written — a newer `payment.updatedBy` stamp on the subscription, a newer `metadata.updated` on the order, a status that is no longer a pending cancellation — makes the cron stand down rather than overwrite the newer truth.
762
+
763
+ Its candidate query (`subscription.payment.provider` + `subscription.cancellation.pending`) needs a composite index on `users`, registered in the same SSOT — it shipped without one, so the cron worked only in brands where the index had been hand-created ([#225](https://github.com/Omega-JS-Stack/omega/issues/225)).
764
+
765
+ ## Webhook Verification
766
+
767
+ ONE layer gates `POST /payments/webhook`, and the dispute-alert route beside it: **the shared key** — `?key=<OMEGA_WEBHOOK_KEY>`, compared in constant time. Every provider rides it, a mismatch is a 401 before anything else runs, and nothing is parsed or stored until it passes.
768
+
769
+ Provider signing secrets are deliberately not part of this: no provider verifies a native signature, and the backend holds no signing secret for one ([#634](https://github.com/Omega-JS-Stack/omega/issues/634)). That is a standing rule for every provider added since, Coinbase Commerce included — its `X-CC-Webhook-Signature` header is **not** verified, and its shared secret is never configured or stored here (Ian 2026-08-27). The key is the boundary, which is why it is a minted, brand-owned value and why a manage run leaves exactly one endpoint carrying it.
770
+
771
+ ## Test Provider
772
+
773
+ The `test` provider generates Stripe-shaped data and auto-fires webhooks to the local server. Only available in non-production environments. Use `provider: 'test'` in intent requests during testing. The test webhook provider delegates to Stripe's parser since it generates Stripe-shaped payloads.
774
+
775
+ Both doors enforce that: the intent side throws inside `intent/providers/test.js`, and `POST /payments/webhook?provider=test` answers 403 in production (the webhook providers receive only the raw request, so the route's dispatch layer carries the guard). Real providers are unaffected — a provider dashboard points at `POST /omega/payments/webhook?provider=<provider>&key=<OMEGA_WEBHOOK_KEY>`, where the shared key is the boundary ([Webhook Verification](#webhook-verification)).
776
+
777
+ ### Discounts and the first charge
778
+
779
+ `discountCodes.applyToAmount(amount, discount)` is the one home of the **discounted-charge** computation — `percent` maps to Stripe's `percent_off`, `amount` to its `amount_off` (dollars here, cents there) — and every place that needs "what is the customer charged today" calls it: the intent route's confirmation URL, the test provider's fabricated payloads, the analytics value resolver, and the `new-subscription` / `purchase-completed` email totals. (Promo *savings* — the amount taken off, not the amount charged — is a different quantity and stays inline at its two email sites.)
780
+
781
+ Only the FIRST charge moves. Every code is `duration: 'once'`, so `users/{uid}.subscription.payment.price` keeps the full renewal price from config; the discount itself is recorded on the order (`order.discount`), which is what analytics reads.
782
+
783
+ **The confirmation URL's `amount` is the ROUTE's job, not a provider's** ([#239](https://github.com/Omega-JS-Stack/omega/issues/239)). `buildConfirmationUrl()` in `routes/payments/intent/post.js` applies the validated discount, so the number is right on every provider. That placement is load-bearing: a real provider applies its coupon on its own hosted page and never revisits this URL, so doing the math provider-side left a discounted Stripe checkout landing on the confirmation page quoting the LIST price — and the client's tracking modules read that param straight into GA4/pixel revenue. A trial quotes `$0` and a coupon takes its cut off nothing.
784
+
785
+ **The confirmation URL names the product TYPE** ([#668](https://github.com/Omega-JS-Stack/omega/issues/668)). `buildConfirmationUrl()` sets `type=subscription|one-time` beside `frequency`, because the confirmation page holds nothing else about what was bought: it decides whether to WAIT for the webhook by asking whether the purchase writes account state at all, and a checkout that sent the wrong `frequency` (a one-time buy asking for `annually`) left it polling for a plan a one-time purchase never writes until it timed out, on a payment that had completed cleanly. The route also NORMALIZES what a one-time checkout bills on — `frequency` is written `once` beside `trial = false`, next to the guards, rather than taken from the caller — so the intent doc, the provider call and the confirmation URL cannot record a cadence the buyer will never be billed on. The one-time receipt names the PRODUCT in its summary row (`templates/order.js`), where it used to repeat the order id the header already prints.
786
+
787
+ **Every provider charges the discounted one-time amount** ([#758](https://github.com/Omega-JS-Stack/omega/issues/758)). The split is by what the provider's API HAS: Stripe and Chargebee hand their hosted page a real coupon object (`discounts: [{ coupon }]`, `coupon_ids: [...]`) and it comes off on their side, while a PayPal v2 Order, a Coinbase charge and the test provider's session are prices this framework computes, so those three call `applyToAmount()` on `prices.once` themselves. PayPal's one-time order did neither: it charged the list price while the confirmation URL built beside it quoted the discounted one, so a buyer with a valid code paid full price and landed on a cheaper receipt.
788
+
789
+ **A PayPal SUBSCRIPTION discounts its first period with a setup fee** ([#759](https://github.com/Omega-JS-Stack/omega/issues/759)). PayPal's Subscriptions API has no coupon object either, and the plan the manager's payment walk created carries ONE infinite `REGULAR` billing cycle (plus a `TRIAL` cycle ahead of it when the product configures trial days), so neither shape the create call offers can move the first payment alone. `plan.billing_cycles` overrides a cycle by `sequence` and carries no `tenure_type` or `frequency`, so it cannot ADD a cheaper first cycle, and re-pricing the single cycle it can reach would ride every renewal with it; pricing a `TRIAL` cycle at the discount is worse, because `resolveTrial()` reads any TRIAL cycle as a claimed trial, which would make a PAYING subscriber read as trialing everywhere the backend asks (access revoked the instant they cancel, the winback offer refusing them). So `intent/providers/paypal.js` charges the discounted period at approval as the plan override's setup fee (`plan.payment_preferences.setup_fee`, at `applyToAmount(prices[frequency], discount)`) and tells the plan's own cycles to start one full period later (`start_time`), which leaves every renewal at the plan's list price. An undiscounted subscription is untouched (no override, no `start_time`, billing starts now), and a free trial still charges nothing today, so a code takes its cut off nothing there. One carve-out remains against Stripe and Chargebee, which attach the coupon beside the trial so it lands on the first PAID invoice: a PayPal trial checkout loses the code entirely, and it cannot follow the trial onto the first paid period without minting a per-buyer plan, which this framework does not do. The override also repeats the plan's `auto_bill_outstanding` and `payment_failure_threshold`, so a partial override can never change a discounted subscriber's retry behavior. Like the coupon builders above, this is proven on the params PayPal is asked to create; that the setup fee is collected at approval when `start_time` sits a period out is a live-sandbox item ([#212](https://github.com/Omega-JS-Stack/omega/issues/212)).
790
+
791
+ **A code that covers the WHOLE price is refused, never sent as $0.00** ([#786](https://github.com/Omega-JS-Stack/omega/issues/786)). `chargeableAmount()` in `libraries/payment/discount-codes.js` is `applyToAmount()` with that refusal in front of it, and both providers whose amount this framework computes call it — the PayPal one-time order, the PayPal subscription's setup fee (before the plan lookup, so PayPal is not called at all) and the Coinbase charge — because neither API takes a zero amount, so `WELCOME10OFF` on anything priced at or under $10 came back a provider 400 the buyer read as the checkout's generic failure. The Error is coded 400, which is the one case `POST /payments/intent` answers with the provider path's own words: a 400 naming the code and the list price. Stripe and Chargebee are untouched — their hosted page holds the coupon and decides for itself — a product priced 0 keeps each provider's existing "No price configured …" fault, and a checkout TAKING A FREE TRIAL is skipped outright (`takingTrial`): nothing is charged today whatever code rides along, and the code comes off the first paid period. The checkout page hides the PayPal and crypto buttons on a zero total for the same reason (`core/js/pages/payment/checkout/modules/state.js` in @omega.js/web), so the refusal is a backstop rather than the buyer's first news. Pinned by `test/routes/payments/intent-zero-total.test.js`.
792
+
793
+ **A trial product carries TWIN PayPal plans, and skipping the trial is a plan choice** ([#761](https://github.com/Omega-JS-Stack/omega/issues/761)). PayPal puts a trial on the PLAN, never on the subscribe call, so the manager's payment walk mints TWO active plans per paid interval on a product that configures `trial.days`: the trial twin (`TRIAL` cycle + `REGULAR`) and the skip-trial twin (`REGULAR` only), named `<Display> (Monthly)` and `<Display> (Monthly, no trial)`. `resolvePlanId(product, frequency, trial)` in `libraries/payment/providers/paypal.js` matches on interval + amount + `TRIAL`-cycle PRESENCE — the manager's own rule, since plan names are display only — and both callers (the checkout and the plan switch) pass the trial value they resolved, so a buyer the route refused a trial subscribes to a plan that has no free cycle on it at all. That is what makes skip-trial real: the old code "skipped" by setting `start_time` five minutes out, which left the plan's `TRIAL` cycle running anyway, so `resolveTrial()` read a PAYING customer as trialing (a cancel inside that window revoked access at once) and the discount had to carve trial products out. Both are gone: the +5 minute nudge is deleted and a returning buyer with a code gets the setup-fee discount like everyone else. A trial checkout still resolves the trial twin and still charges nothing today. **A trial product needs one manage run (`npx omega manage --service=payment`) to mint its no-trial twin** — until then a skip-trial checkout throws the plan lookup's "No active PayPal plan … without a trial cycle" rather than falling back to the trial twin and handing out a free period nobody granted. A product without trial days is unchanged: one plan per interval, one twin.
794
+
795
+ The test provider's remaining share is the **payload it fabricates**, carrying the coupon the way Stripe reports it: a Stripe-shaped `discount` on the subscription, `amount_total` + `total_details.amount_discount` on a one-time session, and the discounted `amount_due` on a declined checkout's failed first invoice.
796
+
797
+ **Both coupon shapes reach the real providers.** A code in `discount-codes.js` carries either `percent` or `amount` (flat dollars), and each provider builds its provider's own form: Stripe gets `percent_off`, or `amount_off` in CENTS with the `currency` beside it (Stripe rejects an amount coupon without one); Chargebee gets `discount_type: 'percentage'` + `discount_percentage`, or `'fixed_amount'` + `discount_amount` in the currency's minor unit + `currency_code`. Currency is `payment.currency` (default `USD`). The deterministic coupon id differs per shape (`BEM_{CODE}_{n}OFF_ONCE` vs `BEM_{CODE}_{n}AMTOFF_ONCE`) so one code can never collide with the other form, and the percent id is unchanged, so coupons already live in a brand's provider account keep resolving. Both builders are proven on the params they ask the provider to create; live-provider verification is a Stage 3 item ([#212](https://github.com/Omega-JS-Stack/omega/issues/212)).
798
+
799
+ **A `validate()` result OMITS the shape a code does not have** — it never carries `amount: undefined` — because the result is not just read, it is WRITTEN: `POST /payments/intent` persists it as `payments-intents/{orderId}.discount`, and firebase-admin refuses a document containing an undefined value, throwing synchronously *after* the provider has already created the real checkout session. Readers branch with `discountCodes.promoShape(discount)` → `'percent' | 'amount' | null`. The `null` arm is not defensive padding: a discount read back off an order written before the amount field existed is `valid` with neither shape, and the confirmation-email transitions are dispatched fire-and-forget, so reading a missing shape there costs the customer their receipt silently.
800
+
801
+ The order email quotes the shape it was given: `promoPercent` renders "15% off", `promoAmount` renders "$10.00 off", `promoSavings` is what the code actually took off (floored at the charge — a $10 code against a $4.99 charge saves $4.99, not $10), and a shapeless discount renders no promo line at all while the totals stand at full price.
802
+
803
+ ### Simulating a declined checkout
804
+
805
+ `POST /payments/intent` takes a `simulate` field — allow-listed to `'decline'` by the schema, honored **only** by the test provider (itself non-production), inert on real providers, and **never persisted** onto the intent or the order. It is how the dunning journey (decline → suspended → recovery) is proven end-to-end.
806
+
807
+ A decline mirrors what a real provider does, per product type:
808
+
809
+ | Product type | What the test provider fabricates |
810
+ |---|---|
811
+ | **subscription** | The subscription is born `past_due` (→ `suspended`) with no trial claimed, then a sequenced `invoice.payment_failed` with `billing_reason: 'subscription_create'`. The two events go out **in order** — the failed invoice names the subscription, and the pipeline resolves it from the order the first event wrote. |
812
+ | **one-time** | The session is still created; the payment is what fails, so a failed `manual` invoice (`invoice.payment_failed`) goes out in place of the completed session. |
813
+
814
+ A decline's `payments-intents` doc still ends `completed`. The intent status means "the pipeline processed this", not "the customer paid" — the payment outcome lives on the order and the subscription.
815
+
816
+ ### Simulating an abandoned checkout
817
+
818
+ `simulate: 'abandon'` is the same allow-listed field's other value: the session is created and returned exactly as always, and **no webhook is fired**. That is not a shortcut — it is what abandonment IS. The customer closes the tab, nothing happened, and the provider has nothing to report.
819
+
820
+ It exists because every other payment test drives an event through the pipeline, so the one state nothing covered was the state where the pipeline never runs ([#212](https://github.com/Omega-JS-Stack/omega/issues/212)). What an abandoned checkout must leave behind is exactly one thing — the `payments-intents/{orderId}` doc, still `pending`, which is what the abandoned-cart lane and any later reconciliation read. What it must NOT leave behind is anything shaped like a purchase: no `payments-orders` doc, no subscription change, no claimed trial, no name auto-filled off a payment resource. A customer who considered buying and did not is indistinguishable from one who never opened the page.
821
+
822
+ ### The dispute test provider
823
+
824
+ The dispute pipeline gets the same treatment the payment routes do. `events/firestore/payments-disputes/providers/test.js` implements the same two functions the Stripe provider does — `searchAndMatch` and `processDispute` — against the emulator's own records, and it is selected the way every dispute provider is: off the alert's `provider` field (Chargeblast's `processor`).
825
+
826
+ It exists because a dispute is the one payment path with no simulatable half: `searchAndMatch` needs a real charge and `processDispute` needs a real refund and a real cancel, so the assertion that matters most — *a chargeback takes the subscription away* — had no test at any tier.
827
+
828
+ The simulation is deliberately shaped like the real thing:
829
+
830
+ - the **match** is found by AMOUNT and CARD (last4 `4242`), the way Stripe's charge search is, so a mismatched alert reaches the `no-match` branch instead of being waved through on the email alone;
831
+ - the **cancel** is not written onto the user. It writes the same synthetic `customer.subscription.deleted` pipeline document Stripe's `subscriptions.cancel()` produces, so the webhook pipeline is what revokes access — here as in production.
832
+
833
+ It **refuses in production**, and that guard is load-bearing rather than decorative: the provider name comes off the alert payload, so without it a forged alert could force-cancel a real subscriber by asking for the simulation.
834
+
835
+ ## Scenario coverage matrix
836
+
837
+ Every scenario in [#212](https://github.com/Omega-JS-Stack/omega/issues/212)'s spec, and the file that pins it. `test/` paths are relative to `packages/backend/`; the unit tier is plain-node (no emulator), the journey tier runs the real pipeline against the emulator.
838
+
839
+ | # | Scenario | Pinned by |
840
+ |---|---|---|
841
+ | 1 | basic → premium upgrade | `test/events/payments/journey-payments-upgrade.test.js`; rule 1 in `transitions-detect.test.js` |
842
+ | 2 | premium cancel, access to term end | `journey-payments-cancel.test.js`, `journey-payments-cancel-endpoint.test.js`, `journey-payments-cancel-no-order.test.js`, `routes/payments/cancel.test.js`, `cancel-trialing.test.js`, `cancel-provider-errors.test.js`, `cancel-skip-guards.test.js`; rules 6 + 8 |
843
+ | 3 | uncancel before term end | `journey-payments-uncancel.test.js`, `routes/payments/uncancel.test.js`; rule 7 (`cancellation-removed`) |
844
+ | 4 | plan switch + frequency switch, proration | `journey-payments-plan-switch.test.js`, `journey-payments-plan-switch-trial.test.js`, `journey-payments-plan-change.test.js`, `routes/payments/plan.test.js`, `refund-paypal-proration.test.js`; rule 9, and the **frequency-only switch has no rule** — pinned as a known gap |
845
+ | 5 | refunds: full, prorated, partial, one-time | `journey-payments-refund-webhook.test.js`, `journey-payments-refund-no-order.test.js`, `journey-payments-one-time-refund.test.js`, `routes/payments/refund.test.js`, `refund-one-time.test.js`, `webhook-refund-*.test.js`, `purchase-refunded-handler.test.js`; refund priority + idempotency in `transitions-detect.test.js` |
846
+ | 6 | trial: claim, convert, cancel mid-trial, lapse | `journey-payments-trial.test.js`, `journey-payments-trial-cancel.test.js`, `trial-lapse-sweep.test.js`, `trial-lapse-sweep-staleness.test.js`, `routes/payments/trial-eligibility.test.js` |
847
+ | 7 | dunning: decline → retry → recovery → cancel | `journey-payments-decline.test.js`, `journey-payments-suspend.test.js`, `journey-payments-failure.test.js`, `journey-payments-winback-decline.test.js`; rules 3, 3b, 4, 5 |
848
+ | 8 | payment-method update mid-subscription | `webhook-ordering.test.js` (`a-payment-method-update-refreshes-state-and-emails-nobody`), `routes/payments/portal.test.js`, `portal-return-url.test.js` |
849
+ | 9 | chargebacks / disputes → forced cancel | `journey-payments-dispute.test.js` (end to end), `routes/payments/dispute-alert.test.js`, `dispute-email-status.test.js`, `dedup-race.test.js` |
850
+ | 10 | webhook robustness: duplicates, ordering, unknown types | `webhook-ordering.test.js`, `dedup-race.test.js`, `webhook-retry-sweep.test.js`, `webhook-atomic-writes.test.js`, `routes/payments/webhook.test.js`, `test-processor-doc-shape.test.js`; real deliveries in `test/stripe-live/subscription-lifecycle.test.js` (opt-in lane) |
851
+ | 11 | abandoned checkout — no residue | `journey-payments-abandoned.test.js`; the reminder sweep's activity rule in `events/payments/abandoned-cart-activity.test.js` |
852
+ | 12 | account deletion with an active subscription | `test/routes/user/delete.test.js` (deletion is REFUSED while a paid subscription stands) |
853
+ | 13 | one-time: purchase, refund, re-purchase | `journey-payments-one-time.test.js`, `journey-payments-one-time-decline.test.js`, `journey-payments-one-time-failure.test.js`, `journey-payments-one-time-refund.test.js`, `routes/payments/intent-one-time-metadata.test.js`, `webhook-stripe-refund-one-time.test.js`; the transition mails in `purchase-refunded-handler.test.js` + `purchase-failed-handler.test.js`, the account surface in `routes/user/orders.test.js` + web's `account-orders.test.js` |
854
+ | 14 | order ids are unique | `helpers/payment/order-id.test.js` (mint, collide, retry, fail loudly) |
855
+
856
+ **Known gaps, pinned rather than hidden:** a frequency-only switch (same product, monthly → annually) matches no transition rule, so no email fires for it — asserted explicitly in `transitions-detect.test.js` so a future rule flips a test rather than passing unnoticed.