@adonis-agora/payments 0.2.0 → 0.3.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 (319) hide show
  1. package/CHANGELOG.md +2347 -0
  2. package/dist/assets/spa/assets/index-CT5Zon6P.css +1 -0
  3. package/dist/assets/spa/assets/index-CvEbJEnq.js +41 -0
  4. package/dist/assets/spa/assets/index-CvEbJEnq.js.map +1 -0
  5. package/dist/assets/spa/index.html +20 -0
  6. package/dist/commands/main.d.ts +4 -3
  7. package/dist/commands/main.d.ts.map +1 -1
  8. package/dist/commands/main.js +9 -2
  9. package/dist/commands/main.js.map +1 -1
  10. package/dist/commands/payments_health.d.ts +31 -0
  11. package/dist/commands/payments_health.d.ts.map +1 -0
  12. package/dist/commands/payments_health.js +113 -0
  13. package/dist/commands/payments_health.js.map +1 -0
  14. package/dist/commands/payments_sync.d.ts +18 -4
  15. package/dist/commands/payments_sync.d.ts.map +1 -1
  16. package/dist/commands/payments_sync.js +136 -40
  17. package/dist/commands/payments_sync.js.map +1 -1
  18. package/dist/configure.d.ts +12 -5
  19. package/dist/configure.d.ts.map +1 -1
  20. package/dist/configure.js +18 -5
  21. package/dist/configure.js.map +1 -1
  22. package/dist/providers/dashboard_provider.d.ts +97 -0
  23. package/dist/providers/dashboard_provider.d.ts.map +1 -0
  24. package/dist/providers/dashboard_provider.js +430 -0
  25. package/dist/providers/dashboard_provider.js.map +1 -0
  26. package/dist/providers/payments_client_provider.d.ts +41 -0
  27. package/dist/providers/payments_client_provider.d.ts.map +1 -0
  28. package/dist/providers/payments_client_provider.js +95 -0
  29. package/dist/providers/payments_client_provider.js.map +1 -0
  30. package/dist/providers/payments_provider.d.ts +35 -0
  31. package/dist/providers/payments_provider.d.ts.map +1 -1
  32. package/dist/providers/payments_provider.js +250 -36
  33. package/dist/providers/payments_provider.js.map +1 -1
  34. package/dist/src/billing/billing_health.d.ts +85 -0
  35. package/dist/src/billing/billing_health.d.ts.map +1 -0
  36. package/dist/src/billing/billing_health.js +155 -0
  37. package/dist/src/billing/billing_health.js.map +1 -0
  38. package/dist/src/billing/billing_overview.d.ts +4 -3
  39. package/dist/src/billing/billing_overview.d.ts.map +1 -1
  40. package/dist/src/billing/billing_overview.js +14 -5
  41. package/dist/src/billing/billing_overview.js.map +1 -1
  42. package/dist/src/billing/billing_store.d.ts +634 -5
  43. package/dist/src/billing/billing_store.d.ts.map +1 -1
  44. package/dist/src/billing/billing_store.js +18 -1
  45. package/dist/src/billing/billing_store.js.map +1 -1
  46. package/dist/src/billing/index.d.ts +2 -1
  47. package/dist/src/billing/index.d.ts.map +1 -1
  48. package/dist/src/billing/index.js +1 -0
  49. package/dist/src/billing/index.js.map +1 -1
  50. package/dist/src/billing/list_query.d.ts +16 -0
  51. package/dist/src/billing/list_query.d.ts.map +1 -0
  52. package/dist/src/billing/list_query.js +28 -0
  53. package/dist/src/billing/list_query.js.map +1 -0
  54. package/dist/src/billing/lucid_billing_store.d.ts +122 -4
  55. package/dist/src/billing/lucid_billing_store.d.ts.map +1 -1
  56. package/dist/src/billing/lucid_billing_store.js +811 -13
  57. package/dist/src/billing/lucid_billing_store.js.map +1 -1
  58. package/dist/src/billing/mixins/index.d.ts +3 -0
  59. package/dist/src/billing/mixins/index.d.ts.map +1 -1
  60. package/dist/src/billing/mixins/index.js +3 -0
  61. package/dist/src/billing/mixins/index.js.map +1 -1
  62. package/dist/src/billing/mixins/with_audit_event.d.ts +48 -0
  63. package/dist/src/billing/mixins/with_audit_event.d.ts.map +1 -0
  64. package/dist/src/billing/mixins/with_audit_event.js +77 -0
  65. package/dist/src/billing/mixins/with_audit_event.js.map +1 -0
  66. package/dist/src/billing/mixins/with_customer.d.ts +39 -0
  67. package/dist/src/billing/mixins/with_customer.d.ts.map +1 -0
  68. package/dist/src/billing/mixins/with_customer.js +68 -0
  69. package/dist/src/billing/mixins/with_customer.js.map +1 -0
  70. package/dist/src/billing/mixins/with_dispute.d.ts +68 -0
  71. package/dist/src/billing/mixins/with_dispute.d.ts.map +1 -0
  72. package/dist/src/billing/mixins/with_dispute.js +83 -0
  73. package/dist/src/billing/mixins/with_dispute.js.map +1 -0
  74. package/dist/src/billing/mixins/with_payment.d.ts +42 -0
  75. package/dist/src/billing/mixins/with_payment.d.ts.map +1 -1
  76. package/dist/src/billing/mixins/with_payment.js +29 -3
  77. package/dist/src/billing/mixins/with_payment.js.map +1 -1
  78. package/dist/src/billing/mixins/with_subscription.d.ts +7 -0
  79. package/dist/src/billing/mixins/with_subscription.d.ts.map +1 -1
  80. package/dist/src/billing/mixins/with_subscription.js +15 -1
  81. package/dist/src/billing/mixins/with_subscription.js.map +1 -1
  82. package/dist/src/billing/mixins/with_usage_event.d.ts +7 -0
  83. package/dist/src/billing/mixins/with_usage_event.d.ts.map +1 -1
  84. package/dist/src/billing/mixins/with_usage_event.js +15 -1
  85. package/dist/src/billing/mixins/with_usage_event.js.map +1 -1
  86. package/dist/src/billing/mixins/with_webhook_event.d.ts +18 -0
  87. package/dist/src/billing/mixins/with_webhook_event.d.ts.map +1 -1
  88. package/dist/src/billing/mixins/with_webhook_event.js +18 -1
  89. package/dist/src/billing/mixins/with_webhook_event.js.map +1 -1
  90. package/dist/src/billing/resolve_dispatch.d.ts +22 -0
  91. package/dist/src/billing/resolve_dispatch.d.ts.map +1 -0
  92. package/dist/src/billing/resolve_dispatch.js +44 -0
  93. package/dist/src/billing/resolve_dispatch.js.map +1 -0
  94. package/dist/src/billing/resolve_store.d.ts +18 -0
  95. package/dist/src/billing/resolve_store.d.ts.map +1 -0
  96. package/dist/src/billing/resolve_store.js +35 -0
  97. package/dist/src/billing/resolve_store.js.map +1 -0
  98. package/dist/src/billing/schema.d.ts +108 -0
  99. package/dist/src/billing/schema.d.ts.map +1 -0
  100. package/dist/src/billing/schema.js +350 -0
  101. package/dist/src/billing/schema.js.map +1 -0
  102. package/dist/src/billing/webhook_dispatcher.d.ts +95 -16
  103. package/dist/src/billing/webhook_dispatcher.d.ts.map +1 -1
  104. package/dist/src/billing/webhook_dispatcher.js +186 -33
  105. package/dist/src/billing/webhook_dispatcher.js.map +1 -1
  106. package/dist/src/billing/webhook_events.d.ts +67 -1
  107. package/dist/src/billing/webhook_events.d.ts.map +1 -1
  108. package/dist/src/billing/webhook_events.js +15 -0
  109. package/dist/src/billing/webhook_events.js.map +1 -1
  110. package/dist/src/billing/webhook_processor.d.ts.map +1 -1
  111. package/dist/src/billing/webhook_processor.js +325 -5
  112. package/dist/src/billing/webhook_processor.js.map +1 -1
  113. package/dist/src/client/define_config.d.ts +150 -0
  114. package/dist/src/client/define_config.d.ts.map +1 -0
  115. package/dist/src/client/define_config.js +98 -0
  116. package/dist/src/client/define_config.js.map +1 -0
  117. package/dist/src/client/handlers.d.ts +71 -0
  118. package/dist/src/client/handlers.d.ts.map +1 -0
  119. package/dist/src/client/handlers.js +119 -0
  120. package/dist/src/client/handlers.js.map +1 -0
  121. package/dist/src/client/index.d.ts +16 -0
  122. package/dist/src/client/index.d.ts.map +1 -0
  123. package/dist/src/client/index.js +13 -0
  124. package/dist/src/client/index.js.map +1 -0
  125. package/dist/src/client/statuses.d.ts +38 -0
  126. package/dist/src/client/statuses.d.ts.map +1 -0
  127. package/dist/src/client/statuses.js +42 -0
  128. package/dist/src/client/statuses.js.map +1 -0
  129. package/dist/src/dashboard/actions.d.ts +143 -0
  130. package/dist/src/dashboard/actions.d.ts.map +1 -0
  131. package/dist/src/dashboard/actions.js +112 -0
  132. package/dist/src/dashboard/actions.js.map +1 -0
  133. package/dist/src/dashboard/auth.d.ts +136 -0
  134. package/dist/src/dashboard/auth.d.ts.map +1 -0
  135. package/dist/src/dashboard/auth.js +152 -0
  136. package/dist/src/dashboard/auth.js.map +1 -0
  137. package/dist/src/dashboard/define_config.d.ts +80 -0
  138. package/dist/src/dashboard/define_config.d.ts.map +1 -0
  139. package/dist/src/dashboard/define_config.js +83 -0
  140. package/dist/src/dashboard/define_config.js.map +1 -0
  141. package/dist/src/dashboard/handlers.d.ts +281 -0
  142. package/dist/src/dashboard/handlers.d.ts.map +1 -0
  143. package/dist/src/dashboard/handlers.js +960 -0
  144. package/dist/src/dashboard/handlers.js.map +1 -0
  145. package/dist/src/dashboard/index.d.ts +18 -0
  146. package/dist/src/dashboard/index.d.ts.map +1 -0
  147. package/dist/src/dashboard/index.js +14 -0
  148. package/dist/src/dashboard/index.js.map +1 -0
  149. package/dist/src/dashboard/login_page.d.ts +23 -0
  150. package/dist/src/dashboard/login_page.d.ts.map +1 -0
  151. package/dist/src/dashboard/login_page.js +170 -0
  152. package/dist/src/dashboard/login_page.js.map +1 -0
  153. package/dist/src/dashboard/period.d.ts +31 -0
  154. package/dist/src/dashboard/period.d.ts.map +1 -0
  155. package/dist/src/dashboard/period.js +44 -0
  156. package/dist/src/dashboard/period.js.map +1 -0
  157. package/dist/src/dashboard/session_cookie.d.ts +42 -0
  158. package/dist/src/dashboard/session_cookie.d.ts.map +1 -0
  159. package/dist/src/dashboard/session_cookie.js +97 -0
  160. package/dist/src/dashboard/session_cookie.js.map +1 -0
  161. package/dist/src/dashboard/spa.d.ts +24 -0
  162. package/dist/src/dashboard/spa.d.ts.map +1 -0
  163. package/dist/src/dashboard/spa.js +52 -0
  164. package/dist/src/dashboard/spa.js.map +1 -0
  165. package/dist/src/define_config.d.ts +244 -9
  166. package/dist/src/define_config.d.ts.map +1 -1
  167. package/dist/src/define_config.js +97 -1
  168. package/dist/src/define_config.js.map +1 -1
  169. package/dist/src/diagnostics.d.ts +287 -7
  170. package/dist/src/diagnostics.d.ts.map +1 -1
  171. package/dist/src/diagnostics.js +317 -1
  172. package/dist/src/diagnostics.js.map +1 -1
  173. package/dist/src/driver.d.ts +139 -5
  174. package/dist/src/driver.d.ts.map +1 -1
  175. package/dist/src/drivers/abacate.d.ts +12 -2
  176. package/dist/src/drivers/abacate.d.ts.map +1 -1
  177. package/dist/src/drivers/abacate.js +112 -19
  178. package/dist/src/drivers/abacate.js.map +1 -1
  179. package/dist/src/drivers/adyen.d.ts +164 -0
  180. package/dist/src/drivers/adyen.d.ts.map +1 -0
  181. package/dist/src/drivers/adyen.js +678 -0
  182. package/dist/src/drivers/adyen.js.map +1 -0
  183. package/dist/src/drivers/asaas.d.ts +52 -2
  184. package/dist/src/drivers/asaas.d.ts.map +1 -1
  185. package/dist/src/drivers/asaas.js +364 -29
  186. package/dist/src/drivers/asaas.js.map +1 -1
  187. package/dist/src/drivers/dodo.d.ts +135 -0
  188. package/dist/src/drivers/dodo.d.ts.map +1 -0
  189. package/dist/src/drivers/dodo.js +864 -0
  190. package/dist/src/drivers/dodo.js.map +1 -0
  191. package/dist/src/drivers/efi.d.ts +98 -0
  192. package/dist/src/drivers/efi.d.ts.map +1 -0
  193. package/dist/src/drivers/efi.js +628 -0
  194. package/dist/src/drivers/efi.js.map +1 -0
  195. package/dist/src/drivers/infinitepay.d.ts +156 -0
  196. package/dist/src/drivers/infinitepay.d.ts.map +1 -0
  197. package/dist/src/drivers/infinitepay.js +321 -0
  198. package/dist/src/drivers/infinitepay.js.map +1 -0
  199. package/dist/src/drivers/lemonsqueezy.d.ts +125 -0
  200. package/dist/src/drivers/lemonsqueezy.d.ts.map +1 -0
  201. package/dist/src/drivers/lemonsqueezy.js +632 -0
  202. package/dist/src/drivers/lemonsqueezy.js.map +1 -0
  203. package/dist/src/drivers/mercadopago.d.ts +97 -0
  204. package/dist/src/drivers/mercadopago.d.ts.map +1 -0
  205. package/dist/src/drivers/mercadopago.js +844 -0
  206. package/dist/src/drivers/mercadopago.js.map +1 -0
  207. package/dist/src/drivers/mollie.d.ts +125 -0
  208. package/dist/src/drivers/mollie.d.ts.map +1 -0
  209. package/dist/src/drivers/mollie.js +812 -0
  210. package/dist/src/drivers/mollie.js.map +1 -0
  211. package/dist/src/drivers/paddle.d.ts +148 -0
  212. package/dist/src/drivers/paddle.d.ts.map +1 -0
  213. package/dist/src/drivers/paddle.js +738 -0
  214. package/dist/src/drivers/paddle.js.map +1 -0
  215. package/dist/src/drivers/pagarme.d.ts +97 -0
  216. package/dist/src/drivers/pagarme.d.ts.map +1 -0
  217. package/dist/src/drivers/pagarme.js +829 -0
  218. package/dist/src/drivers/pagarme.js.map +1 -0
  219. package/dist/src/drivers/pagbank.d.ts +57 -0
  220. package/dist/src/drivers/pagbank.d.ts.map +1 -0
  221. package/dist/src/drivers/pagbank.js +499 -0
  222. package/dist/src/drivers/pagbank.js.map +1 -0
  223. package/dist/src/drivers/paypal.d.ts +120 -0
  224. package/dist/src/drivers/paypal.d.ts.map +1 -0
  225. package/dist/src/drivers/paypal.js +891 -0
  226. package/dist/src/drivers/paypal.js.map +1 -0
  227. package/dist/src/drivers/polar.d.ts +118 -0
  228. package/dist/src/drivers/polar.d.ts.map +1 -0
  229. package/dist/src/drivers/polar.js +604 -0
  230. package/dist/src/drivers/polar.js.map +1 -0
  231. package/dist/src/drivers/razorpay.d.ts +103 -0
  232. package/dist/src/drivers/razorpay.d.ts.map +1 -0
  233. package/dist/src/drivers/razorpay.js +867 -0
  234. package/dist/src/drivers/razorpay.js.map +1 -0
  235. package/dist/src/drivers/shared.d.ts +33 -0
  236. package/dist/src/drivers/shared.d.ts.map +1 -0
  237. package/dist/src/drivers/shared.js +37 -0
  238. package/dist/src/drivers/shared.js.map +1 -0
  239. package/dist/src/drivers/square.d.ts +133 -0
  240. package/dist/src/drivers/square.d.ts.map +1 -0
  241. package/dist/src/drivers/square.js +905 -0
  242. package/dist/src/drivers/square.js.map +1 -0
  243. package/dist/src/drivers/stripe.d.ts +40 -3
  244. package/dist/src/drivers/stripe.d.ts.map +1 -1
  245. package/dist/src/drivers/stripe.js +806 -25
  246. package/dist/src/drivers/stripe.js.map +1 -1
  247. package/dist/src/drivers/woovi.d.ts +10 -3
  248. package/dist/src/drivers/woovi.d.ts.map +1 -1
  249. package/dist/src/drivers/woovi.js +162 -29
  250. package/dist/src/drivers/woovi.js.map +1 -1
  251. package/dist/src/ensure_customer.d.ts +34 -1
  252. package/dist/src/ensure_customer.d.ts.map +1 -1
  253. package/dist/src/ensure_customer.js +29 -4
  254. package/dist/src/ensure_customer.js.map +1 -1
  255. package/dist/src/http.d.ts +37 -3
  256. package/dist/src/http.d.ts.map +1 -1
  257. package/dist/src/http.js +69 -5
  258. package/dist/src/http.js.map +1 -1
  259. package/dist/src/index.d.ts +40 -10
  260. package/dist/src/index.d.ts.map +1 -1
  261. package/dist/src/index.js +22 -4
  262. package/dist/src/index.js.map +1 -1
  263. package/dist/src/invoice/emit_invoice.d.ts.map +1 -1
  264. package/dist/src/invoice/emit_invoice.js +31 -11
  265. package/dist/src/invoice/emit_invoice.js.map +1 -1
  266. package/dist/src/money.d.ts +23 -5
  267. package/dist/src/money.d.ts.map +1 -1
  268. package/dist/src/money.js +72 -7
  269. package/dist/src/money.js.map +1 -1
  270. package/dist/src/payments_manager.d.ts +8 -1
  271. package/dist/src/payments_manager.d.ts.map +1 -1
  272. package/dist/src/payments_manager.js +168 -6
  273. package/dist/src/payments_manager.js.map +1 -1
  274. package/dist/src/services/main.d.ts +38 -0
  275. package/dist/src/services/main.d.ts.map +1 -1
  276. package/dist/src/services/main.js +130 -0
  277. package/dist/src/services/main.js.map +1 -1
  278. package/dist/src/telescope/index.d.ts +2 -0
  279. package/dist/src/telescope/index.d.ts.map +1 -1
  280. package/dist/src/telescope/index.js +1 -0
  281. package/dist/src/telescope/index.js.map +1 -1
  282. package/dist/src/telescope/payments_watcher.d.ts +10 -4
  283. package/dist/src/telescope/payments_watcher.d.ts.map +1 -1
  284. package/dist/src/telescope/payments_watcher.js +65 -13
  285. package/dist/src/telescope/payments_watcher.js.map +1 -1
  286. package/dist/src/telescope/register.d.ts +62 -0
  287. package/dist/src/telescope/register.d.ts.map +1 -0
  288. package/dist/src/telescope/register.js +48 -0
  289. package/dist/src/telescope/register.js.map +1 -0
  290. package/dist/src/testing/fake_payments.d.ts +62 -0
  291. package/dist/src/testing/fake_payments.d.ts.map +1 -0
  292. package/dist/src/testing/fake_payments.js +82 -0
  293. package/dist/src/testing/fake_payments.js.map +1 -0
  294. package/dist/src/testing/in_memory_billing_store.d.ts +151 -2
  295. package/dist/src/testing/in_memory_billing_store.d.ts.map +1 -1
  296. package/dist/src/testing/in_memory_billing_store.js +517 -4
  297. package/dist/src/testing/in_memory_billing_store.js.map +1 -1
  298. package/dist/src/testing/index.d.ts +2 -1
  299. package/dist/src/testing/index.d.ts.map +1 -1
  300. package/dist/src/testing/index.js +1 -0
  301. package/dist/src/testing/index.js.map +1 -1
  302. package/dist/src/types.d.ts +161 -5
  303. package/dist/src/types.d.ts.map +1 -1
  304. package/dist/src/types.js +30 -1
  305. package/dist/src/types.js.map +1 -1
  306. package/dist/src/webhook_handlers.d.ts +96 -2
  307. package/dist/src/webhook_handlers.d.ts.map +1 -1
  308. package/dist/src/webhook_handlers.js +91 -10
  309. package/dist/src/webhook_handlers.js.map +1 -1
  310. package/dist/src/webhook_security.d.ts +94 -3
  311. package/dist/src/webhook_security.d.ts.map +1 -1
  312. package/dist/src/webhook_security.js +173 -9
  313. package/dist/src/webhook_security.js.map +1 -1
  314. package/dist/stubs/config/payments.stub +13 -0
  315. package/dist/stubs/config/payments_client.stub +42 -0
  316. package/dist/stubs/config/payments_dashboard.stub +38 -0
  317. package/dist/stubs/database/migrations/create_billing_tables.stub +35 -77
  318. package/dist/stubs/make/webhook_handler/main.stub +29 -11
  319. package/package.json +60 -25
