@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
@@ -1,16 +1,160 @@
1
1
  import { DateTime } from 'luxon';
2
- import { BillingPayment as DefaultPayment, BillingSubscription as DefaultSubscription, BillingUsageEvent as DefaultUsageEvent, BillingWebhookEvent as DefaultWebhookEvent, } from './mixins/index.js';
2
+ import { OPEN_DISPUTE_STATUSES, } from './billing_store.js';
3
+ import { clampLimit, clampOffset } from './list_query.js';
4
+ import { BillingAuditEvent as DefaultAuditEvent, BillingCustomer as DefaultCustomer, BillingDispute as DefaultDispute, BillingPayment as DefaultPayment, BillingSubscription as DefaultSubscription, BillingUsageEvent as DefaultUsageEvent, BillingWebhookEvent as DefaultWebhookEvent, } from './mixins/index.js';
5
+ import { createBillingTables, detectDialect, registerBillingSchemaCache, } from './schema.js';
6
+ /** Lucid hands back Luxon `DateTime`s; the read SPI speaks plain `Date`. */
7
+ function toDate(value) {
8
+ return value ? value.toJSDate() : null;
9
+ }
10
+ /**
11
+ * Read a single aggregate row.
12
+ *
13
+ * Every aggregate query below must go through `.pojo()`. A model query builder hydrates
14
+ * its rows into model INSTANCES, and a value with no matching column — `count(*) as total`
15
+ * — is not assigned to the instance; it is tucked into `$extras`. Reading `row.total` off
16
+ * the instance therefore yields `undefined`, which `?? 0` then turns into a confident,
17
+ * silent zero. `.pojo()` opts out of hydration and hands back the raw row.
18
+ *
19
+ * Counts also come back as strings on some drivers (Postgres `bigint`), hence the `Number`.
20
+ */
21
+ function toCount(rows) {
22
+ const first = rows?.[0];
23
+ return Number(first?.total ?? 0);
24
+ }
25
+ /**
26
+ * The instant a deadline query looks up to: `now + withinHours`.
27
+ *
28
+ * There is no lower bound on purpose. A deadline that has already PASSED is still open and
29
+ * still unanswered, and excluding it would make the alert go quiet at exactly the moment it
30
+ * became true.
31
+ */
32
+ function deadlineCutoff(query) {
33
+ const now = query.now ?? new Date();
34
+ return new Date(now.getTime() + query.withinHours * 3_600_000);
35
+ }
36
+ /** One dispute row, normalized for reading. `amount` stays integer minor units. */
37
+ function disputeItem(row) {
38
+ return {
39
+ id: String(row.id),
40
+ gatewayId: row.gatewayId,
41
+ paymentGatewayId: row.paymentGatewayId,
42
+ provider: row.provider,
43
+ status: row.status,
44
+ reason: row.reason ?? null,
45
+ // `bigint` comes back as a STRING on Postgres, like every other amount in this store.
46
+ amount: row.amount === null || row.amount === undefined ? null : Number(row.amount),
47
+ currency: row.currency ?? null,
48
+ evidenceDueBy: toDate(row.evidenceDueBy),
49
+ outcome: row.outcome ?? null,
50
+ openedAt: toDate(row.openedAt),
51
+ closedAt: toDate(row.closedAt),
52
+ createdAt: toDate(row.createdAt),
53
+ };
54
+ }
55
+ /**
56
+ * The normalized event, as a jsonb-storable value.
57
+ *
58
+ * Drivers normalize onto object shapes (`PaymentWebhookData`, `SubscriptionWebhookData`), but
59
+ * `WebhookEvent.data` is `unknown` and a driver may hand back anything. Anything that is not
60
+ * an object is stored as `null` rather than coerced: the retry checks for `null` and says it
61
+ * cannot replay, which is a better answer than rebuilding an event around a string.
62
+ */
63
+ function normalizedColumn(value) {
64
+ if (value === null || value === undefined)
65
+ return null;
66
+ if (typeof value !== 'object' || Array.isArray(value))
67
+ return null;
68
+ return value;
69
+ }
70
+ /** One ledger row, normalized for reading. The stored payload is NEVER part of it. */
71
+ function webhookEventItem(row) {
72
+ return {
73
+ id: String(row.id),
74
+ gatewayEventId: row.gatewayEventId,
75
+ provider: row.provider,
76
+ type: row.type,
77
+ status: row.status,
78
+ error: row.error ?? null,
79
+ createdAt: toDate(row.createdAt),
80
+ updatedAt: toDate(row.updatedAt),
81
+ };
82
+ }
83
+ /** One customer-mapping row, normalized for reading. Shared by both customer reads. */
84
+ function customerItem(row) {
85
+ return {
86
+ id: String(row.id),
87
+ gatewayId: row.gatewayId,
88
+ provider: row.provider,
89
+ ownerType: row.ownerType ?? null,
90
+ ownerId: row.ownerId ?? null,
91
+ email: row.email ?? null,
92
+ name: row.name ?? null,
93
+ taxId: row.taxId ?? null,
94
+ createdAt: toDate(row.createdAt),
95
+ };
96
+ }
97
+ /** One audit row, normalized for reading. `amount` is BIGINT — a STRING on Postgres. */
98
+ function auditItem(row) {
99
+ return {
100
+ id: String(row.id),
101
+ action: row.action,
102
+ actor: row.actor ?? null,
103
+ provider: row.provider ?? null,
104
+ subjectType: row.subjectType ?? null,
105
+ subjectId: row.subjectId ?? null,
106
+ amount: row.amount === null || row.amount === undefined ? null : Number(row.amount),
107
+ currency: row.currency ?? null,
108
+ message: row.message ?? null,
109
+ metadata: row.metadata ?? null,
110
+ createdAt: toDate(row.createdAt),
111
+ };
112
+ }
3
113
  /**
4
114
  * Lucid implementation of {@link BillingStore}. Resolves the models passed in (defaulting
5
115
  * to the bundled ones) and writes through them, so custom models/composed mixins keep
6
116
  * working with the same store.
7
117
  */