@@ -0,0 +1,960 @@
1
+ import { billingHealth } from '../billing/billing_health.js';
2
+ import { billingOverview } from '../billing/billing_overview.js';
3
+ import { AUDIT_ACTIONS, OPEN_DISPUTE_STATUSES, } from '../billing/billing_store.js';
4
+ import { BILLING_LIST_DEFAULT_LIMIT, BILLING_LIST_MAX_LIMIT, clampLimit, clampOffset, } from '../billing/list_query.js';
5
+ import { resolvePeriod } from './period.js';
6
+ /** A `200 OK` JSON response. Exported so sibling handlers share one convention. */
7
+ export const ok = (body) => ({ status: 200, body });
8
+ const badRequest = (message) => ({ status: 400, body: { error: message } });
9
+ const notFound = (message) => ({ status: 404, body: { error: message } });
10
+ /** The row is not in the state the action needs — a payment that is not `paid`, a ledger row that
11
+ * is not `failed`. Distinct from `400`: the request was well-formed, the world moved. */
12
+ const conflict = (message) => ({ status: 409, body: { error: message } });
13
+ /** The gateway/handler was reached and refused. `502`, not `500`: nothing here is broken. */
14
+ const upstreamFailed = (message) => ({
15
+ status: 502,
16
+ body: { error: message },
17
+ });
18
+ /** The capability is not wired in this deployment. Same shape the provider uses for a missing
19
+ * billing store, so the SPA reports both the same way. */
20
+ const unavailable = (message) => ({ status: 503, body: { error: message } });
21
+ /** The statuses the payments filter offers. `status` is a free string in the store (a gateway may
22
+ * send its own), so this is a UI convenience list, not a validation whitelist. Every member of
23
+ * `BillingStatus` is here: `authorized` money can still evaporate and `disputed` money has already
24
+ * been pulled back, and an operator who cannot filter for either cannot see either. */
25
+ export const PAYMENT_STATUSES = [
26
+ 'paid',
27
+ 'authorized',
28
+ 'pending',
29
+ 'failed',
30
+ 'refunded',
31
+ 'disputed',
32
+ 'canceled',
33
+ ];
34
+ /** The statuses the disputes filter offers — `DisputeStatus`, ordered the way an operator reads
35
+ * them: what still needs an answer first. `warning` leads because it is the only one where a
36
+ * refund still stops the chargeback from ever being filed. */
37
+ export const DISPUTE_STATUSES = [
38
+ 'warning',
39
+ 'open',
40
+ 'under_review',
41
+ 'lost',
42
+ 'expired',
43
+ 'canceled',
44
+ 'won',
45
+ ];
46
+ /** How far ahead `?dueWithin=` looks when the caller asks for closing windows but names no
47
+ * horizon. Matches `billingHealth`'s default, so the panel and the cron agree about "soon". */
48
+ export const DISPUTE_DEFAULT_DUE_WITHIN_HOURS = 72;
49
+ /** The ledger's three statuses. `failed` is the one that means "a handler threw and the dispatcher
50
+ * gave up" — i.e. the event's effect never happened. */
51
+ export const WEBHOOK_EVENT_STATUSES = ['received', 'processed', 'failed'];
52
+ /** `SubscriptionStatus`, ordered the way an operator reads them: what needs attention first.
53
+ * `paused` sits apart from `active` deliberately — a paused subscriber is not paying. */
54
+ export const SUBSCRIPTION_STATUSES = [
55
+ 'past_due',
56
+ 'paused',
57
+ 'incomplete',
58
+ 'trialing',
59
+ 'active',
60
+ 'canceled',
61
+ 'ended',
62
+ ];
63
+ /** The one status the subscriptions screen opens on: someone's payment failed and the subscription
64
+ * is about to lapse. Everything else can wait until tomorrow. */
65
+ export const SUBSCRIPTION_DEFAULT_STATUS = 'past_due';
66
+ /** Only a `paid` payment can be refunded from this console. A `pending` one has taken no money
67
+ * yet, and a `refunded`/`canceled` one has none left to take. */
68
+ const REFUNDABLE_STATUS = 'paid';
69
+ /**
70
+ * How many rows a provider-filtered page may scan before it gives up and says so.
71
+ *
72
+ * `BillingListQuery` filters on `status` only, so `?provider=` is applied here, over pages read
73
+ * from the store. That is bounded work by construction — but it has to be bounded VISIBLY: a
74
+ * silent "no Asaas payments" that really means "none in the last thousand rows" is the kind of
75
+ * wrong answer an operator acts on. `page.truncated` says which one they are looking at.
76
+ */
77
+ export const PROVIDER_SCAN_CAP = 1000;
78
+ /** How far back `GET /providers` looks to learn which gateways this install actually uses. */
79
+ const PROVIDER_DISCOVERY_SCAN = 600;
80
+ function firstQuery(value) {
81
+ if (Array.isArray(value))
82
+ return value[0];
83
+ return value;
84
+ }
85
+ /** A non-empty query param, or `undefined` — `?status=` must read as "no filter", not as `''`. */
86
+ function filterQuery(value) {
87
+ const raw = firstQuery(value);
88
+ return raw === undefined || raw === '' ? undefined : raw;
89
+ }
90
+ /** Parse an integer query param, leaving the clamping to `list_query.ts` so every caller of the
91
+ * store agrees on the bounds. */
92
+ function intQuery(value) {
93
+ const raw = firstQuery(value);
94
+ if (raw === undefined || raw === '')
95
+ return undefined;
96
+ const n = Number.parseInt(raw, 10);
97
+ return Number.isFinite(n) ? n : undefined;
98
+ }
99
+ /** The paging the request asked for, already clamped — echoed back so the client can tell whether
100
+ * another page might exist (`count === limit` is the only "there might be more" signal; nothing
101
+ * here counts the full match set). */
102
+ function pageOf(req) {
103
+ return {
104
+ limit: clampLimit(intQuery(req.query.limit)),
105
+ offset: clampOffset(intQuery(req.query.offset)),
106
+ };
107
+ }
108
+ /** `Date | null` -> ISO string | null, so every timestamp crosses the wire the same way. */
109
+ function iso(value) {
110
+ return value === null ? null : value.toISOString();
111
+ }
112
+ /**
113
+ * Read one page, optionally narrowed to a provider.
114
+ *
115
+ * With no `provider` this is a single store call and `truncated` is always `false`. With one, it
116
+ * walks the store in store-sized chunks, keeping matches, until it has filled the requested page,
117
+ * run out of rows, or hit {@link PROVIDER_SCAN_CAP}.
118
+ */
119
+ async function pageBy(fetch, page, provider) {
120
+ if (provider === undefined) {
121
+ const rows = await fetch(page.limit, page.offset);
122
+ return { rows, scanned: rows.length, truncated: false };
123
+ }
124
+ const wanted = page.offset + page.limit;
125
+ const matched = [];
126
+ let scanned = 0;
127
+ let exhausted = false;
128
+ while (matched.length < wanted && scanned < PROVIDER_SCAN_CAP && !exhausted) {
129
+ const chunk = Math.min(BILLING_LIST_MAX_LIMIT, PROVIDER_SCAN_CAP - scanned);
130
+ const rows = await fetch(chunk, scanned);
131
+ scanned += rows.length;
132
+ if (rows.length < chunk)
133
+ exhausted = true;
134
+ for (const row of rows) {
135
+ if (row.provider === provider)
136
+ matched.push(row);
137
+ }
138
+ }
139
+ return {
140
+ rows: matched.slice(page.offset, wanted),
141
+ scanned,
142
+ // Only a scan that stopped SHORT is truncated. Filling the page or reaching the end of the
143
+ // table are both complete answers.
144
+ truncated: !exhausted && matched.length < wanted,
145
+ };
146
+ }
147
+ /** The paging envelope every list endpoint echoes back. */
148
+ function pageEnvelope(page, filtered) {
149
+ return {
150
+ limit: page.limit,
151
+ offset: page.offset,
152
+ count: filtered.rows.length,
153
+ scanned: filtered.scanned,
154
+ truncated: filtered.truncated,
155
+ };
156
+ }
157
+ /**
158
+ * `GET <api>/overview` — the `billingOverview()` aggregates for a selectable window.
159
+ *
160
+ * Straight passthrough of the headless function this package already ships: revenue (integer
161
+ * minor units), active subscriptions (which INCLUDES `trialing` — see `countActiveSubscriptions`),
162
+ * and one line per metered meter.
163
+ */
164
+ export async function overview(deps, req) {
165
+ const now = (deps.now ?? (() => new Date()))();
166
+ const period = resolvePeriod({
167
+ period: firstQuery(req.query.period),
168
+ from: firstQuery(req.query.from),
169
+ to: firstQuery(req.query.to),
170
+ }, now);
171
+ const result = await billingOverview(deps.store, { from: period.from, to: period.to });
172
+ return ok({
173
+ period: {
174
+ from: result.period.from.toISOString(),
175
+ to: result.period.to.toISOString(),
176
+ preset: period.preset,
177
+ },
178
+ currency: deps.currency,
179
+ metrics: result.metrics,
180
+ });
181
+ }
182
+ /**
183
+ * `GET <api>/health` — the three silent failures of a billing install, from `billingHealth()`.
184
+ *
185
+ * This is the endpoint the console opens on. Every other screen answers a question the operator
186
+ * had to think of; this one answers the question they did not: an install where the worker died,
187
+ * or the webhook endpoint stopped being reachable, keeps returning `200` everywhere while revenue
188
+ * quietly stops landing. `failures` names WHICH provider and event type is failing, so the answer
189
+ * is a place to look rather than a number.
190
+ *
191
+ * Nothing here is windowed by the period selector: `billingHealth` has its own thresholds
192
+ * (15 min stuck, 2 h unconfirmed, 24 h of failures) and they are the thresholds `payments:health`
193
+ * alerts on, so the console and the cron agree.
194
+ */
195
+ export async function health(deps) {
196
+ const now = deps.now?.();
197
+ const report = await billingHealth(deps.store, now !== undefined ? { now } : {});
198
+ return ok({
199
+ healthy: report.healthy,
200
+ checkedAt: report.checkedAt.toISOString(),
201
+ checks: report.checks,
202
+ failures: report.failures,
203
+ // WHICH windows are closing, not just how many — a count names no gateway dashboard to
204
+ // open. Dates cross the wire as ISO strings, like everywhere else here.
205
+ deadlines: report.deadlines.map(disputeJson),
206
+ // The deadline-free list. On a gateway that publishes no deadline this is the only one of
207
+ // the two that is ever non-empty, which is exactly why it is separate.
208
+ openDisputes: report.openDisputes.map(disputeJson),
209
+ });
210
+ }
211
+ /** One dispute row as JSON. `amount` stays integer minor units — the SPA formats. */
212
+ function disputeJson(row) {
213
+ return {
214
+ id: row.id,
215
+ gatewayId: row.gatewayId,
216
+ paymentGatewayId: row.paymentGatewayId,
217
+ provider: row.provider,
218
+ status: row.status,
219
+ reason: row.reason,
220
+ amount: row.amount,
221
+ currency: row.currency,
222
+ /** `null` means the gateway sent no deadline — never "no hurry". The SPA must say so. */
223
+ evidenceDueBy: iso(row.evidenceDueBy),
224
+ outcome: row.outcome,
225
+ openedAt: iso(row.openedAt),
226
+ closedAt: iso(row.closedAt),
227
+ createdAt: iso(row.createdAt),
228
+ };
229
+ }
230
+ /**
231
+ * `GET <api>/disputes` — a page of `billing_disputes`.
232
+ *
233
+ * Two modes, because a dispute list has two questions:
234
+ *
235
+ * - default: newest first, `status`/`provider` filters — the log.
236
+ * - `?dueWithin=<hours>`: only OPEN disputes carrying a deadline, soonest window first — the
237
+ * work. Rows already past their deadline stay in it: they are still open and still
238
+ * unanswered, and dropping them the moment they expire would make the panel go quiet at
239
+ * exactly the moment it became urgent.
240
+ *
241
+ * There is still no "accept" and no "fight" button: whether to submit evidence or refund is a
242
+ * business rule (it turns on the fee, the evidence you actually have, and the chargeback ratio
243
+ * that puts a merchant into a card network's monitoring programme), and it stays in the app's
244
+ * code. The one write this screen does have is {@link resolveDispute}, which records an outcome
245
+ * that has ALREADY happened at the gateway — see the note there for why that is not the same
246
+ * thing.
247
+ */
248
+ export async function disputes(deps, req) {
249
+ const status = filterQuery(req.query.status);
250
+ const provider = filterQuery(req.query.provider);
251
+ const page = pageOf(req);
252
+ // `firstQuery`, not `filterQuery`: a bare `?dueWithin` (no value) is a request for the work
253
+ // list at the DEFAULT horizon, not an absent filter. A value that is present and unreadable
254
+ // is a `400` — silently falling back to the default would answer a different question than
255
+ // the one asked, and the caller could not tell.
256
+ const dueWithinRaw = firstQuery(req.query.dueWithin);
257
+ if (dueWithinRaw !== undefined) {
258
+ const parsed = dueWithinRaw === '' ? DISPUTE_DEFAULT_DUE_WITHIN_HOURS : Number.parseInt(dueWithinRaw, 10);
259
+ if (!Number.isFinite(parsed) || parsed < 0) {
260
+ return badRequest('dueWithin must be a number of hours, and not negative');
261
+ }
262
+ const withinHours = parsed;
263
+ const now = deps.now?.();
264
+ const rows = await deps.store.listDisputesDueWithin({
265
+ withinHours,
266
+ ...(now !== undefined ? { now } : {}),
267
+ ...(provider !== undefined ? { provider } : {}),
268
+ limit: page.limit,
269
+ offset: page.offset,
270
+ });
271
+ // The full count, not `rows.length`: a page that fills says nothing about how many more
272
+ // windows are closing, and that number is the one an operator plans their day around.
273
+ const total = await deps.store.countDisputesDueWithin({
274
+ withinHours,
275
+ ...(now !== undefined ? { now } : {}),
276
+ ...(provider !== undefined ? { provider } : {}),
277
+ });
278
+ return ok({
279
+ disputes: rows.map(disputeJson),
280
+ dueWithin: { hours: withinHours, total },
281
+ page: { limit: page.limit, offset: page.offset, count: rows.length },
282
+ statuses: DISPUTE_STATUSES,
283
+ });
284
+ }
285
+ const rows = await deps.store.listDisputes({
286
+ ...(status !== undefined ? { status } : {}),
287
+ ...(provider !== undefined ? { provider } : {}),
288
+ limit: page.limit,
289
+ offset: page.offset,
290
+ });
291
+ return ok({
292
+ disputes: rows.map(disputeJson),
293
+ page: { limit: page.limit, offset: page.offset, count: rows.length },
294
+ statuses: DISPUTE_STATUSES,
295
+ });
296
+ }
297
+ /**
298
+ * One payment row as JSON.
299
+ *
300
+ * `externalReference` is here, and its absence was the bug: the store has returned it since the
301
+ * column existed and this serializer dropped it, so the console showed a gateway id where the
302
+ * operator was holding an order number. It is the ONLY field on this row the app itself chose,
303
+ * which makes it the only one that can answer "did THIS student's payment land?".
304
+ *
305
+ * `owner` is the other half of the same question. A payment carries the GATEWAY's customer id
306
+ * (`cus_…`), which names nobody; `billing_customers.owner_type`/`owner_id`, written by
307
+ * `ensureCustomer`, is the only thing tying it to an app user. `null` when the payment has no
308
+ * customer id, or when nothing mapped it.
309
+ */
310
+ function paymentJson(row, owner) {
311
+ return {
312
+ id: row.id,
313
+ gatewayId: row.gatewayId,
314
+ provider: row.provider,
315
+ status: row.status,
316
+ // Integer minor units, as stored. The SPA formats.
317
+ amount: row.amount,
318
+ currency: row.currency,
319
+ customerId: row.customerId,
320
+ subscriptionId: row.subscriptionId,
321
+ /** The APP's own id for this charge. `null` when the gateway echoed none. */
322
+ externalReference: row.externalReference,
323
+ /** Integer minor units already refunded; `null` on a row older than the column. */
324
+ refundedAmount: row.refundedAmount,
325
+ owner,
326
+ paidAt: iso(row.paidAt),
327
+ createdAt: iso(row.createdAt),
328
+ /** Whether the Refund button is offered for this row. The SPA must not have to re-derive
329
+ * the server's rule, because the two disagreeing means a button that always errors. */
330
+ refundable: row.status === REFUNDABLE_STATUS,
331
+ };
332
+ }
333
+ /**
334
+ * Resolve the app-side owner for a page of payments, in ONE store read.
335
+ *
336
+ * A lookup per row would be a query per row of every page. `listCustomersByGatewayIds` takes the
337
+ * distinct ids instead; rows whose customer is unmapped simply get `null`, which is the honest
338
+ * answer — `ensureCustomer` is what writes the mapping, and an app that charges without it has
339
+ * no owner to report.
340
+ */
341
+ async function ownersFor(store, rows) {
342
+ const ids = [...new Set(rows.map((row) => row.customerId).filter((id) => !!id))];
343
+ const owners = new Map();
344
+ if (ids.length === 0)
345
+ return owners;
346
+ for (const customer of await store.listCustomersByGatewayIds(ids)) {
347
+ owners.set(customer.gatewayId, {
348
+ type: customer.ownerType,
349
+ id: customer.ownerId,
350
+ name: customer.name,
351
+ email: customer.email,
352
+ });
353
+ }
354
+ return owners;
355
+ }
356
+ /**
357
+ * `GET <api>/payments` — a page of `billing_payments`, newest first.
358
+ *
359
+ * Filters: `status`, `provider`, and the three that answer "did this one land?" —
360
+ * `reference` (the app's own `externalReference`), `gatewayId`, and `customerId`. All three are
361
+ * EXACT: they are join keys, and a prefix match on `order-4` returning `order-42` is a wrong
362
+ * answer to a question about money.
363
+ */
364
+ export async function payments(deps, req) {
365
+ const status = filterQuery(req.query.status);
366
+ const provider = filterQuery(req.query.provider);
367
+ // `reference` in the query string, `externalReference` in the store — the short name is what
368
+ // an operator types, and both are accepted so a link built from either keeps working.
369
+ const reference = filterQuery(req.query.reference) ?? filterQuery(req.query.externalReference);
370
+ const gatewayId = filterQuery(req.query.gatewayId);
371
+ const customerId = filterQuery(req.query.customerId);
372
+ const page = pageOf(req);
373
+ const filtered = await pageBy((limit, offset) => deps.store.listPayments({
374
+ ...(status !== undefined ? { status } : {}),
375
+ ...(reference !== undefined ? { externalReference: reference } : {}),
376
+ ...(gatewayId !== undefined ? { gatewayId } : {}),
377
+ ...(customerId !== undefined ? { customerId } : {}),
378
+ limit,
379
+ offset,
380
+ }), page, provider);
381
+ const owners = await ownersFor(deps.store, filtered.rows);
382
+ return ok({
383
+ payments: filtered.rows.map((row) => paymentJson(row, (row.customerId && owners.get(row.customerId)) || null)),
384
+ page: pageEnvelope(page, filtered),
385
+ statuses: PAYMENT_STATUSES,
386
+ currency: deps.currency,
387
+ /** Echoed so the SPA can render "no payment carries reference X" rather than "no payments". */
388
+ filters: {
389
+ reference: reference ?? null,
390
+ gatewayId: gatewayId ?? null,
391
+ customerId: customerId ?? null,
392
+ },
393
+ });
394
+ }
395
+ /**
396
+ * `GET <api>/payments/:gatewayId` — everything this system knows about ONE payment.
397
+ *
398
+ * The screen that did not exist, and the honest name for it is "what IS knowable", not "history".
399
+ * `billing_payments` is a single mutable row upserted in place: it has a current state and no
400
+ * past, so "what changed on this payment, and when?" cannot be answered from it and this endpoint
401
+ * does not pretend otherwise. What it assembles instead:
402
+ *
403
+ * - the row's CURRENT state, including `refundedAmount` and the app's own reference;
404
+ * - the app-side OWNER, through `billing_customers`;
405
+ * - every DISPUTE filed against it — that table does carry a real link (`payment_gateway_id`);
406
+ * - the LEDGER rows whose stored delivery names it, newest first. That is a payload substring
407
+ * scan, with everything that implies: it is unindexed, and it can match a delivery that merely
408
+ * mentions the id. `events.matchedBy` says so on the wire rather than in a comment nobody sees.
409
+ * - the AUDIT rows naming it — who refunded it, from this console, and when.
410
+ *
411
+ * The stored payload is still never returned. The timeline says an event ARRIVED and what type it
412
+ * was; what was inside it is telescope's job.
413
+ */
414
+ export async function paymentDetail(deps, req) {
415
+ const gatewayId = req.params.gatewayId;
416
+ if (gatewayId === undefined || gatewayId === '') {
417
+ return badRequest('A payment gateway id is required.');
418
+ }
419
+ // The LIST read, not `findPaymentByGatewayId`: that one hands back the implementation's row
420
+ // (a Lucid model in one store, a plain object in the other) and this response needs the
421
+ // normalized shape every other endpoint here serializes.
422
+ const [row] = await deps.store.listPayments({ gatewayId, limit: 1 });
423
+ if (row === undefined) {
424
+ return notFound(`No payment "${gatewayId}" is recorded locally.`);
425
+ }
426
+ const [owners, disputeRows, eventRows, auditRows] = await Promise.all([
427
+ ownersFor(deps.store, [row]),
428
+ deps.store.listDisputes({ limit: PAYMENT_TIMELINE_LIMIT }),
429
+ deps.store.listWebhookEventsForPayment(gatewayId, { limit: PAYMENT_TIMELINE_LIMIT }),
430
+ deps.store.listAuditEvents({
431
+ subjectType: 'payment',
432
+ subjectId: gatewayId,
433
+ limit: PAYMENT_TIMELINE_LIMIT,
434
+ }),
435
+ ]);
436
+ return ok({
437
+ payment: paymentJson(row, (row.customerId && owners.get(row.customerId)) || null),
438
+ // Filtered here rather than in the store: `listDisputes` has no payment filter, and adding
439
+ // one for a page that is already bounded would be a second read to maintain. A dispute list
440
+ // this long on one install is itself the emergency.
441
+ disputes: disputeRows.filter((d) => d.paymentGatewayId === gatewayId).map(disputeJson),
442
+ events: {
443
+ rows: eventRows.map(webhookEventJson),
444
+ /** How the rows were found — see the note on `listWebhookEventsForPayment`. */
445
+ matchedBy: 'payload-substring',
446
+ },
447
+ audit: auditRows.map(auditJson),
448
+ currency: deps.currency,
449
+ });
450
+ }
451
+ /** How many rows each strand of the per-payment view returns. A payment with more than this many
452
+ * ledger rows is not a timeline, it is an incident. */
453
+ const PAYMENT_TIMELINE_LIMIT = 50;
454
+ /**
455
+ * `GET <api>/subscriptions` — a page of `billing_subscriptions`, newest first,
456
+ * `status`/`provider` filters.
457
+ *
458
+ * For a subscription business this is the daily surface: `countActiveSubscriptions()` answers "how
459
+ * many", which is the wrong question on any day something is wrong. The operational questions are
460
+ * WHICH subscriptions are `past_due` (someone's card failed and they are about to lose access) and
461
+ * which are `paused` (a subscriber who is not paying and must not be read as active) — and a count
462
+ * cannot name a customer to email.
463
+ */
464
+ export async function subscriptions(deps, req) {
465
+ const status = filterQuery(req.query.status);
466
+ const provider = filterQuery(req.query.provider);
467
+ const page = pageOf(req);
468
+ const filtered = await pageBy((limit, offset) => deps.store.listSubscriptions({ ...(status !== undefined ? { status } : {}), limit, offset }), page, provider);
469
+ // The one number that makes the filter tabs worth reading: how many are past due IN TOTAL, not
470
+ // just on this page. Cheap — a single count — and it is the figure that decides the operator's
471
+ // morning.
472
+ const pastDue = await deps.store.countSubscriptions({ status: SUBSCRIPTION_DEFAULT_STATUS });
473
+ return ok({
474
+ subscriptions: filtered.rows.map((row) => ({
475
+ id: row.id,
476
+ gatewayId: row.gatewayId,
477
+ provider: row.provider,
478
+ status: row.status,
479
+ planId: row.planId,
480
+ customerId: row.customerId,
481
+ trialEndsAt: iso(row.trialEndsAt),
482
+ endsAt: iso(row.endsAt),
483
+ createdAt: iso(row.createdAt),
484
+ })),
485
+ page: pageEnvelope(page, filtered),
486
+ statuses: SUBSCRIPTION_STATUSES,
487
+ counts: { past_due: pastDue },
488
+ });
489
+ }
490
+ /**
491
+ * `GET <api>/webhook-events` — a page of the idempotency ledger, newest first,
492
+ * `status`/`provider` filters.
493
+ *
494
+ * The `error` column is carried through verbatim: a `failed` row is the only record that a handler
495
+ * threw, and its message is the only place the reason survives. The stored gateway payload is NOT
496
+ * returned — that is a debugging question, and telescope is where it belongs.
497
+ */
498
+ export async function webhookEvents(deps, req) {
499
+ const status = filterQuery(req.query.status);
500
+ const provider = filterQuery(req.query.provider);
501
+ // The filter the ledger was missing. `status` + `provider` answers "what is failing on Asaas";
502
+ // it could not answer "did a refund event arrive at all", which is the question the ledger gets
503
+ // read for the moment one specific charge is in doubt.
504
+ const type = filterQuery(req.query.type);
505
+ const page = pageOf(req);
506
+ const filtered = await pageBy((limit, offset) => deps.store.listWebhookEvents({
507
+ ...(status !== undefined ? { status } : {}),
508
+ ...(type !== undefined ? { type } : {}),
509
+ limit,
510
+ offset,
511
+ }), page, provider);
512
+ return ok({
513
+ events: filtered.rows.map(webhookEventJson),
514
+ page: pageEnvelope(page, filtered),
515
+ statuses: WEBHOOK_EVENT_STATUSES,
516
+ });
517
+ }
518
+ /** One ledger row as JSON. The stored payload is never part of it — telescope's job. */
519
+ function webhookEventJson(row) {
520
+ return {
521
+ id: row.id,
522
+ gatewayEventId: row.gatewayEventId,
523
+ provider: row.provider,
524
+ type: row.type,
525
+ status: row.status,
526
+ error: row.error,
527
+ createdAt: iso(row.createdAt),
528
+ updatedAt: iso(row.updatedAt),
529
+ /** Only a `failed` row can be retried — see `retryWebhookEvent`. */
530
+ retryable: row.status === 'failed',
531
+ };
532
+ }
533
+ /** One audit row as JSON. `amount` stays integer minor units. */
534
+ function auditJson(row) {
535
+ return {
536
+ id: row.id,
537
+ action: row.action,
538
+ /** `null` is "unattributed" — a console with no `dashboardAuth` cannot name anybody. */
539
+ actor: row.actor,
540
+ provider: row.provider,
541
+ subjectType: row.subjectType,
542
+ subjectId: row.subjectId,
543
+ amount: row.amount,
544
+ currency: row.currency,
545
+ message: row.message,
546
+ metadata: row.metadata,
547
+ createdAt: iso(row.createdAt),
548
+ };
549
+ }
550
+ /** One `billing_customers` row as JSON — the mapping that ties a gateway customer to an app user. */
551
+ function customerJson(row) {
552
+ return {
553
+ id: row.id,
554
+ gatewayId: row.gatewayId,
555
+ provider: row.provider,
556
+ ownerType: row.ownerType,
557
+ ownerId: row.ownerId,
558
+ email: row.email,
559
+ name: row.name,
560
+ taxId: row.taxId,
561
+ createdAt: iso(row.createdAt),
562
+ };
563
+ }
564
+ /**
565
+ * `GET <api>/customers` — a page of `billing_customers`, newest first.
566
+ *
567
+ * The endpoint that did not exist. `billing_customers` holds `owner_type`/`owner_id`, written by
568
+ * the app itself through `ensureCustomer`, and that mapping is the ONLY thing tying a payment to
569
+ * a person: a payment row carries `cus_…` and nothing else. Without this the console could show
570
+ * every charge in the system and never answer "which of these is user 4102's".
571
+ *
572
+ * Filters: `provider`, `ownerType`, `ownerId`, `gatewayId` — all exact. Pair `ownerType` with
573
+ * `ownerId`: one owner may legitimately hold a customer at every configured gateway, so an id
574
+ * alone is not a key.
575
+ */
576
+ export async function customers(deps, req) {
577
+ const provider = filterQuery(req.query.provider);
578
+ const ownerType = filterQuery(req.query.ownerType);
579
+ const ownerId = filterQuery(req.query.ownerId);
580
+ const gatewayId = filterQuery(req.query.gatewayId);
581
+ const page = pageOf(req);
582
+ const rows = await deps.store.listCustomers({
583
+ ...(provider !== undefined ? { provider } : {}),
584
+ ...(ownerType !== undefined ? { ownerType } : {}),
585
+ ...(ownerId !== undefined ? { ownerId } : {}),
586
+ ...(gatewayId !== undefined ? { gatewayId } : {}),
587
+ limit: page.limit,
588
+ offset: page.offset,
589
+ });
590
+ return ok({
591
+ customers: rows.map(customerJson),
592
+ // No `scanned`/`truncated`: every filter here is a column the store applies, so there is no
593
+ // bounded scan behind this list and claiming the caveat would be claiming one that does not
594
+ // apply. Same reason the disputes page reports a narrower envelope.
595
+ page: { limit: page.limit, offset: page.offset, count: rows.length },
596
+ });
597
+ }
598
+ /** The audit actions the filter offers. A free string in the column, so this is a UI convenience
599
+ * list, not a validation whitelist — an app that records its own actions still sees them. */
600
+ export const AUDIT_ACTION_FILTERS = [
601
+ AUDIT_ACTIONS.webhookRejected,
602
+ AUDIT_ACTIONS.refund,
603
+ AUDIT_ACTIONS.disputeResolved,
604
+ ];
605
+ /**
606
+ * `GET <api>/audit` — the trail of things that happened which no other table records.
607
+ *
608
+ * Three kinds of row, and each of them used to be a diagnostic and nothing else:
609
+ *
610
+ * - `webhook.rejected` — a delivery the endpoint REFUSED. This is the one that matters most,
611
+ * because it is otherwise invisible: a rejected delivery never becomes a ledger row, so a
612
+ * rotated webhook secret looks exactly like a quiet week — zero events, zero failures, every
613
+ * check green — while every refund, chargeback and dispute closure is dropped on the floor.
614
+ * - `payment.refunded` — WHO refunded what, from this console. The payment row moves only when
615
+ * the gateway's webhook lands, and that row names no person.
616
+ * - `dispute.resolved` — who closed a dispute the gateway will never close itself.
617
+ *
618
+ * Filters: `action`, `actor`, `provider`, `subjectType`, `subjectId` — all exact.
619
+ */
620
+ export async function auditEvents(deps, req) {
621
+ const action = filterQuery(req.query.action);
622
+ const actor = filterQuery(req.query.actor);
623
+ const provider = filterQuery(req.query.provider);
624
+ const subjectType = filterQuery(req.query.subjectType);
625
+ const subjectId = filterQuery(req.query.subjectId);
626
+ const page = pageOf(req);
627
+ const rows = await deps.store.listAuditEvents({
628
+ ...(action !== undefined ? { action } : {}),
629
+ ...(actor !== undefined ? { actor } : {}),
630
+ ...(provider !== undefined ? { provider } : {}),
631
+ ...(subjectType !== undefined ? { subjectType } : {}),
632
+ ...(subjectId !== undefined ? { subjectId } : {}),
633
+ limit: page.limit,
634
+ offset: page.offset,
635
+ });
636
+ return ok({
637
+ audit: rows.map(auditJson),
638
+ // Column filters throughout, so no bounded scan and no `truncated` caveat to claim.
639
+ page: { limit: page.limit, offset: page.offset, count: rows.length },
640
+ actions: AUDIT_ACTION_FILTERS,
641
+ });
642
+ }
643
+ /**
644
+ * `GET <api>/providers` — the gateways this install actually uses, for the provider filter.
645
+ *
646
+ * Driven by the DATA, never by a hardcoded list: this package ships eighteen drivers and an
647
+ * install runs two or three of them, so a static list would offer fifteen filters that return
648
+ * nothing and hide the one that matters behind them. The ledger side is exact
649
+ * (`webhookEventBreakdown` groups the whole table); the payment/subscription side reads the most
650
+ * recent {@link PROVIDER_DISCOVERY_SCAN} rows, which is enough to notice a gateway that is in use.
651
+ */
652
+ export async function providers(deps) {
653
+ const [paymentRows, subscriptionRows, breakdown] = await Promise.all([
654
+ deps.store.listPayments({ limit: PROVIDER_DISCOVERY_SCAN }),
655
+ deps.store.listSubscriptions({ limit: PROVIDER_DISCOVERY_SCAN }),
656
+ deps.store.webhookEventBreakdown({}),
657
+ ]);
658
+ const names = new Set();
659
+ const types = new Set();
660
+ for (const row of paymentRows)
661
+ names.add(row.provider);
662
+ for (const row of subscriptionRows)
663
+ names.add(row.provider);
664
+ for (const line of breakdown) {
665
+ names.add(line.provider);
666
+ types.add(line.type);
667
+ }
668
+ // The event-type vocabulary, from the DATA for the same reason the provider list is: the types
669
+ // an install actually receives are a handful, the ones this package can emit are many, and a
670
+ // filter offering twenty that return nothing hides the three that do not.
671
+ return ok({ providers: [...names].sort(), eventTypes: [...types].sort() });
672
+ }
673
+ /** The dispute statuses that mean the matter is FINISHED — `DISPUTE_STATUSES` minus the open
674
+ * ones. Only these can be recorded through {@link resolveDispute}: "resolve" that could put a
675
+ * row back into `open` is not a resolution, it is an edit box over a money table. */
676
+ export const DISPUTE_RESOLUTION_STATUSES = DISPUTE_STATUSES.filter((status) => !OPEN_DISPUTE_STATUSES.includes(status));
677
+ /**
678
+ * `POST <api>/disputes/:gatewayId/resolve` — record how a dispute ENDED.
679
+ *
680
+ * The one thing this console could not do, and the reason the dispute alarm was permanently red
681
+ * on a real install: Asaas publishes no lost-dispute event at all, and the driver hardcodes
682
+ * `outcome: 'won'` when it closes one — so a dispute that was LOST stays `open` in
683
+ * `billing_disputes` forever. `listDisputesDueWithin` counts past-deadline rows on purpose, so
684
+ * the check stays red, and a fifteen-minute cron logs the same failure until nobody reads it.
685
+ * A gateway that never closes the loop is the NORMAL case here, not the exception.
686
+ *
687
+ * This does not fight, accept or refund anything — nothing here talks to a gateway. It records
688
+ * an outcome that has already happened somewhere else, with the status, the outcome and WHO said
689
+ * so, which is the difference between a resolution and an edit box.
690
+ *
691
+ * Body: `{ status, outcome?, note? }`. `status` must be a finished one
692
+ * ({@link DISPUTE_RESOLUTION_STATUSES}); `outcome` defaults to it.
693
+ */
694
+ export async function resolveDispute(deps, req) {
695
+ const gatewayId = req.params.gatewayId;
696
+ if (gatewayId === undefined || gatewayId === '') {
697
+ return badRequest('A dispute gateway id is required.');
698
+ }
699
+ const body = (typeof req.body === 'object' && req.body !== null ? req.body : {});
700
+ const status = typeof body.status === 'string' ? body.status : '';
701
+ if (!DISPUTE_RESOLUTION_STATUSES.includes(status)) {
702
+ return badRequest(`\`status\` must be one of ${DISPUTE_RESOLUTION_STATUSES.map((s) => `"${s}"`).join(', ')} — a dispute is only resolved by ending.`);
703
+ }
704
+ if (body.outcome !== undefined && body.outcome !== null && typeof body.outcome !== 'string') {
705
+ return badRequest('`outcome` must be a string when given.');
706
+ }
707
+ if (body.note !== undefined && body.note !== null && typeof body.note !== 'string') {
708
+ return badRequest('`note` must be a string when given.');
709
+ }
710
+ const outcome = typeof body.outcome === 'string' && body.outcome !== '' ? body.outcome : status;
711
+ const note = typeof body.note === 'string' && body.note !== '' ? body.note : null;
712
+ const existing = await deps.store.findDisputeByGatewayId(gatewayId);
713
+ if (existing === null) {
714
+ return notFound(`No dispute "${gatewayId}" is recorded locally.`);
715
+ }
716
+ const dispute = readDisputeRow(existing);
717
+ const now = (deps.now ?? (() => new Date()))();
718
+ // `saveDispute` leaves absent fields alone, so the deadline and the reason the OPENING event
719
+ // carried survive this write — which is the whole point of that rule.
720
+ await deps.store.saveDispute({
721
+ gatewayId,
722
+ paymentGatewayId: dispute.paymentGatewayId,
723
+ provider: dispute.provider,
724
+ status,
725
+ outcome,
726
+ closedAt: now,
727
+ });
728
+ // WHO closed it. The row itself records the outcome; only this records the person, and on a
729
+ // gateway that publishes no closing event a human IS the entire provenance of that outcome.
730
+ const audit = await deps.store.recordAuditEvent({
731
+ action: AUDIT_ACTIONS.disputeResolved,
732
+ ...(deps.actor !== undefined ? { actor: deps.actor } : {}),
733
+ provider: dispute.provider,
734
+ subjectType: 'dispute',
735
+ subjectId: gatewayId,
736
+ ...(dispute.amount !== null ? { amount: dispute.amount } : {}),
737
+ ...(dispute.currency !== null ? { currency: dispute.currency } : {}),
738
+ message: note,
739
+ metadata: {
740
+ status,
741
+ outcome,
742
+ previousStatus: dispute.status,
743
+ paymentGatewayId: dispute.paymentGatewayId,
744
+ },
745
+ createdAt: now,
746
+ });
747
+ return ok({
748
+ dispute: {
749
+ gatewayId,
750
+ paymentGatewayId: dispute.paymentGatewayId,
751
+ provider: dispute.provider,
752
+ status,
753
+ outcome,
754
+ closedAt: now.toISOString(),
755
+ },
756
+ /** `null` on an install whose `billing_audit_events` table is not there yet — the dispute
757
+ * still closed, and saying so beats pretending the note was filed. */
758
+ audit: audit === null ? null : auditJson(audit),
759
+ note: 'Recorded locally. Nothing was sent to the gateway — this closes the row a gateway that publishes no lost-dispute event would otherwise leave open forever.',
760
+ });
761
+ }
762
+ /**
763
+ * Read the fields a resolution needs off whatever row the store returned.
764
+ *
765
+ * `findDisputeByGatewayId` hands back the IMPLEMENTATION's row (a Lucid model in one store, a
766
+ * plain object in the other), not the normalized `DisputeListItem` the lists get — the same
767
+ * situation, and the same defensive read, as {@link readPaymentRow}. `amount` goes through
768
+ * `Number` because Postgres hands a `bigint` back as a string.
769
+ */
770
+ function readDisputeRow(row) {
771
+ const r = row;
772
+ return {
773
+ paymentGatewayId: String(r.paymentGatewayId ?? ''),
774
+ provider: String(r.provider ?? ''),
775
+ status: String(r.status ?? ''),
776
+ amount: r.amount === null || r.amount === undefined ? null : Number(r.amount),
777
+ currency: typeof r.currency === 'string' ? r.currency : null,
778
+ };
779
+ }
780
+ /**
781
+ * `POST <api>/payments/:gatewayId/refund` — refund a payment through its own gateway.
782
+ *
783
+ * `POST`, never `GET`: a refund reachable by URL is a refund a crawler can trigger. Body is
784
+ * `{ amount?: number }` in integer minor units; omit it for the full amount.
785
+ *
786
+ * The local row is deliberately NOT rewritten here. The gateway confirms a refund with a
787
+ * `payment.refunded` webhook, and that is what moves the row to `refunded` — writing it optimistically
788
+ * would show the operator a refund that may never have settled.
789
+ */
790
+ export async function refundPayment(deps, req) {
791
+ const gatewayId = req.params.gatewayId;
792
+ if (gatewayId === undefined || gatewayId === '') {
793
+ return badRequest('A payment gateway id is required.');
794
+ }
795
+ const row = await deps.store.findPaymentByGatewayId(gatewayId);
796
+ if (row === null) {
797
+ return notFound(`No payment "${gatewayId}" is recorded locally.`);
798
+ }
799
+ const payment = readPaymentRow(row);
800
+ if (payment.status !== REFUNDABLE_STATUS) {
801
+ return conflict(`Payment "${gatewayId}" is "${payment.status}", not "${REFUNDABLE_STATUS}". Only a paid payment can be refunded from here.`);
802
+ }
803
+ const amount = readRefundAmount(req.body);
804
+ if (amount === INVALID_AMOUNT) {
805
+ return badRequest('`amount` must be a positive integer in the currency’s minor units, or omitted for a full refund.');
806
+ }
807
+ if (amount !== undefined && amount > payment.amount) {
808
+ return badRequest(`A partial refund of ${amount} is larger than the payment itself (${payment.amount} ${payment.currency}).`);
809
+ }
810
+ const refund = deps.actions?.refund;
811
+ if (refund === undefined) {
812
+ return unavailable('Refunds need the payments manager, which is not available in this deployment.');
813
+ }
814
+ const outcome = await refund({
815
+ provider: payment.provider,
816
+ gatewayId,
817
+ ...(amount !== undefined ? { amount } : {}),
818
+ });
819
+ switch (outcome.kind) {
820
+ case 'ok': {
821
+ // WHO refunded WHAT. The only record of a console refund used to be a diagnostic carrying
822
+ // a gateway id, a provider and an amount — and no actor at all, even though `enforce()`
823
+ // had already verified exactly who authorised the request. The payment row will not carry
824
+ // it either: the gateway's webhook moves that row and it names no person. Partial refunds
825
+ // make it sharper still, now that several of them can land on one payment.
826
+ const audit = await deps.store.recordAuditEvent({
827
+ action: AUDIT_ACTIONS.refund,
828
+ ...(deps.actor !== undefined ? { actor: deps.actor } : {}),
829
+ provider: payment.provider,
830
+ subjectType: 'payment',
831
+ subjectId: gatewayId,
832
+ // What was ASKED for, in the payment's own currency — `amount` absent means the whole
833
+ // charge, and recording the figure rather than a null is what makes a partial legible.
834
+ amount: amount ?? payment.amount,
835
+ currency: payment.currency,
836
+ metadata: {
837
+ partial: amount !== undefined,
838
+ paymentAmount: payment.amount,
839
+ refundGatewayId: outcome.refund.gatewayId,
840
+ refundStatus: outcome.refund.status,
841
+ },
842
+ });
843
+ return ok({
844
+ refund: outcome.refund,
845
+ payment: {
846
+ gatewayId,
847
+ provider: payment.provider,
848
+ amount: payment.amount,
849
+ currency: payment.currency,
850
+ },
851
+ /** `null` when this install has no `billing_audit_events` table yet. The refund still
852
+ * happened — failing it because the note could not be filed would be strictly worse. */
853
+ audit: audit === null ? null : auditJson(audit),
854
+ // The row still says `paid` until the gateway's webhook lands. Say so, rather than
855
+ // letting the operator read the unchanged list as a failed refund.
856
+ note: 'The payment row updates when the gateway’s refund webhook arrives.',
857
+ });
858
+ }
859
+ case 'unavailable':
860
+ return unavailable(outcome.message);
861
+ case 'unsupported':
862
+ return conflict(outcome.message);
863
+ case 'gateway-error':
864
+ return upstreamFailed(outcome.message);
865
+ }
866
+ }
867
+ /**
868
+ * `POST <api>/webhook-events/:gatewayEventId/retry` — re-run a failed event's handlers.
869
+ *
870
+ * The single most useful thing an operator can do after fixing a handler bug: a `failed` row means
871
+ * the dispatcher gave up, so whatever that event described — a subscription activated, a payment
872
+ * recorded — never happened, and nothing will retry it. Replaying is safe because the ledger
873
+ * re-claims a `failed` event (`recordWebhookEvent` returns it again) while refusing an in-flight or
874
+ * already-processed one, so this can never double-apply a redelivery.
875
+ *
876
+ * `POST`, never `GET`, for the same reason the refund is.
877
+ */
878
+ export async function retryWebhookEvent(deps, req) {
879
+ const gatewayEventId = req.params.gatewayEventId;
880
+ if (gatewayEventId === undefined || gatewayEventId === '') {
881
+ return badRequest('A gateway event id is required.');
882
+ }
883
+ const row = await deps.store.findWebhookEventByGatewayEventId(gatewayEventId);
884
+ if (row === null) {
885
+ return notFound(`No webhook event "${gatewayEventId}" is in the ledger.`);
886
+ }
887
+ if (row.status !== 'failed') {
888
+ return conflict(`Event "${gatewayEventId}" is "${row.status}", not "failed". An in-flight event is still running and a processed one already took effect.`);
889
+ }
890
+ const replay = deps.actions?.replayWebhook;
891
+ if (replay === undefined) {
892
+ return unavailable('Retrying an event needs the payments manager, which is not available in this deployment.');
893
+ }
894
+ const outcome = await replay({
895
+ gatewayEventId,
896
+ provider: row.provider,
897
+ type: row.type,
898
+ previousError: row.error,
899
+ });
900
+ switch (outcome.kind) {
901
+ case 'processed':
902
+ return ok({ gatewayEventId, status: 'processed' });
903
+ case 'conflict':
904
+ return conflict(`Event "${gatewayEventId}" was claimed by something else before the retry started.`);
905
+ case 'undeliverable':
906
+ // 422: the request was right, the event simply cannot be rebuilt here. The ledger row is
907
+ // untouched, which the message says so the operator does not go looking for a change.
908
+ return {
909
+ status: 422,
910
+ body: {
911
+ error: outcome.message,
912
+ note: 'The ledger row is unchanged. Redeliver this event from the gateway’s own dashboard instead.',
913
+ },
914
+ };
915
+ case 'failed':
916
+ return upstreamFailed(outcome.message);
917
+ }
918
+ }
919
+ /** Sentinel for an `amount` that was supplied and is not a usable one. Distinct from `undefined`,
920
+ * which means "no amount given" — a full refund. */
921
+ const INVALID_AMOUNT = Symbol('invalid-amount');
922
+ /**
923
+ * Read `{ amount }` off a refund body.
924
+ *
925
+ * Strict on purpose: a decimal `19.9` reaching a gateway that wants minor units is a refund of
926
+ * nineteen cents, and a string `'1990'` is a refund of nothing. Both are rejected rather than
927
+ * coerced.
928
+ */
929
+ function readRefundAmount(body) {
930
+ if (body === undefined || body === null)
931
+ return undefined;
932
+ if (typeof body !== 'object')
933
+ return INVALID_AMOUNT;
934
+ const raw = body.amount;
935
+ if (raw === undefined || raw === null || raw === '')
936
+ return undefined;
937
+ if (typeof raw !== 'number' || !Number.isInteger(raw) || raw <= 0)
938
+ return INVALID_AMOUNT;
939
+ return raw;
940
+ }
941
+ /**
942
+ * Read the fields the refund needs off whatever row the store returned.
943
+ *
944
+ * `findPaymentByGatewayId` hands back the IMPLEMENTATION's row (a Lucid model in one store, a
945
+ * plain object in the other), not the normalized `PaymentListItem` the list endpoints get — so the
946
+ * few fields used here are read defensively, and `amount` goes through `Number` because Postgres
947
+ * hands a numeric column back as a string.
948
+ */
949
+ function readPaymentRow(row) {
950
+ const r = row;
951
+ return {
952
+ provider: String(r.provider ?? ''),
953
+ status: String(r.status ?? ''),
954
+ amount: Number(r.amount ?? 0),
955
+ currency: String(r.currency ?? ''),
956
+ };
957
+ }
958
+ /** Re-exported so the provider and the SPA agree on the default page size without duplicating it. */
959
+ export { BILLING_LIST_DEFAULT_LIMIT };
960
+ //# sourceMappingURL=handlers.js.map