8
118
  export class LucidBillingStore {
119
+ /**
120
+ * Per-column answers to "does this install's table actually have it?", cached per store.
121
+ *
122
+ * `external_reference` and `normalized` arrive in a SECOND migration
123
+ * (an earlier schema), because the first one is already in production
124
+ * everywhere this package is installed. An app that upgrades the package before running it
125
+ * must keep taking webhooks — a payment that records no reference, and a ledger row the
126
+ * dashboard cannot replay, are both survivable; a `column ... does not exist` on every
127
+ * gateway delivery is not. So the write asks once, per column, and skips what is not there.
128
+ *
129
+ * Keyed by `table.column`. One `information_schema` read per column per process, then
130
+ * nothing. A probe that itself fails answers "present": dropping data quietly is the worse
131
+ * of the two failures, and the real INSERT will then say exactly what is wrong.
132
+ */
133
+ #columnCache = new Map();
134
+ /**
135
+ * Whether to create the billing tables on first use. On by default — the ecosystem
136
+ * convention is that a lib owns its own schema, the same way `@adonis-agora/durable` and
137
+ * `@adonis-agora/authz` do. An app that wants explicit control sets
138
+ * `billing.autoCreateSchema: false` and calls {@link createBillingTables} from a migration
139
+ * instead; both paths run the SAME DDL, so they cannot drift.
140
+ */
141
+ #autoCreateSchema;
142
+ /**
143
+ * The one-shot schema promise. Cached so the DDL runs once per store, not once per query,
144
+ * and so concurrent first calls await the same round trip rather than racing six
145
+ * `CREATE TABLE IF NOT EXISTS` statements against each other.
146
+ */
147
+ #schemaReady;
148
+ #customerModel;
9
149
  #subscriptionModel;
10
150
  #paymentModel;
11
151
  #webhookEventModel;
12
152
  #usageEventModel;
13
- constructor(models = {}) {
153
+ #disputeModel;
154
+ #auditEventModel;
155
+ constructor(models = {}, options = {}) {
156
+ this.#autoCreateSchema = options.autoCreateSchema !== false;
157
+ this.#customerModel = (models.customerModel ?? DefaultCustomer);
14
158
  this.#subscriptionModel = (models.subscriptionModel ??
15
159
  DefaultSubscription);
16
160
  this.#paymentModel = (models.paymentModel ?? DefaultPayment);
@@ -18,8 +162,186 @@ export class LucidBillingStore {
18
162
  DefaultWebhookEvent);
19
163
  this.#usageEventModel = (models.usageEventModel ??
20
164
  DefaultUsageEvent);
165
+ this.#disputeModel = (models.disputeModel ?? DefaultDispute);
166
+ this.#auditEventModel = (models.auditEventModel ??
167
+ DefaultAuditEvent);
168
+ // So `dropBillingTables()` can invalidate the memo below. Without it a suite that drops
169
+ // the tables between groups leaves this store certain they still exist, and every
170
+ // following query fails on a table nothing will re-create.
171
+ registerBillingSchemaCache(() => {
172
+ this.#schemaReady = undefined;
173
+ this.#columnCache.clear();
174
+ });
175
+ }
176
+ /**
177
+ * Create the billing tables if they are not there. Idempotent, and safe to call from an
178
+ * app that already ran the published migration — every statement is `IF NOT EXISTS`.
179
+ *
180
+ * Public because an app that turns `autoCreateSchema` off may still want it in a seeder or
181
+ * a test bootstrap without reaching for the standalone function.
182
+ */
183
+ /**
184
+ * Turn the automatic schema creation off after construction.
185
+ *
186
+ * One direction only, and deliberately: the flag exists to keep a library from running
187
+ * DDL against a database somebody else owns, so honouring `false` wherever it appears is
188
+ * the safe move and there is no way to switch it back on from here.
189
+ *
190
+ * It exists because the flag can arrive after the store does. An app that builds its own
191
+ * store — `billing.store: () => lucidBillingStore({ paymentModel: MyPayment })` — has
192
+ * already constructed it by the time the provider reads the config, and skipping the flag
193
+ * there would create tables in exactly the shared database it was set to protect.
194
+ */
195
+ disableAutoCreateSchema() {
196
+ this.#autoCreateSchema = false;
197
+ }
198
+ async ensureSchema() {
199
+ // NOT gated by `#ready()`: that is what calls this, and awaiting it here would have the
200
+ // promise await itself. A deadlock, not a crash — every query would hang forever.
201
+ await createBillingTables(this.#paymentModel.query().client);
202
+ }
203
+ /**
204
+ * Awaited at the top of every public method.
205
+ *
206
+ * Every one, not just the writes: a read against a table that does not exist is the same
207
+ * error, and it is the first thing an app hits — a dashboard opened before the first
208
+ * charge. `test/lucid_store_schema.spec.ts` enumerates this class's public methods and
209
+ * fails when one of them skips this, because "I added a method and forgot" is exactly how
210
+ * a lazily-created schema becomes a schema that exists most of the time.
211
+ */
212
+ async #ready() {
213
+ if (!this.#autoCreateSchema)
214
+ return;
215
+ if (!this.#schemaReady)
216
+ this.#schemaReady = this.ensureSchema();
217
+ return this.#schemaReady;
218
+ }
219
+ async #hasColumn(model, column) {
220
+ const key = `${model.table}.${column}`;
221
+ let answer = this.#columnCache.get(key);
222
+ if (answer === undefined) {
223
+ answer = model
224
+ .query()
225
+ .client.columnsInfo(model.table)
226
+ .then((columns) => Object.hasOwn(columns, column))
227
+ .catch(() => true);
228
+ this.#columnCache.set(key, answer);
229
+ }
230
+ return answer;
231
+ }
232
+ /**
233
+ * Does this install have the `billing_disputes` table at all?
234
+ *
235
+ * Same question as {@link LucidBillingStore.hasColumn}, one level up, and for the same
236
+ * reason: `billing_disputes` arrives in a THIRD migration (an earlier schema), so an
237
+ * app that upgrades the package before running it has a processor that wants to write
238
+ * disputes and no table to write them to. The dispute row is ADDITIONAL — the payment row
239
+ * still moves, the diagnostics still publish — so a missing table skips the write and
240
+ * every dispute read answers empty, rather than failing every gateway delivery with
241
+ * `relation "billing_disputes" does not exist` until someone notices.
242
+ *
243
+ * Reuses the column cache and the column probe: `columnsInfo` on a table that does not
244
+ * exist yields no columns at all. A probe that itself fails answers "present", so the real
245
+ * query then says exactly what is wrong.
246
+ */
247
+ async #hasDisputesTable() {
248
+ return this.#hasTable(this.#disputeModel);
249
+ }
250
+ /**
251
+ * Does this install have `billing_audit_events`?
252
+ *
253
+ * Same question, same tolerance: the table arrives after the package shipped, and every write
254
+ * to it is ADDITIONAL to an action that already happened. Refusing a refund the gateway
255
+ * already accepted because the audit note could not be filed would be strictly worse than
256
+ * filing no note.
257
+ */
258
+ async #hasAuditTable() {
259
+ return this.#hasTable(this.#auditEventModel);
260
+ }
261
+ /**
262
+ * "Does this table exist?", cached beside the column answers and resolved the same way:
263
+ * `columnsInfo` on a missing table yields no columns at all. A probe that itself fails
264
+ * answers "present", so the real query then says exactly what is wrong.
265
+ */
266
+ async #hasTable(model) {
267
+ const key = `${model.table}.*`;
268
+ let answer = this.#columnCache.get(key);
269
+ if (answer === undefined) {
270
+ answer = model
271
+ .query()
272
+ .client.columnsInfo(model.table)
273
+ .then((columns) => Object.keys(columns).length > 0)
274
+ .catch(() => true);
275
+ this.#columnCache.set(key, answer);
276
+ }
277
+ return answer;
278
+ }
279
+ async saveCustomer(customer) {
280
+ await this.#ready();
281
+ const existing = await this.findCustomerByGatewayId(customer.gatewayId);
282
+ const row = (existing ?? new this.#customerModel());
283
+ row.gatewayId = customer.gatewayId;
284
+ row.provider = customer.provider;
285
+ // Absent fields do NOT erase what is already recorded: a later call that only knows the
286
+ // gateway id — a reconcile, a webhook — would otherwise blank the owner mapping that an
287
+ // earlier, better-informed call wrote, which is the whole reason the row exists.
288
+ if (customer.ownerType !== undefined)
289
+ row.ownerType = customer.ownerType;
290
+ if (customer.ownerId !== undefined)
291
+ row.ownerId = customer.ownerId;
292
+ if (customer.email !== undefined)
293
+ row.email = customer.email;
294
+ if (customer.name !== undefined)
295
+ row.name = customer.name;
296
+ if (customer.taxId !== undefined)
297
+ row.taxId = customer.taxId;
298
+ if (customer.metadata !== undefined)
299
+ row.metadata = customer.metadata;
300
+ await row.save();
301
+ return row;
302
+ }
303
+ async findCustomerByGatewayId(gatewayId) {
304
+ await this.#ready();
305
+ return (await this.#customerModel.findBy('gateway_id', gatewayId));
306
+ }
307
+ async findCustomerByOwner(ownerType, ownerId, provider) {
308
+ await this.#ready();
309
+ return (await this.#customerModel
310
+ .query()
311
+ .where('owner_type', ownerType)
312
+ .where('owner_id', ownerId)
313
+ .where('provider', provider)
314
+ .first());
315
+ }
316
+ async listCustomers(query) {
317
+ await this.#ready();
318
+ const builder = this.#customerModel.query().orderBy('created_at', 'desc');
319
+ if (query.provider !== undefined)
320
+ builder.where('provider', query.provider);
321
+ if (query.ownerType !== undefined)
322
+ builder.where('owner_type', query.ownerType);
323
+ if (query.ownerId !== undefined)
324
+ builder.where('owner_id', query.ownerId);
325
+ if (query.gatewayId !== undefined)
326
+ builder.where('gateway_id', query.gatewayId);
327
+ const rows = (await builder
328
+ .limit(clampLimit(query.limit))
329
+ .offset(clampOffset(query.offset)));
330
+ return rows.map(customerItem);
331
+ }
332
+ async listCustomersByGatewayIds(gatewayIds) {
333
+ await this.#ready();
334
+ // No ids means no query. `whereIn('gateway_id', [])` is a guaranteed-empty scan on some
335
+ // dialects and a syntax error on others; either way it is a round trip for a known answer.
336
+ if (gatewayIds.length === 0)
337
+ return [];
338
+ const rows = (await this.#customerModel
339
+ .query()
340
+ .whereIn('gateway_id', [...new Set(gatewayIds)]));
341
+ return rows.map(customerItem);
21
342
  }
22
343
  async saveSubscription(sub) {
344
+ await this.#ready();
23
345
  const existing = await this.findSubscriptionByGatewayId(sub.gatewayId);
24
346
  const row = (existing ?? new this.#subscriptionModel());
25
347
  row.gatewayId = sub.gatewayId;
@@ -33,11 +355,46 @@ export class LucidBillingStore {
33
355
  await row.save();
34
356
  return row;
35
357
  }
358
+ async listSubscriptions(query) {
359
+ await this.#ready();
360
+ const builder = this.#subscriptionModel.query().orderBy('created_at', 'desc');
361
+ if (query.status !== undefined)
362
+ builder.where('status', query.status);
363
+ if (query.provider !== undefined)
364
+ builder.where('provider', query.provider);
365
+ const rows = (await builder
366
+ .limit(clampLimit(query.limit))
367
+ .offset(clampOffset(query.offset)));
368
+ return rows.map((row) => ({
369
+ id: String(row.id),
370
+ gatewayId: row.gatewayId,
371
+ provider: row.provider,
372
+ status: row.status,
373
+ planId: row.planId,
374
+ customerId: row.customerId ?? null,
375
+ trialEndsAt: toDate(row.trialEndsAt),
376
+ endsAt: toDate(row.endsAt),
377
+ createdAt: toDate(row.createdAt),
378
+ }));
379
+ }
380
+ async countSubscriptions(query) {
381
+ await this.#ready();
382
+ const builder = this.#subscriptionModel.query().count('* as total').pojo();
383
+ if (query.status !== undefined)
384
+ builder.where('status', query.status);
385
+ if (query.createdBefore !== undefined)
386
+ builder.where('created_at', '<', query.createdBefore);
387
+ if (query.createdAfter !== undefined)
388
+ builder.where('created_at', '>=', query.createdAfter);
389
+ return toCount(await builder);
390
+ }
36
391
  async findSubscriptionByGatewayId(gatewayId) {
392
+ await this.#ready();
37
393
  const row = await this.#subscriptionModel.findBy('gateway_id', gatewayId);
38
394
  return row;
39
395
  }
40
396
  async savePayment(payment) {
397
+ await this.#ready();
41
398
  const existing = await this.findPaymentByGatewayId(payment.gatewayId);
42
399
  const row = (existing ?? new this.#paymentModel());
43
400
  row.gatewayId = payment.gatewayId;
@@ -47,30 +404,358 @@ export class LucidBillingStore {
47
404
  row.currency = payment.currency;
48
405
  row.customerId = payment.customerId ?? null;
49
406
  row.subscriptionId = payment.subscriptionId ?? null;
50
- row.paidAt = payment.paidAt ? DateTime.fromJSDate(payment.paidAt) : null;
407
+ // An ABSENT reference does not erase the stored one — same rule as `saveCustomer`'s owner
408
+ // mapping, and for a sharper reason: `payment.succeeded` carries the app's reference,
409
+ // `payment.refunded` and `payment.disputed` frequently do not, and blanking it there would
410
+ // destroy the only key `findPaymentByExternalReference` can route on. `null` still clears.
411
+ if (payment.externalReference !== undefined &&
412
+ (await this.#hasColumn(this.#paymentModel, 'external_reference'))) {
413
+ row.externalReference = payment.externalReference;
414
+ }
415
+ // An ABSENT `paidAt` does not erase the stored one — the same leave-alone rule as the
416
+ // reference above, and with a sharper consequence. `payment.refunded`,
417
+ // `payment.disputed` and `payment.dispute_closed` all call this WITHOUT a `paidAt`
418
+ // (a refund/dispute payload carries no settlement date), and `revenue()` filters on
419
+ // `status = 'paid' AND paid_at >= from AND paid_at < to`. Writing `null` through meant a
420
+ // dispute closed as WON restored `status = 'paid'` with `paid_at = NULL`, so the recovered
421
+ // money vanished from every windowed revenue figure — permanently, because `paid_at` is
422
+ // the only record of when the charge landed. `null` still clears it, explicitly.
423
+ if (payment.paidAt !== undefined) {
424
+ row.paidAt = payment.paidAt === null ? null : DateTime.fromJSDate(payment.paidAt);
425
+ }
426
+ // Same leave-alone rule, same reason: only a partial-refund event knows this figure, and
427
+ // every other write about the payment would otherwise reset it to zero.
428
+ if (payment.refundedAmount !== undefined &&
429
+ (await this.#hasColumn(this.#paymentModel, 'refunded_amount'))) {
430
+ row.refundedAmount = payment.refundedAmount;
431
+ }
51
432
  row.payload = payment.payload ?? {};
52
433
  await row.save();
53
434
  return row;
54
435
  }
55
436
  async findPaymentByGatewayId(gatewayId) {
437
+ await this.#ready();
56
438
  const row = await this.#paymentModel.findBy('gateway_id', gatewayId);
57
439
  return row;
58
440
  }
441
+ async findPaymentByExternalReference(reference) {
442
+ await this.#ready();
443
+ // An install that has not run an earlier schema has no column to match on,
444
+ // and every row it holds would answer `null` anyway — so say `null` instead of raising
445
+ // `column "external_reference" does not exist` at a browser that is merely polling.
446
+ if (!(await this.#hasColumn(this.#paymentModel, 'external_reference')))
447
+ return null;
448
+ // Newest first: nothing stops an app reusing a reference across retries, and the row an
449
+ // operator (or a polling checkout page) means is the most recent one.
450
+ const row = await this.#paymentModel
451
+ .query()
452
+ .where('external_reference', reference)
453
+ .orderBy('created_at', 'desc')
454
+ .first();
455
+ return row;
456
+ }
457
+ async listPayments(query) {
458
+ await this.#ready();
459
+ const builder = this.#paymentModel.query().orderBy('created_at', 'desc');
460
+ if (query.status !== undefined)
461
+ builder.where('status', query.status);
462
+ if (query.provider !== undefined)
463
+ builder.where('provider', query.provider);
464
+ if (query.gatewayId !== undefined)
465
+ builder.where('gateway_id', query.gatewayId);
466
+ if (query.customerId !== undefined)
467
+ builder.where('customer_id', query.customerId);
468
+ // Guarded like `findPaymentByExternalReference`: an install that has not run the ALTER has
469
+ // no column to match on, and every row it holds would answer nothing anyway. Returning an
470
+ // empty page beats raising `column "external_reference" does not exist` at a filter box.
471
+ if (query.externalReference !== undefined) {
472
+ if (!(await this.#hasColumn(this.#paymentModel, 'external_reference')))
473
+ return [];
474
+ builder.where('external_reference', query.externalReference);
475
+ }
476
+ const rows = (await builder
477
+ .limit(clampLimit(query.limit))
478
+ .offset(clampOffset(query.offset)));
479
+ return rows.map((row) => ({
480
+ id: String(row.id),
481
+ gatewayId: row.gatewayId,
482
+ provider: row.provider,
483
+ status: row.status,
484
+ amount: Number(row.amount),
485
+ currency: row.currency,
486
+ customerId: row.customerId ?? null,
487
+ subscriptionId: row.subscriptionId ?? null,
488
+ externalReference: row.externalReference ?? null,
489
+ // `bigint` arrives as a STRING on Postgres, and `undefined` on an install whose table
490
+ // predates the column — both normalize to the same honest answer.
491
+ refundedAmount: row.refundedAmount === null || row.refundedAmount === undefined
492
+ ? null
493
+ : Number(row.refundedAmount),
494
+ paidAt: toDate(row.paidAt),
495
+ createdAt: toDate(row.createdAt),
496
+ }));
497
+ }
498
+ async countPayments(query) {
499
+ await this.#ready();
500
+ const builder = this.#paymentModel.query().count('* as total').pojo();
501
+ if (query.status !== undefined)
502
+ builder.where('status', query.status);
503
+ if (query.createdBefore !== undefined)
504
+ builder.where('created_at', '<', query.createdBefore);
505
+ if (query.createdAfter !== undefined)
506
+ builder.where('created_at', '>=', query.createdAfter);
507
+ return toCount(await builder);
508
+ }
509
+ async saveDispute(dispute) {
510
+ await this.#ready();
511
+ if (!(await this.#hasDisputesTable()))
512
+ return null;
513
+ const existing = await this.findDisputeByGatewayId(dispute.gatewayId);
514
+ const row = (existing ?? new this.#disputeModel());
515
+ row.gatewayId = dispute.gatewayId;
516
+ row.paymentGatewayId = dispute.paymentGatewayId;
517
+ row.provider = dispute.provider;
518
+ row.status = dispute.status;
519
+ // A new row starts with every optional column explicitly `null` rather than absent, so a
520
+ // reader never has to tell "the gateway sent nothing" apart from "the column was omitted".
521
+ if (existing === null) {
522
+ row.reason = null;
523
+ row.amount = null;
524
+ row.currency = null;
525
+ row.evidenceDueBy = null;
526
+ row.outcome = null;
527
+ row.closedAt = null;
528
+ row.payload = {};
529
+ }
530
+ // Absent does NOT erase: the event that OPENS a dispute carries the deadline and the
531
+ // reason, and the one that CLOSES it carries neither. Blanking them on the close would
532
+ // destroy the only record of the window that was answered. `null` still clears.
533
+ if (dispute.reason !== undefined)
534
+ row.reason = dispute.reason;
535
+ if (dispute.amount !== undefined)
536
+ row.amount = dispute.amount;
537
+ if (dispute.currency !== undefined)
538
+ row.currency = dispute.currency;
539
+ if (dispute.evidenceDueBy !== undefined) {
540
+ row.evidenceDueBy = dispute.evidenceDueBy ? DateTime.fromJSDate(dispute.evidenceDueBy) : null;
541
+ }
542
+ if (dispute.outcome !== undefined)
543
+ row.outcome = dispute.outcome;
544
+ if (dispute.closedAt !== undefined) {
545
+ row.closedAt = dispute.closedAt ? DateTime.fromJSDate(dispute.closedAt) : null;
546
+ }
547
+ // `openedAt` is when the dispute FIRST reached us and is never moved afterwards: a later
548
+ // event re-stamping it would make every dispute look brand new and destroy the only
549
+ // measure of how long one has been open.
550
+ if (existing === null || existing.openedAt === null) {
551
+ row.openedAt = dispute.openedAt ? DateTime.fromJSDate(dispute.openedAt) : DateTime.now();
552
+ }
553
+ if (dispute.payload !== undefined)
554
+ row.payload = dispute.payload;
555
+ await row.save();
556
+ return row;
557
+ }
558
+ async findDisputeByGatewayId(gatewayId) {
559
+ await this.#ready();
560
+ if (!(await this.#hasDisputesTable()))
561
+ return null;
562
+ return (await this.#disputeModel.findBy('gateway_id', gatewayId));
563
+ }
564
+ async findOpenDisputeByPayment(paymentGatewayId) {
565
+ await this.#ready();
566
+ if (!(await this.#hasDisputesTable()))
567
+ return null;
568
+ const row = await this.#disputeModel
569
+ .query()
570
+ .where('payment_gateway_id', paymentGatewayId)
571
+ .whereIn('status', [...OPEN_DISPUTE_STATUSES])
572
+ .orderBy('created_at', 'desc')
573
+ .first();
574
+ return row;
575
+ }
576
+ async listDisputes(query) {
577
+ await this.#ready();
578
+ if (!(await this.#hasDisputesTable()))
579
+ return [];
580
+ const builder = this.#disputeModel.query().orderBy('created_at', 'desc');
581
+ if (query.status !== undefined)
582
+ builder.where('status', query.status);
583
+ if (query.provider !== undefined)
584
+ builder.where('provider', query.provider);
585
+ const rows = (await builder
586
+ .limit(clampLimit(query.limit))
587
+ .offset(clampOffset(query.offset)));
588
+ return rows.map(disputeItem);
589
+ }
590
+ async countDisputes(query) {
591
+ await this.#ready();
592
+ if (!(await this.#hasDisputesTable()))
593
+ return 0;
594
+ const builder = this.#disputeModel.query().count('* as total').pojo();
595
+ if (query.status !== undefined)
596
+ builder.where('status', query.status);
597
+ if (query.createdBefore !== undefined)
598
+ builder.where('created_at', '<', query.createdBefore);
599
+ if (query.createdAfter !== undefined)
600
+ builder.where('created_at', '>=', query.createdAfter);
601
+ return toCount(await builder);
602
+ }
603
+ async listDisputesDueWithin(query) {
604
+ await this.#ready();
605
+ if (!(await this.#hasDisputesTable()))
606
+ return [];
607
+ const builder = this.#disputeModel
608
+ .query()
609
+ .whereIn('status', [...OPEN_DISPUTE_STATUSES])
610
+ // Explicit, though `<=` would already exclude NULLs: a gateway that sent no deadline
611
+ // gives nothing to be late for, and the reader should not have to know SQL's NULL rules
612
+ // to be sure of that.
613
+ .whereNotNull('evidence_due_by')
614
+ .where('evidence_due_by', '<=', deadlineCutoff(query))
615
+ // Soonest first — the priority order, not the arrival order. The only list in this store
616
+ // that is not newest-first.
617
+ .orderBy('evidence_due_by', 'asc');
618
+ if (query.provider !== undefined)
619
+ builder.where('provider', query.provider);
620
+ const rows = (await builder
621
+ .limit(clampLimit(query.limit))
622
+ .offset(clampOffset(query.offset)));
623
+ return rows.map(disputeItem);
624
+ }
625
+ async countDisputesDueWithin(query) {
626
+ await this.#ready();
627
+ if (!(await this.#hasDisputesTable()))
628
+ return 0;
629
+ const builder = this.#disputeModel
630
+ .query()
631
+ .whereIn('status', [...OPEN_DISPUTE_STATUSES])
632
+ .whereNotNull('evidence_due_by')
633
+ .where('evidence_due_by', '<=', deadlineCutoff(query))
634
+ .count('* as total')
635
+ .pojo();
636
+ if (query.provider !== undefined)
637
+ builder.where('provider', query.provider);
638
+ return toCount(await builder);
639
+ }
640
+ async listOpenDisputes(query) {
641
+ await this.#ready();
642
+ if (!(await this.#hasDisputesTable()))
643
+ return [];
644
+ // Oldest FIRST, and it is the opposite of every other list here. With no deadline to sort
645
+ // on, "nobody has answered this for eleven days" is the only priority signal there is.
646
+ const builder = this.#disputeModel
647
+ .query()
648
+ .whereIn('status', [...OPEN_DISPUTE_STATUSES])
649
+ .orderBy('created_at', 'asc');
650
+ if (query.provider !== undefined)
651
+ builder.where('provider', query.provider);
652
+ const rows = (await builder
653
+ .limit(clampLimit(query.limit))
654
+ .offset(clampOffset(query.offset)));
655
+ return rows.map(disputeItem);
656
+ }
657
+ async countOpenDisputes(query) {
658
+ await this.#ready();
659
+ if (!(await this.#hasDisputesTable()))
660
+ return 0;
661
+ const builder = this.#disputeModel
662
+ .query()
663
+ .whereIn('status', [...OPEN_DISPUTE_STATUSES])
664
+ .count('* as total')
665
+ .pojo();
666
+ if (query.provider !== undefined)
667
+ builder.where('provider', query.provider);
668
+ return toCount(await builder);
669
+ }
670
+ async recordAuditEvent(event) {
671
+ await this.#ready();
672
+ if (!(await this.#hasAuditTable()))
673
+ return null;
674
+ const row = new this.#auditEventModel();
675
+ row.action = event.action;
676
+ row.actor = event.actor ?? null;
677
+ row.provider = event.provider ?? null;
678
+ row.subjectType = event.subjectType ?? null;
679
+ row.subjectId = event.subjectId ?? null;
680
+ row.amount = event.amount ?? null;
681
+ row.currency = event.currency ?? null;
682
+ row.message = event.message ?? null;
683
+ row.metadata = event.metadata ?? null;
684
+ if (event.createdAt !== undefined)
685
+ row.createdAt = DateTime.fromJSDate(event.createdAt);
686
+ await row.save();
687
+ return auditItem(row);
688
+ }
689
+ async listAuditEvents(query) {
690
+ await this.#ready();
691
+ if (!(await this.#hasAuditTable()))
692
+ return [];
693
+ const builder = this.#auditEventModel.query().orderBy('created_at', 'desc');
694
+ this.#applyAuditFilters(builder, query);
695
+ const rows = (await builder
696
+ .limit(clampLimit(query.limit))
697
+ .offset(clampOffset(query.offset)));
698
+ return rows.map(auditItem);
699
+ }
700
+ async countAuditEvents(query) {
701
+ await this.#ready();
702
+ if (!(await this.#hasAuditTable()))
703
+ return 0;
704
+ const builder = this.#auditEventModel.query().count('* as total').pojo();
705
+ this.#applyAuditFilters(builder, query);
706
+ return toCount(await builder);
707
+ }
708
+ /** The audit filter, applied identically by the list and the count — see `#matchesCount`'s
709
+ * note in the in-memory store: a bound one of them ignored would report a healthy zero. */
710
+ #applyAuditFilters(builder, query) {
711
+ if (query.action !== undefined)
712
+ builder.where('action', query.action);
713
+ if (query.actions !== undefined)
714
+ builder.whereIn('action', [...query.actions]);
715
+ if (query.actor !== undefined)
716
+ builder.where('actor', query.actor);
717
+ if (query.provider !== undefined)
718
+ builder.where('provider', query.provider);
719
+ if (query.subjectType !== undefined)
720
+ builder.where('subject_type', query.subjectType);
721
+ if (query.subjectId !== undefined)
722
+ builder.where('subject_id', query.subjectId);
723
+ if (query.createdBefore !== undefined)
724
+ builder.where('created_at', '<', query.createdBefore);
725
+ if (query.createdAfter !== undefined)
726
+ builder.where('created_at', '>=', query.createdAfter);
727
+ }
59
728
  async recordWebhookEvent(event) {
729
+ await this.#ready();
60
730
  const existing = await this.#webhookEventModel.findBy('gateway_event_id', event.gatewayEventId);
61
- if (existing)
62
- return null;
731
+ if (existing) {
732
+ // A previous attempt failed: claim it again so the retry re-runs. Anything
733
+ // else (in flight, or already processed) is a redelivery — stop here.
734
+ if (existing.status !== 'failed')
735
+ return null;
736
+ existing.status = 'received';
737
+ existing.error = null;
738
+ // `payload` and `normalized` are deliberately NOT touched here: the retry re-claims
739
+ // this row precisely to read back what the original delivery recorded.
740
+ await existing.save();
741
+ return existing;
742
+ }
63
743
  const row = new this.#webhookEventModel();
64
744
  row.gatewayEventId = event.gatewayEventId;
65
745
  row.provider = event.provider;
66
746
  row.type = event.type;
67
747
  row.status = 'received';
68
748
  row.payload = event.payload;
749
+ const normalized = normalizedColumn(event.normalized);
750
+ if (normalized !== null && (await this.#hasColumn(this.#webhookEventModel, 'normalized'))) {
751
+ row.normalized = normalized;
752
+ }
69
753
  row.error = null;
70
754
  await row.save();
71
755
  return row;
72
756
  }
73
757
  async markWebhookProcessed(id) {
758
+ await this.#ready();
74
759
  const row = await this.#webhookEventModel.find(id);
75
760
  if (row) {
76
761
  row.status = 'processed';
@@ -78,6 +763,7 @@ export class LucidBillingStore {
78
763
  }
79
764
  }
80
765
  async markWebhookFailed(id, error) {
766
+ await this.#ready();
81
767
  const row = await this.#webhookEventModel.find(id);
82
768
  if (row) {
83
769
  row.status = 'failed';
@@ -85,7 +771,81 @@ export class LucidBillingStore {
85
771
  await row.save();
86
772
  }
87
773
  }
774
+ async listWebhookEvents(query) {
775
+ await this.#ready();
776
+ const builder = this.#webhookEventModel.query().orderBy('created_at', 'desc');
777
+ if (query.status !== undefined)
778
+ builder.where('status', query.status);
779
+ if (query.provider !== undefined)
780
+ builder.where('provider', query.provider);
781
+ if (query.type !== undefined)
782
+ builder.where('type', query.type);
783
+ const rows = (await builder
784
+ .limit(clampLimit(query.limit))
785
+ .offset(clampOffset(query.offset)));
786
+ return rows.map(webhookEventItem);
787
+ }
788
+ async listWebhookEventsForPayment(paymentGatewayId, query = {}) {
789
+ await this.#ready();
790
+ // An empty needle would match the whole table — the timeline of "no payment at all".
791
+ if (paymentGatewayId === '')
792
+ return [];
793
+ // The payload column's type differs per dialect (JSONB / JSON / TEXT) and only one of the
794
+ // three can be compared to a string directly, so it is cast. MySQL spells the text type
795
+ // `CHAR`; Postgres and SQLite take `TEXT`.
796
+ const dialect = detectDialect(this.#webhookEventModel.query().client);
797
+ const textType = /mysql|mariadb/i.test(dialect ?? '') ? 'CHAR' : 'TEXT';
798
+ const rows = (await this.#webhookEventModel
799
+ .query()
800
+ .whereRaw(`CAST(payload AS ${textType}) LIKE ?`, [`%${paymentGatewayId}%`])
801
+ .orderBy('created_at', 'desc')
802
+ .limit(clampLimit(query.limit)));
803
+ return rows.map(webhookEventItem);
804
+ }
805
+ async findWebhookEventByGatewayEventId(gatewayEventId) {
806
+ await this.#ready();
807
+ const row = (await this.#webhookEventModel.findBy('gateway_event_id', gatewayEventId));
808
+ if (!row)
809
+ return null;
810
+ return webhookEventItem(row);
811
+ }
812
+ async countWebhookEvents(query) {
813
+ await this.#ready();
814
+ const builder = this.#webhookEventModel.query().count('* as total').pojo();
815
+ if (query.status !== undefined)
816
+ builder.where('status', query.status);
817
+ if (query.createdBefore !== undefined)
818
+ builder.where('created_at', '<', query.createdBefore);
819
+ if (query.createdAfter !== undefined)
820
+ builder.where('created_at', '>=', query.createdAfter);
821
+ return toCount(await builder);
822
+ }
823
+ async webhookEventBreakdown(query) {
824
+ await this.#ready();
825
+ const builder = this.#webhookEventModel
826
+ .query()
827
+ .select('provider', 'type')
828
+ .count('* as total')
829
+ .groupBy('provider', 'type')
830
+ .orderBy('total', 'desc')
831
+ .pojo();
832
+ if (query.status !== undefined)
833
+ builder.where('status', query.status);
834
+ if (query.createdBefore !== undefined)
835
+ builder.where('created_at', '<', query.createdBefore);
836
+ if (query.createdAfter !== undefined)
837
+ builder.where('created_at', '>=', query.createdAfter);
838
+ // Through `unknown`: the query builder is typed as yielding model instances, but a
839
+ // grouped `select(...).count(...)` yields aggregate rows, which do not overlap with it.
840
+ const rows = (await builder);
841
+ return rows.map((row) => ({
842
+ provider: row.provider,
843
+ type: row.type,
844
+ count: Number(row.total),
845
+ }));
846
+ }
88
847
  async recordUsage(event) {
848
+ await this.#ready();
89
849
  const row = new this.#usageEventModel();
90
850
  row.subscriptionId = event.subscriptionId ?? null;
91
851
  row.customerId = event.customerId ?? null;
@@ -97,11 +857,13 @@ export class LucidBillingStore {
97
857
  return row;
98
858
  }
99
859
  async usageReport(query) {
860
+ await this.#ready();
100
861
  const builder = this.#usageEventModel
101
862
  .query()
102
863
  .select('meter')
103
864
  .sum('quantity as quantity')
104
- .groupBy('meter');
865
+ .groupBy('meter')
866
+ .pojo();
105
867
  if (query.subscriptionId !== undefined)
106
868
  builder.where('subscription_id', query.subscriptionId);
107
869
  else if (query.customerId !== undefined)
@@ -119,21 +881,57 @@ export class LucidBillingStore {
119
881
  }));
120
882
  }
121
883
  async revenue(query) {
122
- const builder = this.#paymentModel.query().where('status', 'paid').sum('amount as total');
884
+ await this.#ready();
885
+ const builder = this.#paymentModel
886
+ .query()
887
+ .where('status', 'paid')
888
+ .sum('amount as total')
889
+ .pojo();
123
890
  if (query.from !== undefined)
124
891
  builder.where('paid_at', '>=', query.from);
125
892
  if (query.to !== undefined)
126
893
  builder.where('paid_at', '<', query.to);
127
- const rows = await builder;
128
- const total = rows[0]?.total;
129
- return Number(total ?? 0);
894
+ return toCount(await builder);
895
+ }
896
+ /**
897
+ * Gross minus what came back. Same rows, same window, one subtraction.
898
+ *
899
+ * Three details this has to get right, each of which has already bitten this package:
900
+ *
901
+ * 1. `COALESCE(refunded_amount, 0)`. `refunded_amount` is `NULL` on every row written before
902
+ * the column existed, and SQL `amount - NULL` is `NULL`, which propagates through `SUM`
903
+ * and turns the whole window into `NULL` — one legacy row would report zero net revenue
904
+ * for an install that took a million. `NULL` means "nothing came back".
905
+ * 2. `SUM` over no rows is `NULL`, not `0`; `toCount` normalizes it, exactly as `revenue()`
906
+ * already relies on.
907
+ * 3. Postgres returns a `BIGINT` sum as a STRING. `toCount` runs it through `Number` for the
908
+ * same reason `amount` is read through `Number` everywhere else in this file — without it
909
+ * the metric would cross the API as `"9000"` and the SPA would render `"9000" / 100`.
910
+ *
911
+ * An install whose table predates the column has no refund recorded anywhere, so net IS
912
+ * gross — and asking for a column Postgres does not have would raise `column
913
+ * "refunded_amount" does not exist` at a dashboard that is merely loading its overview.
914
+ */
915
+ async netRevenue(query) {
916
+ await this.#ready();
917
+ if (!(await this.#hasColumn(this.#paymentModel, 'refunded_amount'))) {
918
+ return this.revenue(query);
919
+ }
920
+ const builder = this.#paymentModel.query().where('status', 'paid').pojo();
921
+ builder.select(builder.client.raw('SUM(amount - COALESCE(refunded_amount, 0)) as total'));
922
+ if (query.from !== undefined)
923
+ builder.where('paid_at', '>=', query.from);
924
+ if (query.to !== undefined)
925
+ builder.where('paid_at', '<', query.to);
926
+ return toCount(await builder);
130
927
  }
131
928
  async countActiveSubscriptions() {
132
- const count = await this.#subscriptionModel
929
+ await this.#ready();
930
+ return toCount(await this.#subscriptionModel
133
931
  .query()
134
932
  .whereIn('status', ['active', 'trialing'])
135
- .count('* as total');
136
- return Number(count[0]?.total ?? 0);
933
+ .count('* as total')
934
+ .pojo());
137
935
  }
138
936
  }
139
937
  /** Builder matching the authkit `lucidStores(...)` convention. */