@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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,2352 @@
1
1
  # @adonis-agora/payments
2
2
 
3
+ ## 0.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#6](https://github.com/DavideCarvalho/adonis-agora-payments/pull/6) [`5d2a895`](https://github.com/DavideCarvalho/adonis-agora-payments/commit/5d2a895a41736ea924c1851c71e59a29034b299f) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - Add an Adyen driver — `payments.adyen({ apiKey, merchantAccount, currency })`, on Adyen's Checkout
8
+ API v71 over REST with no SDK dependency.
9
+
10
+ Stored-token card charges (`paymentMethod.type: 'scheme'`), Pay by Link for hosted checkout, and
11
+ refunds: `supportedMethods` is `credit_card` and `undefined`, `capabilities` is
12
+ `{ refunds: true, invoices: false, subscriptions: false }`. The API key travels as `X-API-Key`;
13
+ `currency` and `merchantAccount` are both required and checked at boot. Live traffic goes to a
14
+ per-customer host (`https://{prefix}-checkout-live.adyenpayments.com/checkout/v71`), so booting live
15
+ without `liveUrlPrefix` fails at boot rather than as a DNS error on the first charge. Adyen's money
16
+ is already an integer in the currency's minor units, so nothing is converted in either direction.
17
+
18
+ `externalReference` is **required** on `charge` and `createCheckout`: it becomes Adyen's `reference`,
19
+ which every webhook echoes as `merchantReference` — the routing key, and one of the eight fields the
20
+ HMAC covers.
21
+
22
+ **Webhook verification is pinned to Adyen's own test vector.** The signature is HMAC-SHA256, base64,
23
+ over `pspReference:originalReference:merchantAccountCode:merchantReference:amount.value:amount.currency:eventCode:success`
24
+ under the **hex-decoded** Customer Area key, and it arrives in `additionalData.hmacSignature`. Two
25
+ things are easy to get subtly wrong and both are covered by tests: signing with the key's characters
26
+ instead of its bytes, and escaping `:` as `\:`. The escaping rule belongs to Adyen's classic
27
+ HPP/dictionary signature — Adyen's own Node, PHP, Java and Python libraries all join the eight
28
+ webhook fields unescaped, and so does this driver, so a `merchantReference` containing a colon
29
+ verifies instead of being rejected as a forgery. Set `hmacKey` and verification is mandatory; leave
30
+ it unset and it is skipped, so local development works without Customer Area setup.
31
+
32
+ Adyen batches notifications in a `notificationItems` array while `parseWebhook` returns one event.
33
+ JSON/HTTP POST webhooks send one item per request, but if more arrive the driver **throws** instead
34
+ of handling the first and silently dropping a capture or a refund. Note also that Adyen's docs ask
35
+ for a `200` whose body is `[accepted]`, while the package's mounted `/payments/webhook/:provider`
36
+ route answers `200 {"received": true}` — current Adyen docs accept any `2xx`, but mount your own
37
+ route if your account requires the literal body.
38
+
39
+ **What it refuses, because Adyen has no endpoint behind it:** every customer operation (Adyen has no
40
+ customer resource — `shopperReference` is a string you invent); `findPayment` (Checkout v71 has no
41
+ read-back by `pspReference`); `refund` with no amount (Adyen requires one and the payment cannot be
42
+ read back to infer a full refund); all four subscription methods (Adyen has no subscription
43
+ resource — recurring billing is you charging a stored token on your own schedule); `listInvoices`; a
44
+ charge with no payment method; and `split`, which Adyen expresses as absolute amounts against
45
+ balance accounts.
46
+
47
+ `idempotencyKey` is sent as Adyen's `Idempotency-Key` request header on `charge` and
48
+ `createCheckout` — the only thing Adyen deduplicates on, and it never echoes the key back on the
49
+ response.
50
+
51
+ One documented ambiguity: `resultCode: 'Authorised'` maps to `paid` because Adyen captures
52
+ automatically by default, but on a manual-capture account it means only that the funds are held. The
53
+ API reference does not expose which mode an account is in, so the driver cannot tell — treat `paid`
54
+ from `charge()` as "authorised" and let the `CAPTURE` webhook be settlement.
55
+
56
+ Written against Adyen's published Checkout v71 reference and covered by unit tests; it has not been
57
+ exercised against a live Adyen account. Verify against `checkout-test.adyen.com` before taking real
58
+ money.
59
+
60
+ - [#6](https://github.com/DavideCarvalho/adonis-agora-payments/pull/6) [`5d2a895`](https://github.com/DavideCarvalho/adonis-agora-payments/commit/5d2a895a41736ea924c1851c71e59a29034b299f) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - Adyen's `NOTIFICATION_OF_CHARGEBACK` was moving a paid payment to `disputed` before Adyen had taken
61
+ anything.
62
+
63
+ Adyen's webhook reference is explicit about which dispute events withdraw funds, and three of them
64
+ do not: `NOTIFICATION_OF_FRAUD` (the issuer's TC40/SAFE alert), `REQUEST_FOR_INFORMATION` (the scheme
65
+ asking a question) and `NOTIFICATION_OF_CHARGEBACK` (a chargeback announced, not yet taken). The
66
+ driver mapped the third to `payment.disputed`, so the payment row stopped saying `paid` over money
67
+ still sitting in the account. All three are now **`payment.dispute_warning`**.
68
+
69
+ `NOTIFICATION_OF_CHARGEBACK` is also the only event carrying `additionalData.defensePeriodEndsAt` —
70
+ the deadline that makes a dispute actionable at all. It now comes through as `actionableUntil` on the
71
+ normalized event, alongside the dispute's own `pspReference` as `disputeId`. Flattening the event
72
+ into one with no room for a deadline was throwing that field away.
73
+
74
+ `CHARGEBACK` — the debit itself — stays `payment.disputed`, and stands alone rather than assuming a
75
+ notification came first: an ACH return goes straight there with no warning and cannot be defended.
76
+
77
+ **The resolution is now named.** `CHARGEBACK_REVERSED` and `PREARBITRATION_WON` close a dispute as
78
+ `won`; `SECOND_CHARGEBACK` and `PREARBITRATION_LOST` close it as `lost`.
79
+ `DISPUTE_DEFENSE_PERIOD_ENDED` means "expired or liability accepted" and the event code does not say
80
+ which, so the driver reads `additionalData.disputeStatus` — `Won` → `won`, `Lost` / `Accepted` /
81
+ `Undefended` → `lost`, `Expired` → `expired` — and stays a `payment.updated` when it recognizes
82
+ nothing, rather than reporting a loss that might be a win. `INFORMATION_SUPPLIED` is a
83
+ `payment.updated`: movement inside an open dispute, not a resolution.
84
+
85
+ Adyen does not treat `CHARGEBACK_REVERSED` as final — pre-arbitration can follow with a second close
86
+ carrying the opposite outcome. It is reported anyway, because the alternative was emitting nothing
87
+ for a successful defense.
88
+
89
+ **If you have a handler on `payment.disputed`,** it no longer fires for a notification of chargeback.
90
+ That is the fix; add `payment.dispute_warning` if you were relying on hearing about the defense
91
+ period there.
92
+
93
+ - [#6](https://github.com/DavideCarvalho/adonis-agora-payments/pull/6) [`5d2a895`](https://github.com/DavideCarvalho/adonis-agora-payments/commit/5d2a895a41736ea924c1851c71e59a29034b299f) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - **A webhook delivery can carry more than one event, and now it is processed as one.**
94
+
95
+ Two gateways put a list in the delivery envelope — Adyen's `notificationItems` and Efí's
96
+ `pix` — while `parseWebhook` returned exactly one `WebhookEvent`. Both drivers refused a
97
+ batch loudly rather than processing the first and dropping the rest, which was the safe half
98
+ of the answer and lost every entry in the batch. The contract now carries what the envelope
99
+ carries.
100
+
101
+ **`PaymentsDriver.parseWebhook` returns `WebhookEvent | WebhookEvent[]`** (still optionally a
102
+ promise). This is a widening, not a migration: every other driver keeps returning a single
103
+ event and the union lets it. A driver whose gateway sends one event per request should keep
104
+ returning one — an array of length one adds nothing and reads as if batching were possible.
105
+ Only an implementation that _reads_ the result needs updating; the mounted route accepts
106
+ either shape.
107
+
108
+ **Adyen verifies the HMAC per notification item.** Adyen's signature lives inside each item's
109
+ own `additionalData.hmacSignature` and nothing signs the envelope, so the driver verifies
110
+ _every_ item before mapping _any_ of them: verifying the first and trusting the rest is a
111
+ replay hole where an attacker appends whatever they like beside one genuine notification. One
112
+ bad signature rejects the whole delivery with `400`. Adyen documents that JSON and HTTP POST
113
+ webhooks carry a single item (only legacy SOAP batches, up to six), so in practice this stays
114
+ one event — the array is simply no longer truncated to its first entry.
115
+
116
+ **Efí returns one event per Pix**, each keyed on its own `endToEndId`. Efí's reference shows
117
+ one entry per notification and never states a maximum; the shape is a list either way.
118
+
119
+ **The mounted route loops, and every event gets its own ledger row.** Idempotency is keyed on
120
+ the gateway event id, so four events in one delivery are four rows, four
121
+ `webhook.received`/`processed`/`failed` triples on the diagnostics bus, and a redelivery
122
+ where three were already processed runs only the fourth. The whole delivery shares one
123
+ `traceId` — the trace answers "what happened to this HTTP request", and losing the fact that
124
+ these four arrived together would be the one thing a batch makes worth knowing. Each event's
125
+ `raw` is the envelope narrowed to its own item, so a ledger row can still say which
126
+ notification it is about and remains replayable by the dashboard.
127
+
128
+ **Behavior change: a delivery whose processing failed now answers `500`, not `200`.** When an
129
+ event throws, its siblings are still attempted — they are different payments, and refusing
130
+ them because a neighbour failed loses money for a reason unrelated to them — and the response
131
+ then reports the failure:
132
+
133
+ ```json
134
+ { "received": true, "processed": 3, "failed": ["evt_2"], "error": "..." }
135
+ ```
136
+
137
+ A `2xx` promises the gateway it never has to send that delivery again, which over a failed
138
+ event is the payment lost for good. Previously the in-process dispatcher swallowed the error
139
+ and answered `200`, leaving the event to a background retry that dies with the process; the
140
+ gateway's own redelivery is the only durable retry there is, and it only starts on a non-2xx
141
+ (Adyen queues one for up to 30 days, Efí makes up to 9 attempts). Both retries now run, and
142
+ the ledger keeps that safe — whichever attempt claims the `failed` row first does the work
143
+ and the other short-circuits. **If you alert on 5xx from `/payments/webhook/:provider`, that
144
+ alert will now fire on a failing handler where it previously stayed silent.** A rejected
145
+ delivery — bad signature, unparsable body, unknown provider — is unchanged at `400`, since
146
+ redelivering it would fail identically.
147
+
148
+ - [#5](https://github.com/DavideCarvalho/adonis-agora-payments/pull/5) [`1db1891`](https://github.com/DavideCarvalho/adonis-agora-payments/commit/1db1891cad2481c468169de8398ffb307befb01a) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - The billing store answers the operational questions, and two bugs that only a real database could
149
+ show are fixed.
150
+
151
+ **Every insert failed on Postgres.** The published migration declares `uuid('id')` with no
152
+ database-side default and the four billing models never assigned one, so `billing_payments`,
153
+ `billing_subscriptions`, `billing_webhook_events` and `billing_usage_events` all rejected every
154
+ insert with `null value in column "id" violates not-null constraint` — the idempotency ledger
155
+ included, which is the first write of every webhook. Fixed with a `@beforeCreate()` hook that
156
+ generates the uuid on the model: it works on every dialect (unlike `gen_random_uuid()`) and needs no
157
+ new migration.
158
+
159
+ **Every aggregate returned zero.** A Lucid model query hydrates rows into model instances, and a
160
+ value with no matching column — `count(*) as total` — goes into `$extras`, not onto the instance. So
161
+ `rows[0].total` was `undefined`, `Number(undefined ?? 0)` returned a confident `0`, and `revenue()`
162
+ and `countActiveSubscriptions()` reported zero against any real database. Every aggregate query now
163
+ goes through `.pojo()`.
164
+
165
+ Both were invisible to the existing suite, which only ever exercised the in-memory store. There is
166
+ now an integration suite (`pnpm test:integration`) that runs against a throwaway Postgres via
167
+ testcontainers, on the **real published migration stub** rather than a copy of the schema.
168
+
169
+ New reads on `BillingStore`, so nothing has to reach around it into the tables:
170
+ `findWebhookEventByGatewayEventId(id)`, `countPayments(query)`, `countWebhookEvents(query)` and
171
+ `webhookEventBreakdown(query)`, filtered by `status` and a `createdBefore`/`createdAfter` window.
172
+
173
+ New `billingHealth(store)` and `node ace payments:health` report the three silent failures of a
174
+ billing install — events claimed and never finished, events the dispatcher gave up on, and charges
175
+ created that never confirmed. The command exits non-zero when any is non-zero, so a scheduler can
176
+ page on it.
177
+
178
+ `payments:sync` now uses the **configured** store rather than always constructing a Lucid one (an app
179
+ with a custom `billing.store` had its reconcile write somewhere the rest of the billing layer never
180
+ reads), no longer reports an unreadable `billing_customers` as "empty", and no longer imports the
181
+ database service at module scope.
182
+
183
+ - [#5](https://github.com/DavideCarvalho/adonis-agora-payments/pull/5) [`1db1891`](https://github.com/DavideCarvalho/adonis-agora-payments/commit/1db1891cad2481c468169de8398ffb307befb01a) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - `billing.role` splits the deployment into an api half and a worker half, and `billing.dispatcher` is
184
+ finally read.
185
+
186
+ `dispatcher` was declared in the config and **never consulted** — the provider resolved the backend
187
+ from the legacy `billing.durable` boolean alias only, which cannot express `'queue'` at all. So
188
+ `dispatcher: 'queue'` and `dispatcher: 'durable'` were silent no-ops. `dispatcher` is now read first,
189
+ with the alias kept as the fallback.
190
+
191
+ `billing.role` is `'all'` (default), `'api'` or `'worker'`. A `'worker'` process does not mount
192
+ `/payments/webhook/:provider` — it consumes what the api half enqueued. An `'api'` process skips
193
+ resolving app handlers, because they run on the worker.
194
+
195
+ Splitting requires a channel between the halves, so `'api'`/`'worker'` demand an explicit
196
+ `dispatcher` of `'durable'` or `'queue'`. `'in-process'` calls the processor inline and `'auto'` can
197
+ silently resolve to it, so both are refused at boot rather than producing a deployment where the api
198
+ half quietly processes everything and the worker sits idle.
199
+
200
+ `CheckoutSession` also now declares `pixCode`/`pixCopiaECola`. Three drivers were already writing the
201
+ field onto it; it only survived typecheck because a conditional spread bypasses the excess-property
202
+ check, so the value was undiscoverable from the type.
203
+
204
+ - [#6](https://github.com/DavideCarvalho/adonis-agora-payments/pull/6) [`5d2a895`](https://github.com/DavideCarvalho/adonis-agora-payments/commit/5d2a895a41736ea924c1851c71e59a29034b299f) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - Mercado Pago now closes a dispute and carries its deadline; Pagar.me, PagBank and InfinitePay say
205
+ plainly what they cannot report.
206
+
207
+ **Mercado Pago — a chargeback that ends now ends in the ledger.** `charged_back` collapsed into
208
+ `payment.disputed` whatever `status_detail` said, so a dispute the seller _won_ sat at `disputed`
209
+ forever — and `revenue()` sums rows that say `paid`, so it wrote off money that had come back.
210
+ `status_detail` is Mercado Pago's own outcome: `reimbursed` is _"decision in favor of the seller,
211
+ money refunded to the seller's account"_ and now closes the dispute as **`won`** (which moves the row
212
+ back to `paid`); `settled` is _"decision against the seller, money withdrawn"_ and closes it as
213
+ **`lost`**. `in_process` — and any detail the driver does not recognize — stays `payment.disputed`,
214
+ because an open dispute has no result to report. `in_mediation` is unchanged.
215
+
216
+ **The evidence deadline was there and unread.** It lives on the chargeback _case_, not on the
217
+ payment, so a `topic_chargebacks_wh` notification now also fetches `GET /v1/chargebacks/{data.id}`
218
+ and carries `date_documentation_deadline` as **`actionableUntil`**, the case's `reason`, and the case
219
+ id as `disputeId`. That second call fails soft: the money question is already answered by the
220
+ payment, and throwing would turn a chargeback the driver read correctly into a 400 and a redelivery.
221
+
222
+ Mercado Pago publishes no pre-dispute alert — the first thing you hear is the chargeback — so no
223
+ `payment.dispute_warning` is emitted, and `topic_claims_integration_wh` stays unmapped because its
224
+ `data.id` is a claim id with no payment beside it. Whether funds move when a chargeback opens or only
225
+ when it settles is not stated in the reference; the mapping was left where it was rather than
226
+ downgraded on a guess, and that is now written on the provider page.
227
+
228
+ **Pagar.me — `chargeback.received` is still not mapped, and now that is a documented decision.**
229
+ `charge.chargedback` is deprecated with a migration deadline of **2026-09-30**, and its replacement
230
+ exists in Pagar.me's event list with a one-sentence description and no published payload, no example
231
+ and no field list. Mapping it on the guess that `data` is a dispute object would file
232
+ `payment.disputed` against an id that may be the dispute's rather than the charge's. It now passes
233
+ through **untouched** instead of being run through the charge mapper, which was fabricating a
234
+ `{ gatewayId: '', amount: 0 }` for any handler registered on it. `charge.chargedback` →
235
+ `payment.disputed` is unchanged. Pagar.me's dispute lifecycle and its `responseDeadline` live in the
236
+ separate Disputes API, which is not on the driver contract — so there is no `actionableUntil` and no
237
+ `payment.dispute_closed` for this gateway, and the provider page says so.
238
+
239
+ **PagBank and InfinitePay have no dispute vocabulary to map.** PagBank's Orders API has no chargeback
240
+ status: a chargeback is the legacy form-encoded `notificationType=transaction` notification (status
241
+ 9, "Retenção temporária"), a different API with different credentials that the driver already refuses
242
+ loudly. InfinitePay documents one webhook, fired only on approval, and no dispute resource at all —
243
+ contested sales are handled in the app, so the first you hear of one is the debit. Both are now
244
+ pinned by tests asserting no dispute type can be emitted, and stated on their provider pages rather
245
+ than left for the reader to discover.
246
+
247
+ - [#6](https://github.com/DavideCarvalho/adonis-agora-payments/pull/6) [`5d2a895`](https://github.com/DavideCarvalho/adonis-agora-payments/commit/5d2a895a41736ea924c1851c71e59a29034b299f) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - Adopt the widened payments contract in the Brazil/LatAm drivers — Asaas, AbacatePay,
248
+ Pagar.me, PagBank and Mercado Pago.
249
+
250
+ **Chargebacks stop being invisible.** `payment.disputed` is a canonical event now, and each
251
+ of these gateways gets its dispute-opening event mapped onto it: Asaas
252
+ `PAYMENT_CHARGEBACK_REQUESTED`, AbacatePay `checkout.disputed` / `transparent.disputed`,
253
+ Pagar.me `charge.chargedback`, and Mercado Pago's `topic_chargebacks_wh` topic plus the
254
+ `charged_back` / `in_mediation` payment statuses. Three of those were previously reported
255
+ as something else and were actively wrong: AbacatePay's disputes arrived as
256
+ `payment.failed` (a chargeback filed as a payment that never happened) and Mercado Pago's
257
+ as `payment.refunded` (indistinguishable from a refund the seller chose to make). The later
258
+ steps of a dispute stay `payment.updated`: the contract deliberately has no resolution
259
+ event, because no two gateways report one the same way.
260
+
261
+ **PagBank has no dispute event to map**, and its docs page now says so plainly. A PagBank
262
+ chargeback arrives only as a legacy form-encoded `notificationType=transaction`
263
+ notification (transaction status 9, "Retenção temporária"), resolved against the v3 XML API
264
+ with legacy credentials — a different API this driver does not speak, and refuses rather
265
+ than half-parses.
266
+
267
+ **`authorized` is told apart from paid and from pending** wherever the gateway separates
268
+ authorization from capture: Asaas `AUTHORIZED` (`authorizeOnly: true`), PagBank
269
+ `AUTHORIZED` ("pré-autorizada"), Mercado Pago `authorized` / `pending_capture`, and
270
+ Pagar.me — which has no `authorized` charge status at all, so the driver reads
271
+ `last_transaction.status === 'authorized_pending_capture'` behind a charge that still says
272
+ `pending`. Previously all of these collapsed into `pending`, understating a hold the issuer
273
+ had already granted.
274
+
275
+ **Mercado Pago's `paused` preapprovals** map to `paused` instead of `past_due`. Billing has
276
+ stopped and the subscription is alive; nothing failed, and it must not entitle the payer.
277
+
278
+ **Idempotency is honoured where the gateway documents it and refused where it does not.**
279
+ Mercado Pago's `refund()` now takes the caller's key instead of always generating one (the
280
+ random fallback stays for callers who pass none — a key derived from the payment id would
281
+ collapse two deliberate partial refunds into one). PagBank's `refund()` sends
282
+ `x-idempotency-key`. Pagar.me's `charge()` sends the `Idempotency-key` header its docs
283
+ specify for order creation, which is new — the key previously only doubled as the order
284
+ `code`. Everywhere else the key is **refused with a clear error** rather than accepted and
285
+ dropped: Asaas and AbacatePay document no idempotency mechanism at all, Pagar.me documents
286
+ one only for `POST /orders`, and Mercado Pago only for payments and refunds. Silently
287
+ dropping a key turns a caller's retry guarantee into a second refund.
288
+
289
+ **PagBank's `listInvoices` throws** instead of returning `[]`. `capabilities.invoices` is
290
+ false, the Orders API has no invoice resource, and `GET /orders` accepts only `charge_id` —
291
+ an empty array said "this customer has no invoices", which PagBank never told us.
292
+
293
+ No `supportedMethods` changed: pix, boleto and card are named categories already, and the
294
+ new `wallet` / `bank_transfer` / `bank_debit` / `upi` / `bnpl` / `voucher` entries describe
295
+ nothing these five gateways produce from `charge()`.
296
+
297
+ - [#6](https://github.com/DavideCarvalho/adonis-agora-payments/pull/6) [`5d2a895`](https://github.com/DavideCarvalho/adonis-agora-payments/commit/5d2a895a41736ea924c1851c71e59a29034b299f) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - The Brazil-first gateways get the dispute vocabulary — and for two of them the answer was that
298
+ their reference does not have one.
299
+
300
+ **Asaas closes a won dispute.** `PAYMENT_AWAITING_CHARGEBACK_REVERSAL` is documented as "Disputa
301
+ vencida, aguardando repasse da adquirente" — Asaas' own English wording is "Dispute won, awaiting
302
+ acquirer settlement" — and it was being flattened into a `payment.updated`, so a successful defense
303
+ reached nobody and the row stayed at `disputed` while the money was coming back. It is now a
304
+ **`payment.dispute_closed` with outcome `won`**, and the payment status map follows it:
305
+ `AWAITING_CHARGEBACK_REVERSAL` reads as `paid` rather than `disputed`, so `findPayment` and the
306
+ webhook stop describing the same gateway state two different ways. `PAYMENT_CHARGEBACK_DISPUTE`
307
+ stays a `payment.updated` — documents submitted is movement inside an open dispute, not a
308
+ resolution.
309
+
310
+ **Asaas' deadline was sitting unread.** The payment's `chargeback` object carries
311
+ `deadlineToSendDisputeDocuments`, the only response deadline Asaas publishes anywhere; it now comes
312
+ through as `actionableUntil`, with `chargeback.id` as `disputeId` and `chargeback.reason` as
313
+ `reason`. Every field is read defensively: Asaas' webhook reference points at the
314
+ `GET /payments/{id}` schema for the notification's payment object, where `chargeback` lives, but no
315
+ published example shows it — an absent one is an event without a deadline, never a malformed one.
316
+
317
+ `PAYMENT_CHARGEBACK_REQUESTED` **stays** `payment.disputed`. Asaas does not publish when the balance
318
+ is actually debited — its developer reference says nothing about the money, and its help centre
319
+ describes the debit as happening on a LOSS while also describing a win as the value _returning_ to
320
+ the balance. The chargeback has been filed either way, so the mapping stands and the ambiguity is
321
+ written down on the docs page instead of guessed at. Asaas also sends no dispute-lost webhook and no
322
+ pre-chargeback warning of any kind; both absences are now documented and pinned by a test.
323
+
324
+ **Efí: a Pix cannot be charged back, but it can be taken back.** BACEN's MED (_Mecanismo Especial de
325
+ Devolução_) returns money to a payer who reported fraud, and it arrives on the Pix webhook this
326
+ driver already parses — as an ordinary `devolução`, distinguished from a refund you made only by its
327
+ `natureza`. The driver was calling it `payment.refunded`, which says the merchant chose to give the
328
+ money back: the one thing that did not happen. A `devolução` whose `natureza` is `MED_FRAUDE`,
329
+ `MED_OPERACIONAL` or `MED_PIX_AUTOMATICO` is now **`payment.disputed`** when it is `DEVOLVIDO` and
330
+ **`payment.dispute_warning`** while it is `EM_PROCESSAMENTO`; `NAO_REALIZADO` took nothing and stays
331
+ out of the dispute vocabulary, and `ORIGINAL`/`RETIRADA`/absent are still your refund. `findPayment`
332
+ agrees: a charge settled by a Pix that was MED-returned reads back as `disputed`. Efí sends no
333
+ notification when a MED is _opened_ and its devolução object has no deadline field, so no
334
+ `actionableUntil` is invented.
335
+
336
+ **Woovi maps all four dispute events.** `OPENPIX:DISPUTE_CREATED` is a **`payment.dispute_warning`**,
337
+ not a `payment.disputed`: Woovi documents that the balance is _blocked_ while a MED is analysed, and
338
+ a block is not a withdrawal. `DISPUTE_REJECTED` closes it as `won`, `DISPUTE_ACCEPTED` as `lost`,
339
+ `DISPUTE_CANCELED` as `canceled` — meanings that come from Woovi's help centre rather than its API
340
+ reference, which is said plainly on the docs page. A dispute payload names the Pix and nothing else,
341
+ so `gatewayId` on these events is the **`endToEndId`**, and `OPENPIX:CHARGE_COMPLETED` now carries
342
+ `metadata.endToEndId` so an app can store the link and join the two.
343
+
344
+ **Woovi also matched none of its own event names.** Real payloads are prefixed —
345
+ `OPENPIX:CHARGE_COMPLETED`, not `CHARGE_COMPLETED` — and the driver's map was written against the
346
+ bare names, so a live webhook fell through as an unrecognized event and the payment was ledgered
347
+ without ever being synced. Both spellings are accepted now.
348
+
349
+ **AbacatePay has no dispute vocabulary beyond "opened", and none was invented.** `checkout.disputed`
350
+ and `transparent.disputed` stay `payment.disputed`; its published event list has nothing before a
351
+ dispute and nothing after it, so there is no warning to map and no outcome to report, and its
352
+ reference says neither whether the funds are withdrawn nor what the deadline to respond is. The
353
+ driver says so in a comment, the docs page says so in a callout, and a test keeps a later edit from
354
+ inventing either event.
355
+
356
+ - [#6](https://github.com/DavideCarvalho/adonis-agora-payments/pull/6) [`5d2a895`](https://github.com/DavideCarvalho/adonis-agora-payments/commit/5d2a895a41736ea924c1851c71e59a29034b299f) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - **Chargebacks, held funds and retry keys in the five card-first gateways** — Stripe, Adyen,
357
+ Square, Razorpay and PayPal now use the words the shared contract grew, and refuse where a
358
+ gateway cannot keep the promise.
359
+
360
+ **Stripe's webhooks are normalized at all.** The driver passed `event.type` and the raw
361
+ Stripe object straight through, and the processor switches on the canonical names — so it
362
+ recognized _nothing_ Stripe sent: every event was ledgered as processed and
363
+ `billing_payments` stayed empty. A chargeback cannot move a row that was never written, so
364
+ this is where the dispute work had to start. `payment_intent.*`, `charge.refunded`,
365
+ `charge.dispute.*`, `checkout.session.*` and `customer.subscription.*` now map onto the
366
+ canonical types; everything else still passes through under its Stripe name with the raw
367
+ object as `event.data`. **This is a behavior change for existing Stripe handlers**: one
368
+ registered on `'payment_intent.succeeded'` should now be registered on `'payment.succeeded'`.
369
+ A rename only happens when the canonical payload can actually be built — the built-in
370
+ handlers throw on a malformed payload, and a throw inside the webhook route is a 500 Stripe
371
+ retries forever.
372
+
373
+ **`payment.disputed` is mapped in all five.** Only the opening event, in every case; the
374
+ resolution — won, lost, funds withdrawn, evidence filed — stays `payment.updated`, because a
375
+ canonical type no driver emits is worse than none.
376
+
377
+ - **Stripe** — `charge.dispute.created`, keyed on the PaymentIntent (`payment_intent` is
378
+ nullable on the Dispute object, so the charge id is the fallback).
379
+ - **Adyen** — `NOTIFICATION_OF_CHARGEBACK` **and** `CHARGEBACK`. Both, because an ACH return
380
+ goes straight to `CHARGEBACK` with no notification and cannot be defended at all — mapping
381
+ only the notification would miss exactly the disputes nobody can fight.
382
+ `REQUEST_FOR_INFORMATION` and `NOTIFICATION_OF_FRAUD` deliberately stay updates: no money
383
+ moves on either, and treating them as chargebacks would take a live payment away over a
384
+ question.
385
+ - **Square** — `dispute.created`, keyed on `disputed_payment.payment_id` (nested, not a
386
+ top-level field).
387
+ - **Razorpay** — `payment.dispute.created`, keyed on `dispute.payment_id` with the
388
+ **disputed** amount, not the charged one.
389
+ - **PayPal** — `CUSTOMER.DISPUTE.CREATED` (and the deprecated `RISK.DISPUTE.CREATED`), keyed
390
+ on `seller_transaction_id` — the capture id; `buyer_transaction_id` would find no row.
391
+
392
+ **`authorized` — funds held, money not moved.**
393
+
394
+ - **Stripe** `requires_capture` was falling through to **`failed`**: a live authorization
395
+ reported as a dead payment. It is `authorized`, and `requires_confirmation` — which fell
396
+ there with it — is `pending`.
397
+ - **Razorpay** `authorized` was `pending` by a documented decision, taken when nothing better
398
+ existed. It is `authorized` now. The `payment.authorized` webhook stays `payment.updated`:
399
+ there is no canonical authorized event, and settling on it would report an order the
400
+ merchant has not been paid for.
401
+ - **Square** `APPROVED` was `pending` — the status of a payment nobody has attempted, for one
402
+ whose money is reserved with a clock against it.
403
+ - **Adyen** gains a **`captureMode`** option, because `Authorised` means two different things
404
+ and the Checkout API will not say which: `captureDelay` is a Management API field and both
405
+ modes answer `/payments` identically. With `'automatic'` (the default, and Adyen's) the
406
+ capture follows on its own and **no CAPTURE webhook is sent**, so `Authorised` is `paid`
407
+ and `AUTHORISATION` is `payment.succeeded`. With `'manual'` it is `authorized`,
408
+ `AUTHORISATION` is only an update, and `CAPTURE` is what settles it.
409
+ - **PayPal** creates `intent: 'CAPTURE'` orders, so it produces no held authorization of its
410
+ own; `PAYMENT.AUTHORIZATION.CREATED`/`.VOIDED` are now recognized as updates carrying
411
+ `status: 'authorized'` instead of falling through as unknown.
412
+
413
+ **`paused` no longer entitles anyone.** Stripe's `paused` mapped to **`active`**, which
414
+ granted access to a subscriber nobody is billing. Square's `PAUSED`, Razorpay's `paused` and
415
+ PayPal's `SUSPENDED` mapped to `past_due`, which said the subscriber owed money they did not.
416
+ All four say `paused`.
417
+
418
+ **Idempotency reaches the calls that grew a key — or throws.**
419
+
420
+ - **Stripe** sends `Idempotency-Key` on `refund`, `createCustomer`, `createSubscription` and
421
+ `updateSubscription` as well as the two it already had. Stripe accepts it on every POST, so
422
+ nothing here has to refuse.
423
+ - **Square**'s key is a body field, and `refund`, `createCustomer` and `createSubscription`
424
+ were generating a UUID and dropping yours — which made Square's own retry safe and a
425
+ retried **job** double-refund. `updateSubscription` **throws**: neither the PUT nor
426
+ `swap-plan` takes one.
427
+ - **Adyen** sends `Idempotency-Key` on `refund` too, capped at the documented 64 characters.
428
+ - **PayPal** sends `PayPal-Request-Id` on `refund` and `createSubscription`; every site now
429
+ goes through one guard capping the key at the 38 characters PayPal's own guidance names.
430
+ `updateSubscription` **throws** — `PATCH /v1/billing/subscriptions/{id}` documents no such
431
+ header.
432
+ - **Razorpay** has no general idempotency header at all. `refund` honours the one mechanism
433
+ that exists — **`X-Refund-Idempotency`**, validated (≥10 chars, `[A-Za-z0-9_-]`) before the
434
+ round trip, because a key Razorpay rejects is a key that does not deduplicate.
435
+ `createCustomer`, `createSubscription` and `updateSubscription` **throw**: dropping a key
436
+ there would turn a retry into a second customer, or a second live subscription billing the
437
+ same person every month.
438
+
439
+ **Payment methods are named by category.**
440
+
441
+ - **Square** widens `supportedMethods` to `wallet`, `bank_debit` and `bnpl` alongside the
442
+ card pair: the Web Payments SDK mints Cash App, ACH and Afterpay tokens, and they all reach
443
+ `POST /v2/payments` through the same `source_id`. `source_type` maps to those categories
444
+ instead of leaving everything but `CARD` unset.
445
+ - **PayPal** widens to `wallet`: `charge()` charges `payment_source.paypal.vault_id` and
446
+ nothing else, which is a stored-balance wallet by definition. `createCheckout()` stays
447
+ `undefined`. A charge routed as anything but the wallet now throws.
448
+ - **Razorpay** stays `['undefined']`, and the reason was never the missing `upi` name:
449
+ **`POST /v1/orders` has no `method` field**, so `charge()` cannot produce a UPI payment on
450
+ request — Checkout method restriction is a browser option. On the way back it is different,
451
+ and `upi`, `wallet`, `bank_transfer` (netbanking) and `bnpl` (paylater, cardless EMI) are
452
+ reported as themselves. A UPI payment used to come back unlabelled.
453
+ - **Stripe** and **Adyen** keep their `supportedMethods` — neither has a single API field for
454
+ "a bank transfer", only iDEAL, Bancontact, EPS, P24, BLIK and a new one each quarter — but
455
+ both now report the category on `Payment.method`. Adyen's was hardcoded to `card`, which
456
+ labelled a SEPA mandate or an iDEAL transfer a card payment.
457
+
458
+ - [#6](https://github.com/DavideCarvalho/adonis-agora-payments/pull/6) [`5d2a895`](https://github.com/DavideCarvalho/adonis-agora-payments/commit/5d2a895a41736ea924c1851c71e59a29034b299f) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - Seven things a real operator could not see once money was flowing. Each was found by asking a
459
+ question of a running install and discovering the console had no way to answer it.
460
+
461
+ **"Did THIS student's payment land?" had no answer.** `listPayments` returned
462
+ `externalReference` — the app's own join key, the one id an operator actually holds — and the
463
+ dashboard's payments handler dropped it before serialising, so the screen showed the gateway's
464
+ `pay_…` instead. There was no lookup by reference or by gateway id anywhere: the handler filtered
465
+ on `status`/`provider`, and so did the client. `PaymentListQuery` now carries
466
+ `externalReference`, `gatewayId` and `customerId` (all EXACT — `order-4` must never return
467
+ `order-42`), `GET <dashboard>/api/payments` takes `?reference=` / `?gatewayId=` / `?customerId=`,
468
+ the row payload carries `externalReference` and `refundedAmount`, and the screen has one search
469
+ box that tries the app's reference first and the gateway id second, because an operator pasting
470
+ from a support ticket does not know which of the two they are holding.
471
+
472
+ **There was no customers endpoint at all**, though `billing_customers` has held
473
+ `owner_type`/`owner_id` — written by every app calling `ensureCustomer` — since the first
474
+ release. That mapping is the only thing tying a payment to a person: the payment row carries
475
+ `cus_…` and nothing else. `GET <dashboard>/api/customers` lists it with `ownerType`/`ownerId`/
476
+ `gatewayId`/`provider` filters, the store gained `listCustomersByGatewayIds` so a page of payments
477
+ resolves its owners in one read rather than one query per row, and every payment row now carries
478
+ an `owner`. A gateway customer nobody mapped reads as **unmapped**, not as a blank — it means
479
+ charges are landing that this console can never attribute.
480
+
481
+ **No per-payment view, and the ledger could not be filtered.** `BillingListQuery` had no event
482
+ `type`, so "did a refund event ever arrive?" was unaskable. It does now, and `GET /api/providers`
483
+ reports the event types an install has actually received so the filter is built from data rather
484
+ than from the twenty types the package can emit. `GET /api/payments/:gatewayId` assembles what IS
485
+ knowable about one charge: current state, owner, the disputes filed against it, the ledger rows
486
+ whose stored delivery names it, and who refunded it. **It is not a history, and it does not claim
487
+ to be** — `billing_payments` is a single mutable row upserted in place, so what it used to be is
488
+ recorded nowhere. The ledger strand is a `CAST(payload AS TEXT) LIKE` scan and says so on the wire
489
+ (`events.matchedBy`) and on screen: it is unindexed, it can over-match, and it cannot see a
490
+ delivery that never stored the id. No history table was invented. The honest fix is a
491
+ `payment_gateway_id` column the **processor** fills on the way in, which is a write path this
492
+ change does not touch.
493
+
494
+ **The dispute-deadline check was structurally dead on a real install.** `disputes_due` was the
495
+ only dispute check, and both reads behind it require `evidence_due_by IS NOT NULL` — a column that
496
+ can only ever be filled by a gateway that publishes a deadline. On Asaas it comes from
497
+ `chargeback.deadlineToSendDisputeDocuments`, which the driver's own comments note no published
498
+ webhook example even contains. So a chargeback could be open with the money already pulled back
499
+ and `payments:health` reported healthy. A new **`open_disputes`** check counts every unanswered
500
+ dispute (`warning`, `open`, `under_review`) with no deadline required and **no threshold that can
501
+ turn it off** — an open chargeback is money already out of the account, so there is no horizon at
502
+ which it stops mattering. `BillingHealth` gained `openDisputes` (oldest first, because with no
503
+ deadline, age is the only priority left) and the health panel names them.
504
+
505
+ **A lost Asaas dispute could never be closed, so the alarm stayed red forever.** Asaas publishes
506
+ no lost-dispute event and the driver hardcodes `outcome: 'won'` on close, so `billing_disputes`
507
+ sat at `open` indefinitely; `listDisputesDueWithin` counts past-deadline rows on purpose, so the
508
+ check stayed red and a fifteen-minute cron logged the same failure until nobody read it — burying
509
+ every other finding with it. `POST <dashboard>/api/disputes/:gatewayId/resolve` records how a
510
+ dispute ENDED: a finished status (`lost`/`won`/`expired`/`canceled`), an outcome, a note, and WHO
511
+ said so. It sends nothing to a gateway, and the dialog says so twice: the decision was made at the
512
+ bank, this writes down which way it went. There is still no "fight" and no "accept" — that is a
513
+ business rule and it stays in the app's code.
514
+
515
+ **A rejected delivery left no record and no check covered it.** A bad signature, an unparsable
516
+ body or an unknown provider is answered `400` with nothing written anywhere, so a rotated webhook
517
+ token was invisible: zero events, zero failures, every check green. `unconfirmed_payments`
518
+ eventually fires at 2 h, but only for charges the app itself created — refund, chargeback and
519
+ dispute-closure deliveries produce no pending payment and simply vanished. A new
520
+ `billing_audit_events` table records them, a **`rejected_deliveries`** check counts them over 24 h,
521
+ and `GET <dashboard>/api/audit` surfaces them. **The rejection itself happens in
522
+ `providers/payments_provider.ts`, which this change does not touch: the endpoint must call
523
+ `store.recordAuditEvent({ action: 'webhook.rejected', provider, message })` on each `400` path for
524
+ the check to see anything.** Until it does, the store, the check and the screen are in place and
525
+ the count is zero.
526
+
527
+ **And a refund issued from the console left no audit trail.** The only record was a diagnostic
528
+ carrying a gateway id, a provider and an amount — and no actor, even though the dashboard's own
529
+ `enforce()` had already verified exactly who authorised the request. `enforce()` now returns that
530
+ user, `Deps.actor` carries it per request, and a successful refund writes an audit row naming the
531
+ person, the amount asked for and whether it was partial. A refund the gateway REFUSED writes
532
+ nothing: an audit of refunds that never happened is an audit nobody can trust. A console with no
533
+ `dashboardAuth` records `actor: null` — "unattributed", never an invented "system".
534
+
535
+ `billing_audit_events` is a new TABLE, so it needs nothing from the schema module's post-ship
536
+ ALTER phase: `CREATE TABLE IF NOT EXISTS` carries it to an existing install exactly as well as to a
537
+ fresh one. An install that upgrades the package before running the migration keeps working — every
538
+ audit write is additional to an action that already happened, so a missing table skips the note and
539
+ answers `null` rather than failing a refund the gateway already accepted.
540
+
541
+ The console gained two screens (**Customers** and **Activity**) and a per-payment detail view.
542
+ 1261 unit tests, 109 integration tests against real Postgres, 119 dashboard tests, typecheck and
543
+ lint clean. Every fix proven by mutation, including the JSONB cast — `jsonb LIKE text` is not an
544
+ operator in Postgres, and the in-memory store would have gone on passing without it.
545
+
546
+ - [#6](https://github.com/DavideCarvalho/adonis-agora-payments/pull/6) [`5d2a895`](https://github.com/DavideCarvalho/adonis-agora-payments/commit/5d2a895a41736ea924c1851c71e59a29034b299f) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - **The dashboard mounts at `/payments` now, not `/payments-dashboard`.**
547
+
548
+ It shares that prefix with the machine endpoints — `POST /payments/webhook/:provider` and
549
+ `GET /payments/client/status` — and the old default existed because that looked dangerous: a console
550
+ guard sitting in front of a gateway's delivery endpoint is how a webhook ends up answering `403` to
551
+ Stripe.
552
+
553
+ It is not dangerous, because every route the dashboard provider registers is an **exact** path:
554
+ `/payments`, `/payments/assets/:file`, `/payments/api/…`, and the login routes. There is no SPA
555
+ catch-all, so the guard cannot reach a delivery it was never routed. The test that used to assert
556
+ "no dashboard route starts with `/payments`" now asserts the invariant that actually matters — no
557
+ wildcard, and nothing dynamic directly under the prefix, either of which would swallow
558
+ `:provider` and `client`.
559
+
560
+ **To keep the old URL**, set it explicitly:
561
+
562
+ ```ts
563
+ // config/payments_dashboard.ts
564
+ export default defineConfig({ path: "/payments-dashboard" });
565
+ ```
566
+
567
+ Also: `assertCapability` accepts `'disputes'`. The capability has been on the driver contract since
568
+ disputes landed and was read by nothing, so an app could call `submitDisputeEvidence` on a gateway
569
+ that had already declared it does not support it.
570
+
571
+ - [#6](https://github.com/DavideCarvalho/adonis-agora-payments/pull/6) [`5d2a895`](https://github.com/DavideCarvalho/adonis-agora-payments/commit/5d2a895a41736ea924c1851c71e59a29034b299f) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - Add the **Disputes** screen to the billing console. `billing_disputes` had a full JSON API and no
572
+ UI, so the one table in this package with a deadline was the one you could not see.
573
+
574
+ A chargeback has a clock, and missing it loses the money **by default rather than on the merits**,
575
+ so the screen leads with the clock rather than with the log. Two panels, in this order:
576
+
577
+ - **Evidence windows closing** — `GET <api>/disputes?dueWithin=<hours>`: open disputes carrying a
578
+ deadline, soonest first, deadline as the leading column. The countdown is in hours (`in 5 hours`),
579
+ not days: rendering that as "today" is the difference between filing this morning and losing by
580
+ default. The horizon picker opens on **72 h** — the same horizon `payments:health` alerts on, so
581
+ the console and the cron agree about "soon" — and the count above the table is the server's
582
+ unbounded `dueWithin.total`, not the page it happened to fit.
583
+ - **All disputes** — the log, newest first, filterable by status and gateway.
584
+
585
+ The work list is deliberately **not** filterable by gateway. It is the one list whose whole job is
586
+ that nothing gets missed, and narrowing it to Stripe while an Asaas window shuts tonight is exactly
587
+ the failure it exists to prevent.
588
+
589
+ Three nullable facts are rendered as the things they mean, not as missing data:
590
+
591
+ - a window **already past its deadline still appears**, marked `past due` with how long ago — it is
592
+ still open and still unanswered, and going quiet the moment it expires reads as resolved;
593
+ - a dispute with **no deadline** is absent from the work list and says _the gateway sends no
594
+ deadline_ in the log, rather than showing a dash. Several gateways send no date, and Woovi's
595
+ three-day rule is policy rather than a field;
596
+ - a dispute with **no amount** (Stripe's early fraud warning carries none) says so instead of
597
+ rendering `R$ 0,00`.
598
+
599
+ `warning` does not look like a chargeback: nothing has been pulled back, a refund still prevents the
600
+ debit, and the row says _no money moved_ in words rather than leaving it to a hue. The seven
601
+ `DisputeStatus` values get their own hues, and an unmodelled gateway status falls back to grey
602
+ rather than borrowing one.
603
+
604
+ **The screen is read-only and stays that way.** No "fight this", no "accept", no "refund": whether
605
+ contesting is worth it turns on margin, customer value, the dispute fee and the chargeback ratio
606
+ that puts a merchant into a card network's monitoring programme. That is a business rule that lives
607
+ in your app's code, and a console button invites someone to press it without any of that context.
608
+ The JSON API has no action route for disputes, and a test asserts the client grows no method that
609
+ implies one.
610
+
611
+ Also in the console: the health panel's fourth check (`disputes_due`) now has somewhere to go — its
612
+ button opens the Disputes screen on exactly the rows it counted — and the panel names **which**
613
+ windows are closing, with gateway, dispute id and countdown, instead of only how many.
614
+
615
+ - [#6](https://github.com/DavideCarvalho/adonis-agora-payments/pull/6) [`5d2a895`](https://github.com/DavideCarvalho/adonis-agora-payments/commit/5d2a895a41736ea924c1851c71e59a29034b299f) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - Turn the billing dashboard into a management console: health panel, subscriptions screen, gateway filter, and the first two actions.
616
+
617
+ The console could tell you what happened; it could not tell you what needed doing, and it could not
618
+ do anything about it. Four changes:
619
+
620
+ - **A health panel at the top of the Overview**, above the revenue tiles. `billingHealth()` — the
621
+ three silent failures of a billing install (events claimed and never finished, events the
622
+ dispatcher gave up on, charges created that never confirmed) — existed only behind
623
+ `node ace payments:health`. It is now `GET <path>/api/health` and the first thing on the page: a
624
+ quiet green line when the install is clean, and when it is not, the count, what it means, which
625
+ provider and event type is failing, and a link straight to those rows.
626
+ - **A subscriptions screen** (`GET <path>/api/subscriptions`), defaulting to `past_due`, showing
627
+ plan, customer, trial end and period end. `paused` is rendered as its own state with its own hue
628
+ and an explicit "not billing" — a paused subscriber is not paying, and reading them as active
629
+ grants access to someone who is not.
630
+ - **A gateway filter** on payments, subscriptions and webhook events, built from
631
+ `GET <path>/api/providers` — what your data actually contains, not the eighteen drivers the
632
+ package ships. A filtered page reports when its scan stopped short rather than showing a
633
+ confident empty result.
634
+ - **Two actions**, the console's first writes. `POST <path>/api/payments/:gatewayId/refund`
635
+ (optional partial amount, confirmed in the UI with the amount and the customer, refused before the
636
+ call for a gateway with no refund API, reporting the gateway's own message when it refuses) and
637
+ `POST <path>/api/webhook-events/:gatewayEventId/retry`, which re-runs a failed event through your
638
+ own webhook handlers. Both are `POST` only and both run through the existing `authorize` guard and
639
+ the `dashboardAuth` session.
640
+
641
+ Also: the payments filter now offers `authorized` and `disputed` (both were reachable statuses with
642
+ no way to filter for them), and the SPA's money formatter was aligned with `src/money.ts` — it was
643
+ missing the three-decimal currencies entirely, so a KWD amount rendered 10× too large.
644
+
645
+ - [#6](https://github.com/DavideCarvalho/adonis-agora-payments/pull/6) [`5d2a895`](https://github.com/DavideCarvalho/adonis-agora-payments/commit/5d2a895a41736ea924c1851c71e59a29034b299f) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - A chargeback has a deadline, and until now nothing in the library knew it.
646
+
647
+ `payment.disputed` moved the payment row off `paid`, which stopped the worst version of the bug —
648
+ revenue counted twice while the money was already going back. But a dispute is not one moment, it is
649
+ three, and the library had a name for only the middle one. The two that decide the outcome were
650
+ missing: the **warning** that arrives before a chargeback exists, when refunding is still cheaper
651
+ than losing, and the **close** that carries whether you won.
652
+
653
+ **The contract.** `Dispute` and `DisputeEvidence` are shared types now, and the driver contract
654
+ carries `capabilities.disputes` plus optional `findDispute` and `submitDisputeEvidence`. `Dispute`
655
+ leads with `evidenceDueBy` and `canSubmitEvidence` because those are the two fields an operator acts
656
+ on — every network gives a fixed window to respond, and missing it loses the money by default rather
657
+ than on the merits. Both methods are optional: a gateway with no dispute API declares
658
+ `disputes: false` and the router refuses before a call is made, rather than a driver returning an
659
+ empty object that reads like "no disputes".
660
+
661
+ **Two new canonical webhook types.** `payment.dispute_warning` and `payment.dispute_closed` join
662
+ `WEBHOOK_EVENT_TYPES`, and the diagnostics bus publishes them with the fields that make them
663
+ actionable — `actionableUntil` on the warning, `outcome` on the close. **No driver maps them yet**;
664
+ they are declared so the drivers can be wired one at a time against real gateway payloads without
665
+ each one inventing its own name for the same event. Today Adyen's `NOTIFICATION_OF_FRAUD` still
666
+ flattens to `payment.updated` and Stripe's `radar.early_fraud_warning.created` lands in the ledger
667
+ as an unknown type. Both are tracked in the roadmap.
668
+
669
+ **What this deliberately does not do:** decide. The library carries evidence to the gateway and tells
670
+ you a window is closing. Whether a dispute is worth fighting or cheaper to refund depends on margin,
671
+ customer value and fraud history — that is a business rule, and it stays in your code.
672
+
673
+ - [#6](https://github.com/DavideCarvalho/adonis-agora-payments/pull/6) [`5d2a895`](https://github.com/DavideCarvalho/adonis-agora-payments/commit/5d2a895a41736ea924c1851c71e59a29034b299f) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - A dispute has a deadline, and now something stores it.
674
+
675
+ The three dispute events landed and the processor reacted to all of them, but nothing was
676
+ persisted: `evidence_due_by` arrived on a webhook, went out on the diagnostics bus, and was gone.
677
+ "Which disputes are open, and which windows close this week" could only be answered by opening every
678
+ gateway's own dashboard, one at a time — and a window that closes unanswered loses the money by
679
+ default rather than on the merits.
680
+
681
+ **New `billing_disputes` table**, published as `add_billing_disputes` (guarded by `hasTable`, so a
682
+ fresh install gets it from `create_billing_tables` and this one does nothing) and declared in the
683
+ create migration too. It keeps the dispute's own gateway id (unique — the idempotency key), the
684
+ payment's gateway id (indexed — the join back to `billing_payments`), status, reason, amount,
685
+ `evidence_due_by`, the outcome, and the opened/closed timestamps.
686
+
687
+ **New reads on `BillingStore`**: `saveDispute`, `findDisputeByGatewayId`,
688
+ `findOpenDisputeByPayment`, `listDisputes`, `countDisputes`, and the two that earn the table —
689
+ `listDisputesDueWithin({ withinHours })` and `countDisputesDueWithin(...)`, the open disputes whose
690
+ window closes soonest, in deadline order rather than arrival order. A deadline already **past**
691
+ stays in the list: the dispute is still open and still unanswered, and dropping it the moment it
692
+ expires would make the alert go quiet at exactly the moment it became true. A dispute the gateway
693
+ sent no deadline for is never in it — `null` there means "the gateway told us nothing", not "no
694
+ hurry".
695
+
696
+ **The processor writes them.** Each of `payment.dispute_warning`, `payment.disputed` and
697
+ `payment.dispute_closed` now persists a dispute row _in addition_ to what it already did — a warning
698
+ still moves no money, a chargeback still moves the payment to `disputed`, a won close still puts it
699
+ back to `paid`, and a close with no outcome still throws. Rows are keyed on the dispute's own gateway
700
+ id; where a gateway sends none, on `dispute:<provider>:<payment gateway id>`, so the later events of
701
+ one dispute land on the row its opening event created instead of accumulating one row per webhook.
702
+
703
+ **`node ace payments:health` gained a fourth check**: an open dispute whose evidence window closes
704
+ within 72 hours (`--dispute-window` in hours). It exits non-zero like the others and names each
705
+ closing window — the dispute, the payment and the deadline — because a count sends nobody anywhere.
706
+
707
+ **A read-only disputes panel** in the dashboard API: `GET <path>/api/disputes` for the log, and
708
+ `?dueWithin=<hours>` for the work list, with the full closing-window total beside the page. No
709
+ action buttons, deliberately: whether to fight a dispute or refund it turns on the fee, the evidence
710
+ your app holds and the chargeback ratio that triggers network monitoring, and that decision stays in
711
+ your code.
712
+
713
+ An install that upgrades the package before running the migration keeps taking webhooks: the dispute
714
+ write is skipped and the dispute reads answer empty, rather than failing every gateway delivery with
715
+ `relation "billing_disputes" does not exist`.
716
+
717
+ - [#6](https://github.com/DavideCarvalho/adonis-agora-payments/pull/6) [`5d2a895`](https://github.com/DavideCarvalho/adonis-agora-payments/commit/5d2a895a41736ea924c1851c71e59a29034b299f) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - Make `findDispute` and `submitDisputeEvidence` real on Stripe, and say plainly why Adyen cannot have
718
+ them.
719
+
720
+ `PaymentsDriver` has declared both methods and a `capabilities.disputes` flag since the dispute pass,
721
+ and no driver implemented either — a contract nothing kept. Stripe now keeps it.
722
+
723
+ **Stripe** (`capabilities.disputes: true`, API `2025-08-27.basil`). `findDispute` is
724
+ `GET /v1/disputes/{id}`; `submitDisputeEvidence` is `POST /v1/disputes/{id}` with the `evidence` hash
725
+ and `submit` (defaulting to `true` — the method is called submit, and staging silently would report a
726
+ defense the bank never received; `metadata.submit: false` stages a draft). The two fields an operator
727
+ acts on are read from Stripe's own `evidence_details`, never guessed from the status: `evidenceDueBy`
728
+ from `due_by`, and `canSubmitEvidence` from the status **and** `past_due` — past the deadline a
729
+ dispute is lost by default while its status still says `needs_response`. A dispute Stripe closed as
730
+ `lost` on arrival, or one the networks forbid contesting, reports `canSubmitEvidence: false`, so the
731
+ caller learns it before building a case rather than at the API error. `warning_*` statuses stay on the
732
+ warning side of the money line, `warning_closed` maps to `expired` and `prevented` to `canceled`.
733
+
734
+ Evidence is carried or refused, never dropped. `explanation` → `uncategorized_text`, the customer and
735
+ shipping fields → their Stripe names, a single `documentIds` entry → `uncategorized_file`, and
736
+ `metadata` reaches Stripe's own 27 evidence field names verbatim (checked against them, with file
737
+ fields requiring a `file_…` upload id). What Stripe has no home for throws with the fix in the
738
+ message: `receiptUrl`/`invoiceUrl`/`termsUrl` (Stripe wants a File upload id, and reviewing banks
739
+ follow no links), `priorUndisputedPayments` (Visa CE 3.0 wants two prior charge ids with device and
740
+ IP, not a count), more than one `documentIds` entry (Stripe files evidence by type), and an empty
741
+ evidence packet — which would spend the dispute's single submission on nothing. Mapping happens
742
+ before the dispute is read, so a refusal costs no round trip.
743
+
744
+ The library never decides whether to fight: no auto-submit, no threshold, no default response. It
745
+ makes the deadline visible and the submission one honest call.
746
+
747
+ **Adyen** keeps `capabilities.disputes: false`, and both methods now exist and throw instead of being
748
+ absent. Adyen has a dispute API — Defend Disputes v30 — but it cannot be driven through this
749
+ contract: none of its five endpoints reads a dispute back (so `Dispute.status`, `amount` and the
750
+ deadline would be invented), a defense is a scheme-specific `defenseReasonCode` plus base64 documents
751
+ with no free-text field anywhere, and it lives on `ca-{test,live}.adyen.com/ca/services/DisputeService/v30`
752
+ behind a credential with the "API dispute management" role. The throws name the three calls to make
753
+ instead, and the provider page documents the flow — including that a `disputeServiceResult.success:
754
+ false` arrives with an HTTP 200.
755
+
756
+ - [#6](https://github.com/DavideCarvalho/adonis-agora-payments/pull/6) [`5d2a895`](https://github.com/DavideCarvalho/adonis-agora-payments/commit/5d2a895a41736ea924c1851c71e59a29034b299f) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - A documentation sweep across every page, and the six code bugs it turned up. The docs were read
757
+ against the source rather than against themselves, which is the only way this kind of thing surfaces.
758
+
759
+ **AbacatePay created checkouts and subscriptions for 1/100 of their amount.** AbacatePay documents it
760
+ outright — "valores monetários são sempre em centavos" — and `charge()` passed the integer straight
761
+ through, while `createCheckout` and `createSubscription` ran `toDecimal`. The driver disagreed with
762
+ itself on two paths nobody had compared: R$19,90 went out as `19.9` and became a 19-centavo
763
+ checkout. Same bug as Woovi's, found the same way.
764
+
765
+ **`withBillable` was not exported from the package root**, so every model `node ace make:billable`
766
+ has ever scaffolded failed to compile — the stub imports it from `@adonis-agora/payments` and the
767
+ root exported only the model classes. `withSubscription` and `withPayment` were unreachable too. A
768
+ new test reads every published stub and fails when it imports a symbol the package does not export.
769
+
770
+ **A failing invoice provider rejected `charge()` after the gateway had taken the money.** The caller
771
+ saw a failed call over a real charge, and the obvious response to a failed charge is to charge again.
772
+ The charge and the invoice are two different facts; `charge()` now returns the true one and publishes
773
+ **`invoice.failed`** with the payment's gateway id. Subscribe to it: in Brazil an NFS-e is a legal
774
+ obligation and nothing else will tell you.
775
+
776
+ **`billing.dispatcher: 'queue'` never existed.** It was in the config type, the docs and the
777
+ dispatcher's own comments; `queueDispatch` was declared and never read, so the mode fell through and
778
+ silently ran durable-or-in-process. Splitting `billing.role` across api/worker was _permitted_ on it,
779
+ which produced an api process doing all the work and a worker sitting idle. It is refused at boot
780
+ with a named error, and `'durable'` is now the only mode a deployment can be split on.
781
+
782
+ **`autoCreateSchema: false` did not reach a store the app built itself.** `billing.store: () =>
783
+ lucidBillingStore({ paymentModel: MyPayment })` constructs the store before the provider reads the
784
+ config, so the flag was skipped and DDL ran against exactly the shared database it was set to
785
+ protect.
786
+
787
+ **`payment.failed` never published its `reason`.** The field was on the payload type from the
788
+ beginning, so a subscriber could not see it even on the gateways that normalize one — and "why" is
789
+ the only question anyone asks of a failed payment.
790
+
791
+ Smaller: the routing error listed five payment methods when there are eleven (the union is derived
792
+ from a list now, so the message cannot go stale again); the dashboard's `422` told operators to run a
793
+ migration that no longer exists; and a dozen doc comments still named the collapsed migration files.
794
+
795
+ - [#6](https://github.com/DavideCarvalho/adonis-agora-payments/pull/6) [`5d2a895`](https://github.com/DavideCarvalho/adonis-agora-payments/commit/5d2a895a41736ea924c1851c71e59a29034b299f) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - Add the Dodo Payments driver (`payments.dodo()`) — merchant-of-record billing for SaaS,
796
+ over the REST API with no SDK dependency.
797
+
798
+ Dodo is the legal seller of record: it registers for, calculates, files and remits sales
799
+ tax and VAT across 220+ regions, carries chargeback liability and PCI scope, and pays you
800
+ net. The library does not model any of that, but one consequence lands directly in the
801
+ driver: **there is no amount-only endpoint anywhere in the Dodo API.** Every charge names a
802
+ product you created in Dodo, and an arbitrary amount is honored only on a product with Pay
803
+ What You Want enabled. `charge()` therefore requires `metadata.productId`, and refuses
804
+ rather than guessing.
805
+
806
+ **Supports** cards worldwide and **Pix in Brazil** — the charge's `method` becomes
807
+ `allowed_payment_method_types`, which is the field that restricts the hosted checkout
808
+ (`pix` keeps the card methods as a fallback, because a checkout whose every listed method
809
+ is unavailable simply fails). **Boleto is not advertised**: it exists in Dodo's raw
810
+ processor-level enum but is absent from its supported-methods documentation. A Pix charge
811
+ returns `hostedUrl` and no `pixCode` — Dodo's API returns neither field; the payer gets the
812
+ QR on Dodo's page.
813
+
814
+ Also: customers, checkout sessions (`POST /checkouts`, the non-deprecated route),
815
+ subscriptions, full refunds, and payments-as-invoices with the PDF on `hostedPdfUrl`.
816
+ Amounts are integers in the currency's smallest unit throughout, so nothing is converted.
817
+ `currency` is required and is sent as Dodo's `billing_currency`.
818
+
819
+ **Refuses**, rather than reporting a change Dodo never made:
820
+
821
+ - `refund(paymentId, amount)` — a partial refund. Dodo's `POST /refunds` has no top-level
822
+ amount; partial refunds are per line item and need the product id of the line, which this
823
+ contract's single `amount` cannot address. A full refund works normally.
824
+ - `createSubscription({ amount | cycle })` — the recurring price and interval belong to the
825
+ product.
826
+ - `createSubscription({ card })` — there is no tokenized-card input.
827
+ - `updateSubscription({ amount | description })` — a Dodo subscription has neither field. A
828
+ plan switch goes through `metadata.productId` and `POST /subscriptions/{id}/change-plan`.
829
+ - `updateCustomer({ taxId })` — a Dodo customer has no tax id; it belongs to an individual
830
+ B2B purchase.
831
+ - `charge()` with no `metadata.productId`, or with no billing country from
832
+ `metadata.billingCountry` / `metadata.billing` / the driver's `billingCountry` option —
833
+ Dodo requires a country on every payment.
834
+
835
+ `charge()` returns a **pending** payment: `POST /payments` hands back a payment link the
836
+ customer still has to pay on, and settlement arrives as a `payment.succeeded` webhook.
837
+ Cancelling is a `PATCH` either way — Dodo has no `DELETE /subscriptions/{id}` — and Dodo has
838
+ no `subscription.created` event, so `subscription.active` is what maps onto it.
839
+
840
+ Webhooks are verified against the Standard Webhooks scheme with a ±5 minute replay window.
841
+ The HMAC key is the secret with `whsec_` stripped and the rest **base64-decoded** — the
842
+ spec default, which Dodo's docs never state outright but its SDK's dependency on the
843
+ `standardwebhooks` reference library settles. Note this is the opposite of the Polar driver
844
+ added alongside it.
845
+
846
+ Written against the published API reference and covered by unit tests; **not yet exercised
847
+ against a live Dodo Payments account.** Verify in test mode before taking real money.
848
+
849
+ - [#6](https://github.com/DavideCarvalho/adonis-agora-payments/pull/6) [`5d2a895`](https://github.com/DavideCarvalho/adonis-agora-payments/commit/5d2a895a41736ea924c1851c71e59a29034b299f) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - **Efí (formerly Gerencianet)** driver — the **Pix API** (`pix.api.efipay.com.br`) and only
850
+ that one. Efí's Cobranças API is a different product with different auth (boleto, card,
851
+ carnê, native subscriptions, no certificate); nothing there is reachable from this driver
852
+ and no flag switches it over. BRL-only, so no `currency` option.
853
+
854
+ ```ts
855
+ providers: {
856
+ efi: payments.efi({
857
+ clientId: env.get('EFI_CLIENT_ID'),
858
+ clientSecret: env.get('EFI_CLIENT_SECRET'),
859
+ pixKey: env.get('EFI_PIX_KEY'),
860
+ certificate: env.get('EFI_CERTIFICATE'), // the .p12 from the Efí dashboard
861
+ }),
862
+ }
863
+ ```
864
+
865
+ **The certificate is the interesting part.** Efí requires mutual TLS on every request,
866
+ including the OAuth token request, and a client certificate belongs to the TLS handshake
867
+ rather than to a header — so it could not be configured the way every other gateway's key
868
+ is. `httpRequest` gained one optional `fetch` option, and the driver builds a
869
+ certificate-bearing `fetch` over `node:https` (which has accepted `pfx` forever) and passes
870
+ it through, keeping the shared error handling and adding **no new dependency**. Pass
871
+ `certificate` as a path or a Buffer, or pass your own `fetch` when a proxy holds the
872
+ certificate. Miss it and the driver refuses to boot — at boot, not at the first charge —
873
+ with the dashboard path and the config key in the message.
874
+
875
+ **The token cannot outlive itself.** The access token is cached against the `expires_in`
876
+ Efí actually returned, minus a minute of skew; concurrent charges share one token request
877
+ instead of racing to mint several, and a `401` drops the cache and retries once for the
878
+ token revoked before it expired. A cache with a lifetime of its own is how you get a driver
879
+ that works all afternoon and starts failing an hour into a deploy that stayed up — the
880
+ tests move the clock past the expiry and assert a second token is minted.
881
+
882
+ **The txid is the only reference Efí echoes.** Its notification carries `endToEndId`,
883
+ `txid`, `chave`, `valor`, `horario` and `infoPagador` — nothing else of yours. So an
884
+ `externalReference` that fits the txid charset (26–35 alphanumerics) is sent as the txid and
885
+ comes back as `event.data.externalReference`; one that does not fit is not mangled — Efí
886
+ generates the txid and you route on the returned `gatewayId`. Money crosses as a decimal
887
+ string built by shifting the integer's digits, never by dividing, so `1990` cannot leave as
888
+ `"19.89"`.
889
+
890
+ **Nothing authenticates the webhook inside the driver, and the docs say so** instead of
891
+ implying a guarantee. Efí's mechanisms are mutual TLS at your edge and an `hmac` **query
892
+ parameter** — and `parseWebhook` is handed the body and the headers, never the URL. The
893
+ provider page says where to enforce both. A batched notification (more than one Pix in the
894
+ array) is refused loudly with every txid named, rather than processing the first and
895
+ dropping money that has already arrived; the registration probe, which has no `pix` array,
896
+ is answered with an inert event so registering the webhook succeeds.
897
+
898
+ **What it refuses:** every subscription method (recurring Pix at Efí is either the Cobranças
899
+ API or Pix Automático, both different products), every customer operation (the payer is
900
+ inline on the charge), and any `method` other than `pix`. `listInvoices` returns `[]`.
901
+
902
+ Written against Efí's published Pix API reference and covered by unit tests; **it has never
903
+ presented a real certificate to a live Efí account.** Verify against homologation (`pix-h`)
904
+ with your own `.p12` before taking real money.
905
+
906
+ - [#6](https://github.com/DavideCarvalho/adonis-agora-payments/pull/6) [`5d2a895`](https://github.com/DavideCarvalho/adonis-agora-payments/commit/5d2a895a41736ea924c1851c71e59a29034b299f) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - Store the two things the library's own story depended on and never kept: the app's
907
+ `externalReference` on the payment row, and the normalized event on the webhook ledger row.
908
+
909
+ **This release adds two database columns. Existing installs must run a new migration.**
910
+ `node ace configure @adonis-agora/payments` now publishes a second migration file,
911
+ `add_billing_external_reference`, alongside `create_billing_tables` — it adds
912
+ `billing_payments.external_reference` (nullable, indexed) and
913
+ `billing_webhook_events.normalized` (nullable jsonb). Every step in it is guarded by
914
+ `hasColumn`, so a fresh install (whose `create_billing_tables` already declares both columns)
915
+ runs it as a no-op. Run `node ace migration:run` after upgrading.
916
+
917
+ - **`externalReference` is now stored and queryable.** Drivers mapped it, `parseWebhook`
918
+ surfaced it and the processor published it on the diagnostics bus — and then dropped it, so
919
+ nothing could look a payment up by the id the app actually knows it by. The processor now
920
+ persists it from `payment.succeeded`, `payment.failed`, `payment.refunded` and
921
+ `payment.disputed`; a later event that echoes no reference does **not** blank a stored one.
922
+ New: `BillingStore.findPaymentByExternalReference(reference)` (both implementations),
923
+ `savePayment({ externalReference })`, and `externalReference` on `PaymentListItem`.
924
+ - **The browser status endpoint polls by your own id.** `GET <path>/status?reference=` now looks
925
+ the payment up by `external_reference` first and falls back to reading the reference as a
926
+ gateway id. `resolveReference` remains as an escape hatch for apps that poll with something
927
+ that is neither, but it is no longer the default: `config.resolveReference` now defaults to
928
+ `null` instead of the identity mapping, and setting it replaces the built-in lookup entirely.
929
+ - **The dashboard's webhook retry replays signed gateways.** It rebuilds the event from the
930
+ ledger row's `payload` + `normalized` columns and runs the processor directly, never calling
931
+ `parseWebhook` — which used to re-verify a signature computed over headers the ledger never
932
+ kept, so a Stripe or Adyen retry answered `422` while unsigned gateways replayed fine.
933
+ `createReplayAction` no longer takes a `parse` dependency.
934
+
935
+ **Degradation before the migration runs, by design:** both writes ask the schema once (cached)
936
+ and skip a column that is not there, so an install that upgrades the package before migrating
937
+ keeps taking webhooks. It records payments without a reference,
938
+ `findPaymentByExternalReference` answers `null`, the status endpoint falls back to the gateway
939
+ id, and the dashboard's retry answers `422` naming the missing migration. Nothing is
940
+ backfilled — a reference that was never stored cannot be recovered from a raw payload.
941
+
942
+ - [#6](https://github.com/DavideCarvalho/adonis-agora-payments/pull/6) [`5d2a895`](https://github.com/DavideCarvalho/adonis-agora-payments/commit/5d2a895a41736ea924c1851c71e59a29034b299f) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - Four places where the config said one thing and the money did another. Three gateway calls that
943
+ reported a success the gateway never performed now do the real thing, or refuse — and Stripe no
944
+ longer picks a currency for you.
945
+
946
+ **Breaking config change: `currency` is required on Stripe.** It defaulted to `'brl'`, so an app in
947
+ the euro area that never set it charged in reais — silently, successfully, with nothing in the flow
948
+ saying so. Stripe bills in whatever you hand it, which makes any default a guess at the app's
949
+ country, and the wrong guess does not fail. The driver now refuses to boot without one:
950
+
951
+ ```
952
+ [payments] Driver "stripe" has no currency configured. Set `currency` in
953
+ config/payments.ts — a multi-currency gateway has no safe default.
954
+ ```
955
+
956
+ **To migrate:** add `currency` to your Stripe provider in `config/payments.ts` —
957
+ `payments.stripe({ apiKey: env.get('STRIPE_KEY'), currency: 'brl' })` keeps today's behavior
958
+ explicitly. The type makes it required, so `tsc` finds it before boot does. Asaas, AbacatePay and
959
+ Woovi are BRL-only, take no currency option, and are unchanged.
960
+
961
+ **Stripe sends the idempotency key as a request header.** `idempotencyKey` was written into the
962
+ PaymentIntent's metadata, which Stripe does not deduplicate on — a retried charge created a second
963
+ PaymentIntent while the docs promised it could not. `charge` and `createCheckout` now pass it as the
964
+ SDK's `idempotencyKey` request option (the `Idempotency-Key` header), the two inputs that carry a
965
+ key. The metadata copy stays: Stripe never echoes the header back on the object, so it is the only
966
+ thing that lets `payment.payload` trace a charge to the key that created it.
967
+
968
+ **Stripe creates the payment method the charge asked for.** `charge()` never sent
969
+ `payment_method_types`, so a charge routed as Pix was created with whatever the account's dashboard
970
+ defaults are — while `supportedMethods` advertised `pix` and `boleto`. The charge's `method` now maps
971
+ onto the intent (`pix` → `pix`, `boleto` → `boleto`, `credit_card` → `card`), and a Pix or boleto
972
+ intent comes back with what the payer needs: `pixCode` (the BR Code) and `hostedUrl` (Stripe's
973
+ instructions or voucher page), read off `next_action`. A charge with no `method` still falls back to
974
+ Stripe's dynamic payment methods. Note that routing alone does not carry the method —
975
+ `payments.driver('pix')` picks the provider, `method: 'pix'` on the charge picks the method.
976
+
977
+ **Woovi refuses a subscription update instead of faking one.** `updateSubscription` fetched the
978
+ subscription, merged the new amount into a local copy and returned it, so the caller got back a
979
+ `Subscription` showing the new amount while OpenPix kept charging the old one. OpenPix subscriptions
980
+ are immutable — the API creates and reads them and nothing else — so the driver now throws a
981
+ `[payments]` error telling you to cancel and recreate, or to keep the change on your own record.
982
+ This is a behavior change for anyone who was calling it and believing the result.
983
+
984
+ - [#6](https://github.com/DavideCarvalho/adonis-agora-payments/pull/6) [`5d2a895`](https://github.com/DavideCarvalho/adonis-agora-payments/commit/5d2a895a41736ea924c1851c71e59a29034b299f) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - PayPal, Razorpay and Square were all reporting a pre-dispute alert as a chargeback, and none of the
985
+ four drivers here could tell you how a dispute ended. All four now speak the full dispute
986
+ vocabulary — `payment.dispute_warning`, `payment.disputed`, `payment.dispute_closed` — and carry the
987
+ response deadline as `actionableUntil`.
988
+
989
+ **PayPal.** A PayPal dispute is not a card-scheme chargeback; it has a lifecycle of its own and
990
+ PayPal decides it. `CUSTOMER.DISPUTE.CREATED` fires at two different points in that lifecycle —
991
+ PayPal's own sandbox guide has you assert `dispute_life_cycle_stage` is `INQUIRY` on one test and
992
+ `CHARGEBACK` on the next — and the driver mapped both to `payment.disputed`. An `INQUIRY` is the
993
+ buyer and seller talking in the Resolution Center with nothing adjudicated and nothing debited, so it
994
+ is now **`payment.dispute_warning`**. `CUSTOMER.DISPUTE.UPDATED` carrying a stage past `INQUIRY` is
995
+ now `payment.disputed`: PayPal sends no dedicated "escalated to a claim" event, so that is the only
996
+ notice a row that opened as a warning ever gets. `CUSTOMER.DISPUTE.RESOLVED` now closes the dispute
997
+ with the outcome from `dispute_outcome.outcome_code` — `RESOLVED_SELLER_FAVOUR` → `won`,
998
+ `RESOLVED_BUYER_FAVOUR` → `lost`, `CANCELED_BY_BUYER` → `canceled` — and stays a `payment.updated`
999
+ for the four codes that do not name who kept the money (`RESOLVED_WITH_PAYOUT` is "the merchant _or_
1000
+ customer", `ACCEPTED`/`DENIED` are deprecated, `NONE` is "closed without any decision").
1001
+ `seller_response_due_date` now comes through as `actionableUntil`, and the dispute's `reason` is now
1002
+ spelled `reason` rather than `disputeReason`.
1003
+
1004
+ **Mollie.** Unchanged where it should be: Mollie has no fraud alert, no retrieval request and no
1005
+ inquiry, so the driver emits no `payment.dispute_warning` and the chargeback object carries no
1006
+ deadline to surface. What changed is the close — `chargeback.reversed` was a plain `payment.updated`,
1007
+ which left the row stuck at `disputed` with the revenue written off. It is now
1008
+ **`payment.dispute_closed` (`won`)**, and the reversal is read from `reversedAt` as well as from the
1009
+ event name, because the payload is a snapshot and a redelivered `chargeback.received` carries it too.
1010
+ The chargeback id is now `disputeId` rather than `chargebackId`.
1011
+
1012
+ **Razorpay.** `payment.dispute.created` fires for all five dispute **phases**, and two of them are
1013
+ not a chargeback: `fraud` is the bank's risk-analysis alert and `retrieval` is what Razorpay itself
1014
+ calls "essentially a _soft_ chargeback". Both are now **`payment.dispute_warning`**. `.won`, `.lost`
1015
+ and `.closed` are now `payment.dispute_closed` — `closed` maps to `canceled`, because Razorpay
1016
+ defines it as a fraud case that ended after you supplied details or refunded, with no verdict and
1017
+ nothing deducted. `respond_by` now comes through as `actionableUntil`, and `reason_code` and
1018
+ `amount_deducted` are on the payload as `reason` and `amountDeducted`. `.under_review` and
1019
+ `.action_required` stay `payment.updated`.
1020
+
1021
+ **Square.** The Dispute `state` now decides the event, on `dispute.created` and
1022
+ `dispute.state.updated` alike. `INQUIRY_EVIDENCE_REQUIRED` and `INQUIRY_PROCESSING` are
1023
+ **`payment.dispute_warning`**; `EVIDENCE_REQUIRED` and `PROCESSING` are `payment.disputed` (Square
1024
+ "withholds the disputed funds from the seller's Square account balance" the moment the bank notifies
1025
+ it); `WON` closes as `won` and `LOST`/`ACCEPTED` close as `lost` — accepting a dispute is Square
1026
+ returning the money to the cardholder, so it is a loss. `INQUIRY_CLOSED` names no winner and stays a
1027
+ `payment.updated`. `due_at` now comes through as `actionableUntil`, and the deprecated
1028
+ `dispute.state.changed` and `dispute.evidence.deleted` events are recognized.
1029
+
1030
+ **If you have a handler on `payment.disputed`,** it no longer fires for a PayPal inquiry, a Razorpay
1031
+ fraud or retrieval alert, or a Square inquiry. That is the fix — add `payment.dispute_warning` if you
1032
+ want to hear about them, which you do: that is the window where a refund still stops the chargeback
1033
+ being filed at all.
1034
+
1035
+ - [#6](https://github.com/DavideCarvalho/adonis-agora-payments/pull/6) [`5d2a895`](https://github.com/DavideCarvalho/adonis-agora-payments/commit/5d2a895a41736ea924c1851c71e59a29034b299f) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - Export the webhook payload types an app handler actually needs
1036
+
1037
+ `WebhookEvent` was exported; the shapes drivers normalize `event.data` onto were not. Every
1038
+ app writing a handler had to re-declare them as an inline cast — a type that agrees with
1039
+ nothing and drifts silently when a field is added. `PaymentWebhookData`,
1040
+ `SubscriptionWebhookData` and `DisputeWebhookData` are now exported from the package root,
1041
+ along with the `isPaymentWebhookData` / `isDisputeWebhookData` / `isSubscriptionWebhookData`
1042
+ guards.
1043
+
1044
+ `DisputeWebhookData` also gained `externalReference`. Gateways that build a dispute event
1045
+ out of the payment resource carry it — Asaas nests `chargeback` on the payment and spreads
1046
+ the payment's fields — and an app routing a chargeback back to its own order needs it as
1047
+ much as a `payment.succeeded` handler does. It was on the wire and hidden by the type.
1048
+
1049
+ The `make:webhook-handler` stub now uses those types (and is in English; it was writing
1050
+ Portuguese comments into other people's apps).
1051
+
1052
+ The gate that checks published stubs against the package's exports could not see any of
1053
+ this: it matched `import {` and not `import type {`, so a stub importing a type that does
1054
+ not exist passed. Fixed, and proven by mutation in both directions.
1055
+
1056
+ - [#6](https://github.com/DavideCarvalho/adonis-agora-payments/pull/6) [`5d2a895`](https://github.com/DavideCarvalho/adonis-agora-payments/commit/5d2a895a41736ea924c1851c71e59a29034b299f) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - Eight things an application integration found, all of them silent: typed webhook handlers, a boot
1057
+ refusal for the wirings that could never work, lazy service accessors, and a testing surface that
1058
+ does not need a cast.
1059
+
1060
+ **1. `event.data` is typed by the event type.** `WebhookEvent<T = unknown>` plus
1061
+ `WebhookHandler = (event: WebhookEvent) => …` forced every handler to open with
1062
+ `const data = event.data as PaymentWebhookData` — and on `payment.disputed` that cast was simply
1063
+ wrong, because a dispute carries a `DisputeWebhookData` whose `amount` and `currency` are optional
1064
+ (a Stripe early fraud warning has neither). New `WebhookEventDataMap` pairs each canonical type with
1065
+ its payload, and `defineWebhookHandler('payment.disputed', (event) => …)` infers it, so reading a
1066
+ payment field off a dispute is a compile error. The returned value is callable _and_ carries
1067
+ `type`/`handle`, so one definition works as a `billing.handlers` entry and as an
1068
+ `app/payment_handlers/` default export. A passthrough type keeps `data: unknown` — nothing
1069
+ normalized it, and pretending otherwise is the same lie one level down. `make:webhook-handler` now
1070
+ generates `WebhookEventFor<'…'>` and no cast.
1071
+
1072
+ **2. A typo in `eventType` no longer boots.** `normalizeWebhookHandlerModule` accepted any string,
1073
+ the provider did `handlers[type] = …`, and the processor skipped a lookup miss — so
1074
+ `'payment.suceeded'` registered a handler nothing ever called, the ledger recorded the delivery as
1075
+ **processed**, the route answered 200 (telling the gateway never to send it again), and the grant
1076
+ never happened. `WEBHOOK_EVENT_TYPES` and `WebhookEventType` are now exported (they existed and were
1077
+ not, so apps typed the key as a bare string), and the provider refuses at boot on an unknown type —
1078
+ and on two handlers claiming the same type, which used to overwrite in silence. The rule is
1079
+ namespace-based and stated in the error: `payment.*`/`subscription.*` is the library's namespace, so
1080
+ a type there must be canonical; a gateway event a driver could not map arrives lowercased as the
1081
+ gateway spells it (`payment_anticipated`) and is accepted as-is. `billing.passthroughEvents` covers
1082
+ the rare gateway that spells one with a dot.
1083
+
1084
+ **3. Lazy service accessors.** `getPayments()` throws until the provider's `booted()` hook runs, and
1085
+ providers registered earlier boot first — durable constructs workflow services before payments has
1086
+ set anything — so resolving in a constructor throws. `lazyPayments()`, `lazyBillingStore()` and
1087
+ `lazyPaymentsDriver(methodOrName?)` resolve on first property access, so `#payments = lazyPayments()`
1088
+ in a field initializer is safe. `getPayments()`/`getBillingStore()` keep the eager throw.
1089
+
1090
+ **4. `@adonis-agora/payments/testing` can build a manager.** It exported the fake driver and no way
1091
+ to wrap it, so apps wrote `setPayments({ driver: () => fake } as never)` — a cast that erased the
1092
+ fact the stand-in has no `invoice()` and no `assertCapability()`. New `fakePayments(driver?)` returns
1093
+ a real `PaymentsManager`; `swapPayments(manager)` / `swapBillingStore(store)` return a restore that
1094
+ works when nothing was set, which the hand-rolled save/restore could not express.
1095
+
1096
+ **5. Webhooks can be awaited in tests.** In durable mode `dispatchAll` resolves when the event is
1097
+ **accepted**, not processed, so every app pairing `billing.dispatcher` with durable wrote the same
1098
+ polling `waitFor` — and had to write its negative assertions as timed sleeps.
1099
+ `dispatcher.flushWebhooks()` (and `flushWebhooks()` from `./testing`) resolves when the accepted work
1100
+ has actually run, background in-process retries included, and throws instead of hanging when the
1101
+ events belong to a separate worker process.
1102
+
1103
+ **6. `truncateBillingTables(db)`, and `dropBillingTables` invalidates the store's memo.**
1104
+ `LucidBillingStore` caches schema creation in `#schemaReady`, so an app that dropped the tables
1105
+ between test groups left the store certain they still existed and every following query failed on a
1106
+ missing relation. The drop now tells live stores to forget; the new truncate empties the rows, which
1107
+ is what a suite actually needs — without it one test's webhook ledger deduplicates the next test's
1108
+ event, the library's idempotency working against the suite.
1109
+
1110
+ **7. `driver()` no longer ignores `config.methods`.** `#resolveName` returned `{ name: default }`
1111
+ with no method, so the routing map was never consulted and the driver came back unbound — the same
1112
+ failure the manager's own comment names one branch down: _a charge routed as Pix could come back a
1113
+ card_. Now: exactly one method routed to the default provider is bound; several is ambiguous and the
1114
+ `charge`/`createSubscription` is refused, naming the two ways to say what you meant (so an app that
1115
+ already passes `method:` everywhere is unaffected); and a method the map routes to a **different**
1116
+ provider is refused rather than sent to the wrong gateway.
1117
+
1118
+ **8. A driver with an empty webhook-credential slot no longer boots.** `if (this.#webhookToken !==
1119
+ undefined)` meant a driver with nothing configured verified nothing, and the mounted
1120
+ `POST /payments/webhook/:provider` accepted any body — including one that marks a payment paid. Every
1121
+ driver now declares `webhookVerification` (`'configured'` / `'unconfigured'` / `'unsupported'` — Efí
1122
+ and InfinitePay sign nothing at all), and the provider refuses to boot on an unconfigured one, the
1123
+ way the dashboard already refuses a missing session secret. `allowUnverifiedWebhooks: ['efi']` is the
1124
+ explicit opt-out for an app that terminates verification upstream.
1125
+
1126
+ - [#6](https://github.com/DavideCarvalho/adonis-agora-payments/pull/6) [`5d2a895`](https://github.com/DavideCarvalho/adonis-agora-payments/commit/5d2a895a41736ea924c1851c71e59a29034b299f) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - **New driver: InfinitePay** (CloudWalk, Brazil) — `payments.infinitepay()`. **It does checkout
1127
+ links and nothing else, because that is all InfinitePay documents.**
1128
+
1129
+ **Not yet run against a live account.** It is written against InfinitePay's published checkout
1130
+ documentation and covered by unit tests that stub the HTTP layer; nothing in it has touched a real
1131
+ InfinitePay account. Create a link, pay it, and confirm the `checkPayment()` step end to end before
1132
+ taking real money.
1133
+
1134
+ InfinitePay's only currently documented developer API is the Checkout / Payment Link API: create a
1135
+ link, redirect the payer, receive a per-link webhook when the payment is approved. There is no
1136
+ documented server-side charge API, no refund endpoint, no customer resource, no subscription API,
1137
+ and no list or search endpoint of any kind. So this driver implements `createCheckout`,
1138
+ `parseWebhook` and a driver-specific `checkPayment()` — and **`charge()`, `refund()`,
1139
+ `createCustomer()`, `findCustomer()`, `updateCustomer()`, `findPayment()`, `listInvoices()` and
1140
+ every subscription method throw** a `[payments]` error naming the gap. They do not return an empty
1141
+ result and they do not call an endpoint that is not there. (A v2 transactions API did exist and is
1142
+ still referenced by CloudWalk's abandoned WooCommerce and Magento plugins, but its documentation
1143
+ host is gone and its credentials were handed out by email; the driver deliberately does not build
1144
+ on an archive.)
1145
+
1146
+ ```ts
1147
+ providers: {
1148
+ infinitepay: payments.infinitepay({
1149
+ handle: env.get('INFINITEPAY_HANDLE'),
1150
+ webhookUrl: env.get('INFINITEPAY_WEBHOOK_URL'),
1151
+ }),
1152
+ }
1153
+ ```
1154
+
1155
+ **The credential is a `handle`, not a secret.** The checkout endpoint is public and takes no
1156
+ credentials at all; the handle (your InfiniteTag) is what identifies the merchant. "Checkout
1157
+ Integrado" must be enabled in the InfinitePay app first. Amounts are integer centavos on both
1158
+ sides — no conversion — and being BRL-only it takes no `currency` option. `supportedMethods` is
1159
+ `['pix', 'credit_card']`: what the hosted page settles with. **No boleto.**
1160
+
1161
+ **The webhook is unauthenticated, and the driver says so rather than implying otherwise.**
1162
+ InfinitePay documents no signature, no HMAC and no shared token for the checkout webhook — its own
1163
+ security advice is to re-check the payment instead. `parseWebhook` therefore verifies nothing: an
1164
+ event out of it means _somebody claimed a payment happened_. Confirm it with `checkPayment()`
1165
+ (`event.raw` carries the three ids it needs) before crediting anything, and put an unguessable
1166
+ segment in the `webhookUrl` you configure. `webhook_url` is sent **per link** — there is no global
1167
+ registration — so without `webhookUrl` no webhook ever arrives.
1168
+
1169
+ **`findPayment` throws; `checkPayment` replaces it.** `POST /payment_check` needs all three of
1170
+ `order_nsu`, `transaction_nsu` and `slug`, and the last two only exist once the payment does. A
1171
+ pending link is invisible to the API, so one id is not enough and the driver refuses instead of
1172
+ guessing.
1173
+
1174
+ `createCheckout` returns the gateway's `url`; since the response carries no link id, the session's
1175
+ `id`/`gatewayId` is the `order_nsu` the call sent — the only id the webhook and `checkPayment`
1176
+ speak in, and therefore where `externalReference` goes. It is the one field InfinitePay echoes
1177
+ back, and it returns on `event.data.externalReference`. `cancelUrl` is ignored (a link has one `redirect_url`), `planId`/`trialDays` are refused,
1178
+ and `metadata.items` passing a real cart must add up to `amount` or the call throws.
1179
+
1180
+ - [#6](https://github.com/DavideCarvalho/adonis-agora-payments/pull/6) [`5d2a895`](https://github.com/DavideCarvalho/adonis-agora-payments/commit/5d2a895a41736ea924c1851c71e59a29034b299f) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - Add a **Lemon Squeezy** driver — the v1 REST API on `api.lemonsqueezy.com`.
1181
+ `payments.lemonsqueezy({ apiKey, storeId, webhookSecret })`, REST over `fetch`, no new peer
1182
+ dependency.
1183
+
1184
+ Lemon Squeezy is a merchant of record: it is the seller of record on your customer's statement, it
1185
+ remits sales tax and VAT worldwide, and it pays you out. It is also the only gateway in this package
1186
+ that speaks **JSON:API** — every field lives under `data.attributes`, ids are strings at the resource
1187
+ level, and both `Accept` and `Content-Type` must be `application/vnd.api+json`, which is why this
1188
+ driver does its own `fetch` instead of going through the shared HTTP helper.
1189
+
1190
+ **`charge()` throws.** Lemon Squeezy has no server-side charge endpoint at all — as merchant of
1191
+ record it owns the payment page, and a purchase begins with a hosted checkout. `createCheckout()` is
1192
+ the entry point; `planId` is a **variant** id and is required, because a Lemon Squeezy checkout
1193
+ always sells something from the catalog.
1194
+
1195
+ **`createSubscription()` throws** for the same reason: a subscription exists once a customer
1196
+ completes a checkout for a subscription variant, never from an API call.
1197
+
1198
+ **Cancelling immediately throws.** `cancelSubscription(id)` cancels at the end of the billing period
1199
+ and the subscription runs to `ends_at`; there is no immediate-termination endpoint, and reporting one
1200
+ would leave a customer with access the caller believes was revoked. `updateSubscription()` accepts
1201
+ only a plan swap (`metadata.variantId`) and refuses `amount` (the price belongs to the variant) and
1202
+ `description` (no such field). A `taxId` on a customer throws — Lemon Squeezy has none; the buyer
1203
+ enters a tax number at checkout, so pass `metadata.taxNumber` on the checkout instead. `trialDays`
1204
+ throws too: a trial is a property of the variant.
1205
+
1206
+ **Money is already in cents** — `999` is $9.99 — so unlike the Brazilian gateways there is no decimal
1207
+ conversion in either direction. There is **no `currency` option**: a store has exactly one currency
1208
+ set in the dashboard, and rather than let you assert one that might not match, the driver reads the
1209
+ currency off what the API returns and reports no `amount` on a `CheckoutSession`, because the
1210
+ checkout response states a price but never a currency. There is also **no sandbox host and no
1211
+ sandbox flag** — test mode lives in the API key, and `meta.test_mode` is surfaced on
1212
+ `event.data.testMode`.
1213
+
1214
+ **Webhooks.** `X-Signature`, HMAC-SHA256 over the raw body in hex, compared timing-safe; the driver
1215
+ refuses to parse without a configured signing secret. `order_created` is mapped by the order's own
1216
+ `status`, not by the event name, so an order that failed or was refunded does not become
1217
+ `payment.succeeded`. Lemon Squeezy sends no event id, so a stable one is derived from the event name,
1218
+ resource type, id and `updated_at` — `updated_at` included so a second `subscription_updated` for the
1219
+ same subscription is not mistaken for a replay. `externalReference` rides on
1220
+ `checkout_data.custom.external_reference` and comes back as `meta.custom_data`; because
1221
+ `CheckoutInput` has no `externalReference` field, pass it as `metadata.externalReference`.
1222
+
1223
+ `supportedMethods` is `['undefined']` only — the API takes no payment-method argument, so the driver
1224
+ cannot promise a payment will be a card rather than PayPal.
1225
+
1226
+ Written against Lemon Squeezy's published API reference and covered by unit tests; **not yet
1227
+ exercised against a live Lemon Squeezy account**. Verify in test mode before taking real money.
1228
+
1229
+ - [#6](https://github.com/DavideCarvalho/adonis-agora-payments/pull/6) [`5d2a895`](https://github.com/DavideCarvalho/adonis-agora-payments/commit/5d2a895a41736ea924c1851c71e59a29034b299f) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - **The library creates its own tables now. There is no migration to run.**
1230
+
1231
+ Every other library in this ecosystem owns its schema — `@adonis-agora/durable` exports
1232
+ `createDurableTables`, `@adonis-agora/authz` exports `createAuthzTables`, both auto-create by
1233
+ default with an off switch. This package did not, and the cost compounded in one release: each
1234
+ column added after 0.2.0 became another migration file with hand-written `hasColumn` / `hasTable`
1235
+ guards, and adopting the library meant publishing and running **three** files before taking a single
1236
+ payment.
1237
+
1238
+ `billing.autoCreateSchema` is on by default. The store calls `createBillingTables` once on first
1239
+ use; the DDL is idempotent, so an install that already ran the 0.2.0 migration gets no-ops, and the
1240
+ cost is one round trip on the first query of a process.
1241
+
1242
+ **It is an upgrade path, not just a first-install convenience.** `CREATE TABLE IF NOT EXISTS` cannot
1243
+ carry a new column to a table that already exists, so columns added after their table shipped are
1244
+ applied as guarded `ALTER TABLE`s — which is precisely what `add_billing_external_reference` was
1245
+ doing by hand. Upgrading the package is now a deploy, not a deploy plus a migration.
1246
+
1247
+ **To own the DDL yourself** — a shared database, a team that reviews every schema change, a deploy
1248
+ that runs migrations as their own step:
1249
+
1250
+ ```ts
1251
+ // config/payments.ts
1252
+ billing: {
1253
+ autoCreateSchema: false;
1254
+ }
1255
+ ```
1256
+
1257
+ and run the migration `configure` publishes. It calls the same `createBillingTables`, so the two
1258
+ paths cannot drift. `createBillingTables`, `dropBillingTables` and `BILLING_TABLES` are exported for
1259
+ seeders and test bootstraps.
1260
+
1261
+ **Three published stubs became one.** `add_billing_external_reference` and `add_billing_disputes` are
1262
+ gone; their work is in the function. Files already published into your app keep working — they are
1263
+ your migrations now, and re-running them is harmless.
1264
+
1265
+ The gate that makes this safe is a test, not a convention: `lucid_store_schema.spec.ts` enumerates
1266
+ every public method on `LucidBillingStore` and fails when one of them queries without creating the
1267
+ schema first. A lazily-created schema that most methods wait for is worse than none — it works in
1268
+ development, where something writes before anything reads, and fails in production on whichever call
1269
+ happens to be first.
1270
+
1271
+ ***
1272
+
1273
+ Two ordering bugs in this function were found by the integration suite against a real Postgres, and
1274
+ both would have shipped: an `ALTER TABLE` running two statements above its own `CREATE TABLE`, and
1275
+ an index on a column an older install does not have until the ALTER adds it. Either one fails the
1276
+ whole call, so the schema is half-built on every boot and the only symptom is a query error
1277
+ somewhere else. The DDL is now three explicit phases — create, then late columns, then indexes —
1278
+ and a test asserts that order rather than trusting it.
1279
+
1280
+ Separately, and found by the same suite: **`billing_payments.amount` was arriving as a string.** The
1281
+ column is `BIGINT` and node-postgres returns bigints as strings rather than guess past 2^53, so a
1282
+ row read back held `'1990'` while its declared type said `number`. Adding a fee to it concatenated.
1283
+ Both `BillingPayment.amount` and `BillingDispute.amount` now coerce on read; `Number()` is safe for
1284
+ these columns specifically, because 2^53 minor units is ninety trillion reais.
1285
+
1286
+ - [#6](https://github.com/DavideCarvalho/adonis-agora-payments/pull/6) [`5d2a895`](https://github.com/DavideCarvalho/adonis-agora-payments/commit/5d2a895a41736ea924c1851c71e59a29034b299f) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - **New gateway: Mercado Pago** — `payments.mercadopago({ accessToken, currency, webhookSecret })`.
1287
+ Pix, boleto and card through the Checkout API (`POST /v1/payments`), Checkout Pro preferences for
1288
+ hosted checkout, and preapprovals for subscriptions. Plain REST, no SDK, no new peer dependency.
1289
+
1290
+ It is the first **multi-currency** driver after Stripe, and it takes `currency` the same way: as a
1291
+ required option with no default. Mercado Pago runs seven country sites (BR, AR, MX, CL, CO, PE, UY)
1292
+ and bills in whatever it is handed, so a default would be a guess at which one — and the wrong guess
1293
+ charges instead of failing. `transaction_amount` crosses the boundary as a decimal, converted with
1294
+ the currency in hand: `clp` has no cents, and dividing a Chilean amount by 100 bills 1% of it while
1295
+ the gateway accepts it happily.
1296
+
1297
+ `idempotencyKey` goes out as the `X-Idempotency-Key` header Mercado Pago requires, not as a body
1298
+ field. `externalReference` maps to `external_reference` on payments, preferences and preapprovals,
1299
+ and the driver rejects a reference outside Mercado Pago's 64-character alphanumeric shape up front
1300
+ instead of letting the gateway answer with an opaque 400.
1301
+
1302
+ **A Mercado Pago notification carries the id of the changed resource and nothing else** — no amount,
1303
+ no status, no `external_reference`. So `parseWebhook` verifies the `x-signature` HMAC (over
1304
+ `id:<data.id>;request-id:<x-request-id>;ts:<ts>;`, read out of Mercado Pago's own SDK because the
1305
+ docs page no longer publishes it) and then fetches the resource, returning the real event with its
1306
+ status, amount and your `externalReference`. That fetch is one API call per notification, and a
1307
+ failed one throws so the route answers 400 and Mercado Pago retries — reporting a status the gateway
1308
+ did not confirm would be worse than a retry. `subscription_authorized_payment` is the exception: its
1309
+ `/authorized_payments/{id}` shape is unverified against the reference, so it stays `payment.updated`
1310
+ with the id rather than mapped from guessed field names.
1311
+
1312
+ **What it refuses**, rather than fake: `listInvoices` (Mercado Pago has no invoices for a customer —
1313
+ `authorized_payments` are a subscription's charges, keyed by a different id space);
1314
+ `cancelSubscription({ atPeriodEnd: true })` (a preapproval cancel is immediate and irreversible); a
1315
+ subscription `startDate` with no end date (Mercado Pago silently ignores it); and a card charge with
1316
+ no brand in `metadata.paymentMethodId`.
1317
+
1318
+ Written against Mercado Pago's published API reference and covered by unit tests; it has not been
1319
+ run against a live Mercado Pago account. Verify in sandbox before taking real money.
1320
+
1321
+ - [#6](https://github.com/DavideCarvalho/adonis-agora-payments/pull/6) [`5d2a895`](https://github.com/DavideCarvalho/adonis-agora-payments/commit/5d2a895a41736ea924c1851c71e59a29034b299f) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - Add a Mollie driver — `payments.mollie({ apiKey, currency })`, on Mollie's v2 REST API with no SDK
1322
+ dependency.
1323
+
1324
+ Cards and a hosted checkout page in any currency, native subscriptions, and refunds:
1325
+ `supportedMethods` is `credit_card` and `undefined`, `capabilities` is
1326
+ `{ refunds: true, invoices: false, subscriptions: true }`. `currency` is required — Mollie settles
1327
+ in whatever you hand it, so a default here would be a guess at the app's country, and the wrong
1328
+ guess still takes money. Mollie's money is a decimal string in the currency's own scale (`"19.90"`
1329
+ for EUR, `"1990"` for JPY, `"1.990"` for KWD); the conversion happens only at the HTTP boundary,
1330
+ through `formatDecimal`/`fromDecimal`.
1331
+
1332
+ **The webhook is a bare id, and the fetch is what authenticates it.** Mollie POSTs
1333
+ `id=tr_5B8cwPMGnU6qLbRvo7qEZo`, form-encoded, unsigned, with no status — deliberately, so a forged
1334
+ call cannot make you think a payment succeeded. `parseWebhook` reads the id, fetches the payment
1335
+ with your API key, and builds the event entirely from what that authenticated call returned; nothing
1336
+ in the request body is trusted beyond the id. It returns a promise, which the driver contract allows
1337
+ and the mounted `/payments/webhook/:provider` route awaits, so Mollie webhooks mark payments paid
1338
+ through the normal route with no extra wiring. A failed fetch **throws**: the route answers 400 and
1339
+ Mollie retries, which is the right outcome for a payment nobody could confirm. Event ids are
1340
+ `mollie:<paymentId>:<status>`, stable per transition, so a redelivery dedupes while the next status
1341
+ still gets through. Set `webhookSecret` and the driver also verifies Mollie's next-gen
1342
+ `X-Mollie-Signature` (HMAC-SHA256 hex over the raw body) fail-closed, before fetching anything — a
1343
+ signature proves who sent the event, not what the payment is worth.
1344
+
1345
+ **`supportedMethods` is short because the shared union has no room.** `PaymentMethodName` is
1346
+ `pix | credit_card | debit_card | boleto | undefined`, so iDEAL, Bancontact, SEPA Direct Debit,
1347
+ PayPal, Klarna, Apple Pay, EPS, Przelewy24, BLIK, TWINT and the rest of Mollie's catalogue cannot be
1348
+ declared or routed by name. They still work — omit `method` and Mollie's hosted page offers whatever
1349
+ the profile has enabled, which is what `'undefined'` means. `debit_card` is absent for the same
1350
+ reason: Mollie has no debit-only method to ask for.
1351
+
1352
+ `idempotencyKey` is sent as Mollie's `Idempotency-Key` request header on `charge` and
1353
+ `createCheckout` — the only thing Mollie deduplicates on. It is not copied into `metadata`, where it
1354
+ would be echoed back and protect nothing.
1355
+
1356
+ **What it refuses, rather than reporting a success Mollie never performed:**
1357
+ `split` (Mollie Connect routes
1358
+ are absolute amounts against organization ids); `trialDays` on a subscription (Mollie has no trial —
1359
+ pass `startDate`); `planId`/`trialDays` on a checkout; a charge with no redirect URL; a `method`
1360
+ Mollie has no id for; and `listInvoices`, because Mollie's Invoices API returns the monthly invoices
1361
+ Mollie issues to _you_ for its fees, not documents you issue to your customers.
1362
+
1363
+ Every Mollie subscription endpoint is nested under a customer while the driver contract passes only
1364
+ the subscription id, so `cancelSubscription`/`updateSubscription`/`findSubscription` resolve the
1365
+ customer from the subscription they created, or from Mollie's account-wide list — and throw rather
1366
+ than guess when neither finds it. Pass `"cst_xxx/sub_xxx"` to skip the lookup.
1367
+
1368
+ Written against Mollie's published v2 API reference and covered by unit tests; it has not been
1369
+ exercised against a live Mollie account. Verify in sandbox (a `test_` key) before taking real money.
1370
+
1371
+ - [#6](https://github.com/DavideCarvalho/adonis-agora-payments/pull/6) [`5d2a895`](https://github.com/DavideCarvalho/adonis-agora-payments/commit/5d2a895a41736ea924c1851c71e59a29034b299f) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - The dispute vocabulary on the four merchant-of-record gateways — Paddle, Lemon Squeezy, Polar and
1372
+ Dodo Payments. On an MoR the gateway is the seller on the cardholder's statement, so the dispute is
1373
+ legally the gateway's and not yours. That changes what the events mean, and it turns out to change it
1374
+ differently on all four. All four keep `capabilities.disputes: false`, now declared rather than
1375
+ implied.
1376
+
1377
+ **Paddle: `chargeback_warning` is not a funds-untouched warning, and it now moves the row.** Paddle
1378
+ Billing has no `dispute.*` event at all — the whole dispute vocabulary is the `action` field on an
1379
+ adjustment. Both `chargeback` and `chargeback_warning` are now **`payment.disputed`**, which runs
1380
+ opposite to the Stripe and Adyen pre-dispute alerts on purpose: Paddle is the merchant of record and
1381
+ acts on the early warning instead of forwarding it, so "the disputed amount is refunded" the moment
1382
+ that adjustment is created. Mapping it to `payment.dispute_warning` writes nothing to the payment row
1383
+ and would leave it saying `paid` over money Paddle has already returned to the buyer. Paddle
1384
+ therefore sends **no** funds-untouched pre-dispute notification, and this driver does not invent one.
1385
+
1386
+ `chargeback_reverse` and `chargeback_warning_reverse` now close the dispute as
1387
+ **`payment.dispute_closed` with `outcome: 'won'`** — both put the amount back, and leaving the row at
1388
+ `disputed` writes off money that returned. Every dispute event now carries the adjustment id as
1389
+ `disputeId` and the adjustment's `reason`. Only `adjustment.created` is a dispute moment;
1390
+ `adjustment.updated` stays `payment.updated`, because it fires for the approval lifecycle of an
1391
+ adjustment that already exists.
1392
+
1393
+ **There is no `actionableUntil` on Paddle, and that is the finding, not a gap.** "The Paddle team
1394
+ contests chargebacks for you", and the defense "is fully automated, and additional evidence submitted
1395
+ by sellers is not required or accepted". No response window belongs to you and no adjustment field
1396
+ carries one.
1397
+
1398
+ **Dodo: the whole lifecycle now has outcomes.** `dispute.won` → `won`, `dispute.lost` and
1399
+ `dispute.accepted` → `lost` (accepting without contest is a loss, not a cancellation),
1400
+ `dispute.expired` → `expired`, `dispute.cancelled` → `canceled`. `dispute.challenged` stays
1401
+ `payment.updated` — movement inside an open dispute. A `dispute.*` event whose `payload_type` is not
1402
+ `Dispute` degrades to `payment.updated` rather than a close the processor would throw on for carrying
1403
+ no outcome. The dispute's `remarks` now comes through as `reason`.
1404
+
1405
+ `dispute.opened` stays `payment.disputed` at **every** `dispute_stage`, including `pre_dispute`.
1406
+ Dodo's reference says "Cardholder initiates dispute; funds are held" without qualifying it by stage,
1407
+ so downgrading a `pre_dispute` open to a warning would leave the row saying `paid` over money Dodo
1408
+ says it has already held. The stage travels on `event.data.disputeStage`.
1409
+
1410
+ Dodo is the one MoR here where the fight and the clock are yours — ten days, evidence through the
1411
+ Dodo dashboard — but it sends **no deadline field**, so there is no `actionableUntil`. The driver
1412
+ deliberately does not derive `created_at + 10 days`: that would put a date the library invented into
1413
+ the one field an operator is meant to trust. The rule is documented on the provider page instead.
1414
+
1415
+ **Lemon Squeezy and Polar send nothing at all, now proven rather than asserted.** Neither catalogue
1416
+ has a dispute or chargeback event; Lemon Squeezy has no `order_updated` either, so the `fraudulent`
1417
+ order status it uses for a charged-back order can never reach a webhook handler, and Polar's Order
1418
+ status enum has no charged-back value. Both gateways manage the dispute themselves and bill you the
1419
+ $15 network fee. Each driver now has a test asserting that **no** event it can receive produces
1420
+ `payment.disputed`, `payment.dispute_warning` or `payment.dispute_closed`, so a well-meaning future
1421
+ mapping cannot quietly invent one. Worth knowing on Polar: Rapid Dispute Resolution auto-refunds a
1422
+ dispute, so a chargeback genuinely can reach you as an ordinary `refund.created` — it stays
1423
+ `payment.refunded`, because that is what happened to the money.
1424
+
1425
+ **If you have a handler on `payment.disputed`,** it now also fires for a Paddle `chargeback_warning`.
1426
+ That is the fix.
1427
+
1428
+ - [#6](https://github.com/DavideCarvalho/adonis-agora-payments/pull/6) [`5d2a895`](https://github.com/DavideCarvalho/adonis-agora-payments/commit/5d2a895a41736ea924c1851c71e59a29034b299f) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - Revenue was reported gross with nothing saying so, and a partial refund was invisible in it.
1429
+
1430
+ `billing_payments.refunded_amount` landed in the previous release precisely so a PARTIAL refund
1431
+ could be recorded without mangling `amount` or `status`: a R$10 refund on a R$100 charge leaves the
1432
+ row `paid` at `amount: 10000, refundedAmount: 1000`, and the net is one subtraction. But every
1433
+ aggregate went on summing `amount` alone. `revenue()` and `billingOverview`'s revenue metric counted
1434
+ that charge at its full R$100, the console printed it under the single word **Revenue**, and nothing
1435
+ on the screen admitted the number was gross. Money that had already gone back to the cardholder was
1436
+ being reported as earned.
1437
+
1438
+ **`revenue()` is unchanged and still gross.** It was the only revenue figure this library had for
1439
+ two releases and apps read it; redefining it in a release that already carries breaking changes
1440
+ would have moved numbers on other people's screens with no error to announce it. Gross and net are
1441
+ both legitimate — gross is what you collected, net is what you kept — so the fix publishes both.
1442
+
1443
+ **`store.netRevenue({ from, to })` is new**, on the `BillingStore` SPI and on BOTH implementations
1444
+ (`LucidBillingStore` and the `InMemoryBillingStore` in `/testing`). It takes exactly the rows and
1445
+ the window `revenue()` takes — `status = 'paid'`, windowed on `paid_at` — and sums
1446
+ `amount - COALESCE(refunded_amount, 0)` instead of `amount`. Integer minor units throughout, never a
1447
+ division. Two details it has to get right, both proven against real Postgres: `refunded_amount` is
1448
+ `NULL` on every row written before the column existed and `amount - NULL` is `NULL` in SQL, which
1449
+ `SUM` spreads across the whole window — so one legacy row would report zero net revenue for an
1450
+ install that took a million; and a `BIGINT` sum arrives from node-postgres as a **string**, so it is
1451
+ consumed through `Number` like every other amount in the store. On an install whose table predates
1452
+ the column, `netRevenue()` answers exactly what `revenue()` does, because no refund was ever
1453
+ recorded to subtract.
1454
+
1455
+ **`billingOverview` now returns two money metrics**, `revenue` (label `Revenue, gross (cents)`) and
1456
+ `net_revenue` (label `Revenue, net of refunds (cents)`). The `revenue` key keeps its key, its
1457
+ position and its value; only its label gained the word "gross". If you render the metric list by
1458
+ key, add `net_revenue` to whatever you treat as money — a money metric rendered as a plain count is
1459
+ the figure wrong by 100×.
1460
+
1461
+ **The console shows both**, as **Revenue (gross)** ("Paid payments settled in this window. Refunds
1462
+ NOT subtracted.") and **Revenue (net)** ("The same payments, minus what was refunded. This is what
1463
+ you kept."), each labelled in words so neither can be read as the other.
1464
+
1465
+ Nothing else that reports money inherited the blindness: `billingHealth` and `payments:sync` report
1466
+ counts, not amounts, and the payments list and per-payment view already showed `refundedAmount`
1467
+ beside the charge. `meteredBill`'s `total` is a projected overage charge rather than settled
1468
+ revenue, so refunds do not apply to it.
1469
+
1470
+ - [#6](https://github.com/DavideCarvalho/adonis-agora-payments/pull/6) [`5d2a895`](https://github.com/DavideCarvalho/adonis-agora-payments/commit/5d2a895a41736ea924c1851c71e59a29034b299f) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - Add a **Paddle** driver — Paddle Billing (the v2 API on `api.paddle.com`), not the deprecated
1471
+ Paddle Classic vendors API. `payments.paddle({ apiKey, currency, sandbox, productId, webhookSecret })`,
1472
+ REST over `fetch`, no new peer dependency.
1473
+
1474
+ Paddle is a merchant of record: it is the seller on your customer's statement, it remits sales tax
1475
+ and VAT worldwide, and it pays you out. That is why this driver refuses more than it implements, and
1476
+ the refusals are the point.
1477
+
1478
+ **`charge()` throws.** Paddle has no endpoint that takes money — every payment is collected by
1479
+ Paddle Checkout or a Paddle-issued invoice. The closest thing, `POST /transactions`, creates an
1480
+ _unpaid_ record whose only route to payment is the checkout URL it returns, which is exactly what
1481
+ `createCheckout()` does. Returning a `Payment` from `charge()` would advertise a capture that never
1482
+ happened, so `createCheckout()` is the entry point and `charge()` says so.
1483
+
1484
+ **`createSubscription()` throws.** Paddle's API has no create-subscription endpoint at all: Paddle
1485
+ creates one when a customer completes a checkout for a recurring price. Call
1486
+ `createCheckout({ planId })` and read the id off the `subscription.created` webhook.
1487
+
1488
+ **`updateSubscription()` refuses `amount` and `description`.** Paddle has no editable amount on a
1489
+ subscription — you swap `items[].price_id` with a `proration_billing_mode`, which the shared input
1490
+ cannot express — and no description field. Only `metadata` is written, onto `custom_data`. Likewise
1491
+ `trialDays` on a checkout throws (a Paddle trial lives on the price), a `taxId` on a customer throws
1492
+ (Paddle keeps tax ids on a _business_), and a partial refund throws when the transaction has more
1493
+ than one line item, because Paddle refunds per line item and an `amount` alone cannot address one.
1494
+
1495
+ **Money.** Paddle sends and receives amounts as **strings in the smallest unit** with the currency
1496
+ named separately — `"1990"` + `"USD"`. The package's integer cents convert at the driver boundary
1497
+ and nowhere else. `currency` is **required**, like Stripe's: Paddle bills in whatever the transaction
1498
+ names, so a default would be a guess at the app's country and a wrong guess succeeds silently.
1499
+
1500
+ **Webhooks.** `Paddle-Signature: ts=…;h1=…`, HMAC-SHA256 over `` `${ts}:${rawBody}` `` in hex,
1501
+ compared timing-safe against the notification setting secret. Paddle always signs, so the driver
1502
+ refuses to parse without a configured secret rather than trusting the body. The timestamp check is
1503
+ opt-in via `webhookMaxAgeSeconds` — Paddle documents it as optional and its own SDK's five-second
1504
+ window would discard real events on a retry or a skewed clock. `externalReference` rides on
1505
+ `custom_data.external_reference` and is read back onto `event.data.externalReference`; because
1506
+ `CheckoutInput` has no `externalReference` field, pass it as `metadata.externalReference`.
1507
+
1508
+ `supportedMethods` is `['undefined']` only — the transaction API takes no payment-method argument, so
1509
+ the driver cannot promise a charge will be a card, and routing `credit_card` here is correctly
1510
+ refused by the manager.
1511
+
1512
+ Written against Paddle's published Billing API reference and covered by unit tests; **not yet
1513
+ exercised against a live Paddle account**. Verify in the sandbox before taking real money.
1514
+
1515
+ - [#6](https://github.com/DavideCarvalho/adonis-agora-payments/pull/6) [`5d2a895`](https://github.com/DavideCarvalho/adonis-agora-payments/commit/5d2a895a41736ea924c1851c71e59a29034b299f) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - **New driver: Pagar.me** (Stone's Brazilian gateway, Core API v5) — `payments.pagarme()`.
1516
+
1517
+ **Not yet run against a live account.** It is written against Pagar.me's published v5 API reference
1518
+ and covered by unit tests that stub the HTTP layer; nothing in it has touched a real Pagar.me
1519
+ account. Verify your flow in test mode (an `sk_test_…` key) before taking real money.
1520
+
1521
+ Pix, boleto, credit and debit card, native subscriptions, payment links and marketplace splits,
1522
+ over `https://api.pagar.me/core/v5` via `fetch` (no SDK dependency). Authenticated with HTTP
1523
+ Basic: the secret key is the username and the password is empty. `PAGARME_SECRET_KEY` is the env
1524
+ fallback; there is no sandbox host, because a `sk_test_…` key is what puts the account in test
1525
+ mode.
1526
+
1527
+ ```ts
1528
+ providers: {
1529
+ pagarme: payments.pagarme({ secretKey: env.get('PAGARME_SECRET_KEY') }),
1530
+ }
1531
+ ```
1532
+
1533
+ **Money stays in centavos.** Asaas, AbacatePay and Woovi take decimal reais, so those drivers
1534
+ divide by 100 — Pagar.me's `amount` fields are already integer centavos, the same unit as this
1535
+ library's `Money`, and the driver does no conversion at all. R$ 19,90 is `1990` on both sides,
1536
+ including a `fixedValue` split share. Being BRL-only, it takes no `currency` option.
1537
+
1538
+ **A charge is an order carrying one payment.** `POST /orders` with a single `items[]` line and one
1539
+ `payments[]` entry; the first charge the gateway creates is what comes back as the `Payment`, and
1540
+ that `ch_…` id is what `findPayment` and the `charge.*` webhooks then talk about.
1541
+ `externalReference` is sent as the order's `code` _and_ as `metadata.external_reference` — order
1542
+ metadata is repeated on every charge, which is what makes the reference survive into the webhook.
1543
+ On a checkout it becomes the payment link's `order_code`, which Pagar.me stamps as the `code` of
1544
+ every order the link produces; either way it comes back out on `event.data.externalReference`.
1545
+
1546
+ **Webhooks are authenticated by Basic credentials, or not at all.** Pagar.me signs nothing; the
1547
+ dashboard offers optional HTTP Basic credentials on the webhook endpoint and no HMAC. Set
1548
+ `webhookUser`/`webhookPassword` (or `PAGARME_WEBHOOK_USER`/`PAGARME_WEBHOOK_PASSWORD`) and the
1549
+ driver rejects, timing-safe, any request that does not carry them.
1550
+
1551
+ Three places where the driver refuses instead of pretending:
1552
+
1553
+ - **`updateSubscription` throws.** A Pagar.me subscription has no amount or description of its own
1554
+ — the price lives on its _items_, changed through the subscription-item sub-resource. No request
1555
+ means what the contract's `{ amount, description }` means, so it refuses rather than returning a
1556
+ subscription the gateway never changed. Cancel and recreate, or edit the item.
1557
+ - **`supportedMethods` excludes `'undefined'`.** An order must name its `payment_method`, so "let
1558
+ the customer choose" exists only on a payment link, never on a charge. A charge with no `method`
1559
+ is refused rather than left to the account's dashboard defaults.
1560
+ - **A split rule naming both `percentualValue` and `fixedValue` throws.** A Pagar.me rule carries
1561
+ exactly one `type`, so there is no honest mapping for both.
1562
+
1563
+ `cancelSubscription` cancels immediately — there is no period-end flag in the API. `atPeriodEnd:
1564
+ true` sends `cancel_pending_invoices: false`, which leaves the current cycle's already-issued
1565
+ invoices payable; it does not keep the subscription running. `createCheckout` creates a payment
1566
+ link and ignores `cancelUrl`, because a link has only `flow_settings.success_url`. Chargebacks
1567
+ (`charge.chargedback`) normalize to `payment.updated`, since the contract has no dispute event —
1568
+ `event.raw.type` still names the original.
1569
+
1570
+ - [#6](https://github.com/DavideCarvalho/adonis-agora-payments/pull/6) [`5d2a895`](https://github.com/DavideCarvalho/adonis-agora-payments/commit/5d2a895a41736ea924c1851c71e59a29034b299f) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - **PagBank (PagSeguro)** driver — the Orders API v4 (`api.pagseguro.com/orders`), Bearer
1571
+ token, Pix, credit and debit card, and boleto. BRL-only, so like the other Brazilian
1572
+ gateways it takes no `currency` option; there is nothing to choose.
1573
+
1574
+ ```ts
1575
+ providers: {
1576
+ pagbank: payments.pagbank({
1577
+ token: env.get('PAGBANK_TOKEN'),
1578
+ notificationUrls: [`${env.get('APP_URL')}/payments/webhook/pagbank`],
1579
+ }),
1580
+ }
1581
+ ```
1582
+
1583
+ Money is **integer centavos on both sides**. PagBank's `amount.value` is already the
1584
+ currency's smallest unit, so unlike the Asaas and Woovi drivers this one converts nothing —
1585
+ `1990` in your code is `1990` on the wire. Pix uses PagBank's current flow, where the charge
1586
+ carries `payment_method.type: 'PIX'` and the BR Code comes back on `charges[].qr_code.text`;
1587
+ the older order-level `qr_codes` shape is still read when one arrives.
1588
+
1589
+ **Which id it reconciles on.** PagBank gives the same money an order id (`ORDE_…`) and a
1590
+ charge id (`CHAR_…`), and the webhook delivers the _order_. The driver therefore uses the
1591
+ order id as `gatewayId` everywhere — keying on the charge id would file the charge you
1592
+ created and the webhook confirming it under two different ids, and nothing would reconcile.
1593
+ `refund()` and `findPayment()` take either id and resolve the other themselves.
1594
+ `externalReference` is the order's `reference_id`, echoed on every notification, and
1595
+ `idempotencyKey` goes out as the `x-idempotency-key` header PagBank honours for 48 hours.
1596
+
1597
+ **The webhook check is stated plainly rather than implied.** PagBank's `x-authenticity-token`
1598
+ is `sha256("<your API token>-<raw body>")`. The driver verifies it timing-safe on every
1599
+ notification and rejects anything missing, wrong, or computed over altered bytes — but it is
1600
+ **not an HMAC**. It is a secret prefix hashed with SHA-256, the textbook setup for a
1601
+ length-extension forgery (what saves it in practice is that the forged body carries binary
1602
+ padding in the middle, which `JSON.parse` refuses), and the secret is the API credential
1603
+ itself, so rotating one rotates the other. The provider page says so in as many words.
1604
+ `verifyWebhooks: false` exists for exactly one reason — PagBank's sandbox does not always
1605
+ send the header — and it leaves the endpoint open to anything that can reach it.
1606
+
1607
+ **What it refuses,** loudly, with a `[payments]` message naming what to use instead:
1608
+ subscriptions (that is PagBank's separate Assinaturas API), every customer operation (the
1609
+ Orders API has no customer resource — the payer travels inline, and `customer: { name,
1610
+ email, taxId }` is required on every charge), and the legacy form-encoded
1611
+ `notificationCode` notifications, which are a different API with no authenticity token at
1612
+ all. `listInvoices` returns `[]`: orders are indexed by their own id, not by customer.
1613
+
1614
+ Written against PagBank's published API reference and covered by unit tests; **it has never
1615
+ talked to a live PagBank account.** Verify in the sandbox before taking real money.
1616
+
1617
+ - [#6](https://github.com/DavideCarvalho/adonis-agora-payments/pull/6) [`5d2a895`](https://github.com/DavideCarvalho/adonis-agora-payments/commit/5d2a895a41736ea924c1851c71e59a29034b299f) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - Six ways the billing tables recorded the wrong amount of money — a won dispute vanishing from
1618
+ revenue, a partial refund never recorded at all, and a reconcile that moved historic revenue into
1619
+ the current month. Every one of them was silent.
1620
+
1621
+ **`savePayment` no longer erases `paid_at` on a write that omits it.** The column was written
1622
+ unconditionally (`payment.paidAt ? ... : null`), while three of the processor's own handlers —
1623
+ `payment.refunded`, `payment.disputed`, `payment.dispute_closed` — call it without one, because a
1624
+ refund or dispute payload carries no settlement date. `revenue()` filters
1625
+ `status = 'paid' AND paid_at >= from AND paid_at < to`, so a dispute closed as **won** restored
1626
+ `status = 'paid'` with `paid_at = NULL` and the recovered money dropped out of every windowed
1627
+ revenue figure, permanently — `paid_at` is the only record of when a charge landed. Absent now means
1628
+ "not stated" and leaves the stored value alone, exactly like `externalReference`; pass `null` to
1629
+ clear it. `refundedAmount` follows the same rule. **If you have run a dispute to a won close, or a
1630
+ refund, on an earlier version, those rows have `paid_at = NULL` and are missing from your monthly
1631
+ revenue** — `payments:sync` can repair them from the gateway, or set the column from
1632
+ `billing_payments.payload` by hand.
1633
+
1634
+ **New column: `billing_payments.refunded_amount`** (integer minor units, same units as `amount`,
1635
+ nullable). Added by `createBillingTables`' post-ship `ALTER` phase, so an existing install picks it
1636
+ up on the next boot with no new migration; it is also on the read side, as
1637
+ `PaymentListItem.refundedAmount`. Net revenue for a row is `amount - refunded_amount` — never a
1638
+ division.
1639
+
1640
+ **`payment.updated` has a built-in sync.** It used to reach `default: return Promise.resolve()`, so
1641
+ the ledger row went to `processed` and nothing happened. On Asaas that is where a **partial refund**
1642
+ arrives (`PAYMENT_PARTIALLY_REFUNDED`) — deliberately, because `payment.refunded` writes the whole
1643
+ charge off — along with a deleted charge, a restored one, a denied refund and an undone cash
1644
+ receipt. It now keeps status, amount, refunded amount and settlement date current on a row that
1645
+ already exists, and publishes the `payment.updated` diagnostic that was declared and published by
1646
+ nothing. It never creates a row, and it never moves one out of `disputed`: only
1647
+ `payment.dispute_closed`, which carries an outcome, resolves a chargeback. `PaymentWebhookData`
1648
+ gains optional `status`, `paidAt` and `refundedAmount` for drivers that can normalize them; the
1649
+ Asaas driver now does, summing only refunds Asaas reports as settled.
1650
+
1651
+ **`payment.succeeded` prefers the gateway's own settlement date** (`data.paidAt`) over the webhook's
1652
+ arrival time, so a redelivered or replayed confirmation is still filed in the month it was earned.
1653
+
1654
+ **Asaas webhooks are deduplicated on Asaas' event id.** `parseWebhook` synthesized
1655
+ `` `${event}-${paymentId}` ``, which is a (payment, event-type) identity rather than an event
1656
+ identity — so the SECOND `PAYMENT_UPDATED` for a payment was silently discarded by the idempotency
1657
+ ledger as a replay of the first, and a partial refund arrives as exactly that type. Its
1658
+ `Math.random()` fallback also disabled deduplication entirely for any payload naming neither a
1659
+ payment nor a subscription. The driver now reads the `id` Asaas sends on the notification body, and
1660
+ falls back — only when there is none — to a SHA-256 digest of the raw body, which is deterministic:
1661
+ a genuine redelivery still deduplicates, two different notifications no longer collide.
1662
+
1663
+ **Asaas `charge()` honours `idempotencyKey` instead of silently ignoring it.** Every other Asaas
1664
+ method refuses the key loudly ("Asaas has no idempotency mechanism … deduplicate before you call,
1665
+ e.g. by looking the record up by `externalReference` first"); `charge()` alone quietly repurposed it
1666
+ as an `externalReference` fallback, so an app passing `idempotencyKey: order.id` on the one call
1667
+ that moves money got no protection and no warning. The driver now performs that documented lookup
1668
+ itself: with a key, it searches `GET /payments?externalReference=…&customer=…` first and returns the
1669
+ existing charge — Pix code attached — instead of creating a second one. A deduplicated call emits no
1670
+ fiscal invoice and publishes no `charge.created` diagnostic, because nothing was charged. It is one
1671
+ request, not a lock: two concurrent calls with the same key can still both create.
1672
+
1673
+ **Asaas `listInvoices` pages.** It issued `GET /payments?customer=…` with no `limit`/`offset` and no
1674
+ loop, while Asaas pages that endpoint — so `payments:sync` printed a confident "N invoice(s) synced"
1675
+ covering only the newest page and left older charges unreconciled with no way to tell. It now
1676
+ follows `hasMore` to the end, and throws rather than truncating if a gateway never stops.
1677
+
1678
+ **`payments:sync` reconciles in both directions, and stops inventing settlement dates.** It wrote
1679
+ `status: 'paid'` and counted everything else as "skipped (non-paid)", so a local row saying `paid`
1680
+ while the gateway said refunded or charged back could never be corrected — the exact drift a
1681
+ gateway-is-truth reconcile exists for. It also stamped `paidAt: new Date()`, so running it in two
1682
+ months counted the same charge in both. It now asks the gateway's payment resource (which speaks
1683
+ `BillingStatus`, unlike `Invoice.status`, whose vocabulary has no `refunded`) and writes that status
1684
+ in either direction, using the gateway's own settlement date or none at all, and never overwriting a
1685
+ `paid_at` already recorded. **One local state is never overwritten from the gateway: `disputed`** —
1686
+ the gateway's payment resource usually still reports a disputed charge as received, so reconciling it
1687
+ back to `paid` would re-count money the bank has pulled back. Those rows are reported and left alone.
1688
+ The per-customer output now reads `reconciled / already current / undecidable` rather than
1689
+ `synced / skipped`.
1690
+
1691
+ - [#6](https://github.com/DavideCarvalho/adonis-agora-payments/pull/6) [`5d2a895`](https://github.com/DavideCarvalho/adonis-agora-payments/commit/5d2a895a41736ea924c1851c71e59a29034b299f) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - Add the browser-facing payment status endpoint, plus a new `@adonis-agora/payments-react` package with the `usePaymentStatus` hook that polls it.
1692
+
1693
+ A Pix QR code (or a boleto) is not paid until the gateway's webhook confirms it, seconds to days later. Every app that takes Pix hand-writes the same polling loop, and most write it without backoff, without a stop condition, and without cleanup on unmount.
1694
+
1695
+ **Server.** A new opt-in config (`config/payments_client.ts`) and provider (`@adonis-agora/payments/payments_client_provider`) mounting one route:
1696
+
1697
+ ```
1698
+ GET /payments/client/status?reference=<reference>
1699
+ -> { status, amount, currency, paidAt }
1700
+ ```
1701
+
1702
+ Disabled by default — unlike the dashboard, this endpoint is reachable by every logged-in browser. The response is deliberately four fields: no payload, no customer, no gateway ids.
1703
+
1704
+ Ownership is enforced ahead of the lookup. `authorize` decides whether the request may exist (default: a user resolved structurally off `ctx.auth`, so authkit, `@adonisjs/auth` and a custom guard all work and none is a dependency), `owner` says who is asking, and `authorizeReference` decides whether that caller may see this payment. The default `authorizeReference` checks the payment's gateway customer against the one the owner holds in `billing_customers`; an app that never recorded that mapping is **denied** with a message telling it to use `ensureCustomer({ store, owner })` or supply its own hook — never silently allowed. Every answer carries `Cache-Control: no-store` and costs one indexed read, with no gateway call.
1705
+
1706
+ **Browser.** `usePaymentStatus(reference)` polls with backoff (2s, growing, capped at 30s) and stops on a terminal status, on unmount, while the tab is hidden (resuming on focus), and on a `401`/`403`. Errors are surfaced, never thrown. No context provider and no data-fetching dependency.
1707
+
1708
+ It does **not** wrap any gateway's card SDK — Stripe, Mercado Pago and Adyen ship their own.
1709
+
1710
+ See the new [Client polling](https://agora.goflip.ai/docs/payments/client) page, including how to write your own `authorize`/`owner`/`authorizeReference` and how to build the endpoint from scratch as an ordinary controller.
1711
+
1712
+ - [#5](https://github.com/DavideCarvalho/adonis-agora-payments/pull/5) [`1db1891`](https://github.com/DavideCarvalho/adonis-agora-payments/commit/1db1891cad2481c468169de8398ffb307befb01a) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - Add `@adonis-agora/payments-dashboard`, the billing console for `@adonis-agora/payments`.
1713
+
1714
+ A React SPA (Vite + Tailwind + TanStack Query) with three read-only screens — an overview of
1715
+ `billingOverview()`'s revenue/subscription/usage aggregates over a selectable window, a payment list,
1716
+ and the webhook-event ledger that surfaces `failed` rows with the handler error that caused them.
1717
+ Everything is a `BillingStore` read; the console makes no gateway calls and has no control actions.
1718
+
1719
+ The Adonis half ships inside `@adonis-agora/payments` and mirrors `@adonis-agora/durable`'s dashboard:
1720
+ a `dashboard_provider` that serves the built bundle from disk (never importing the SPA package), the
1721
+ `BASE_PLACEHOLDER` rewrite that lets one bundle mount at any path, framework-light JSON handlers, and
1722
+ the same optional `dashboardAuth` session gate. New entry points: `@adonis-agora/payments/dashboard`
1723
+ and `@adonis-agora/payments/dashboard_provider`, plus a published `config/payments_dashboard.ts`.
1724
+
1725
+ The dashboard is off-able entirely (`enabled: false` registers no routes at all) and defaults to the
1726
+ same safe auth posture as the durable console: open outside production, and in production a bearer
1727
+ token equal to `PAYMENTS_DASHBOARD_TOKEN`, denying when it is unset.
1728
+
1729
+ Also adds two narrow reads to the `BillingStore` contract — `listPayments(query)` and
1730
+ `listWebhookEvents(query)` — implemented in both `LucidBillingStore` and `InMemoryBillingStore`. They
1731
+ return a normalized plain shape rather than the implementation's row type, so a reader never depends
1732
+ on Lucid.
1733
+
1734
+ - [#6](https://github.com/DavideCarvalho/adonis-agora-payments/pull/6) [`5d2a895`](https://github.com/DavideCarvalho/adonis-agora-payments/commit/5d2a895a41736ea924c1851c71e59a29034b299f) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - **New gateway: PayPal** — `payments.paypal({ clientId, clientSecret, currency, sandbox, webhookId })`.
1735
+ Orders v2 for money in, Payments v2 for refunds, Subscriptions v1 for recurring billing, over OAuth2
1736
+ client credentials. Plain REST, no SDK, no new peer dependency.
1737
+
1738
+ Multi-currency, so `currency` is required with no default, like Stripe's. Amounts go out as the
1739
+ decimal string PayPal wants with the decimal places that currency allows — `"19.90"` for USD,
1740
+ `"1990"` for JPY. HUF and TWD are refused when the amount is not a whole unit: PayPal rejects
1741
+ decimals for both even though ISO 4217 gives them two minor units. The OAuth token is cached for
1742
+ exactly as long as PayPal's own `expires_in` says and no longer, so a long-running process cannot
1743
+ hold a token past its life and start 401ing an hour into a deploy.
1744
+
1745
+ **PayPal is a wallet, so `charge()` refuses more than it accepts.** A payment normally needs the
1746
+ payer to approve it on paypal.com, which no server call can stand in for: `createCheckout` is the
1747
+ entry point and returns the approval URL. `charge()` works only against an already-vaulted payment
1748
+ method (`paymentMethodId` = a Vault v3 token id) and throws otherwise, rather than fake a payment
1749
+ nobody approved. It also requires an `idempotencyKey` — it becomes the `PayPal-Request-Id` header
1750
+ PayPal documents as mandatory there, and generating one internally would defeat the point.
1751
+
1752
+ **Webhook verification is an API call.** PayPal has no local HMAC: you POST the headers and the event
1753
+ to `/v1/notifications/verify-webhook-signature`, so every webhook the mounted route handles costs a
1754
+ round trip to PayPal, plus an OAuth call whenever the cached token has expired. Worth knowing before
1755
+ pointing a high-volume webhook at it. With no `webhookId` configured there is nothing to verify
1756
+ against and the event is parsed unverified, the same rule the other drivers use.
1757
+
1758
+ `externalReference` maps to `custom_id` (not `reference_id`, which does not survive onto the capture)
1759
+ and is read back out of `custom_id` on `PAYMENT.CAPTURE.*` events and `custom` on the
1760
+ `PAYMENT.SALE.*` events a subscription's charges arrive as.
1761
+
1762
+ **What else it refuses**: the customer methods (PayPal has no customer resource — the id is one you
1763
+ choose, created as a side effect of vaulting); `listInvoices` (no customer filter exists);
1764
+ `createSubscription` with `amount`/`cycle`/`trialDays` (all three live on the PayPal plan, so
1765
+ accepting them would report a price the gateway never charges); `cancelSubscription({ atPeriodEnd })`;
1766
+ and a subscription `description`. `updateSubscription({ amount })` does reprice at the gateway, and
1767
+ reads the subscription first so it patches the REGULAR billing cycle rather than a plan's trial.
1768
+
1769
+ Written against PayPal's published API reference and covered by unit tests; it has not been run
1770
+ against a live PayPal account. Verify in sandbox before taking real money.
1771
+
1772
+ - [#5](https://github.com/DavideCarvalho/adonis-agora-payments/pull/5) [`1db1891`](https://github.com/DavideCarvalho/adonis-agora-payments/commit/1db1891cad2481c468169de8398ffb307befb01a) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - Rename the Pix fields on `Payment` to say what they actually hold: `pixQrCodeImage` and `pixCode`.
1773
+
1774
+ The two old names described the wrong things. `pixQrCode` never held a code — it held a base64 PNG
1775
+ of the QR image, so `<img src={payment.pixQrCode}>` was the only correct use of a field that read
1776
+ like a string you could copy. The value the customer _does_ copy and paste, the BR Code (EMV
1777
+ payload), was hidden behind `pixCopiaECola`, a Portuguese name in an otherwise English API.
1778
+
1779
+ The normalized `Payment` now exposes:
1780
+
1781
+ - `pixQrCodeImage` — base64-encoded PNG of the QR code, for rendering.
1782
+ - `pixCode` — the BR Code / EMV payload, the copy-paste string.
1783
+
1784
+ **Nothing breaks.** `pixQrCode` and `pixCopiaECola` remain on the type as `@deprecated` optional
1785
+ fields, and every driver (Asaas, Woovi, AbacatePay) populates both the new and the old name with the
1786
+ same value. Existing code keeps working; move to the new names at your own pace.
1787
+
1788
+ - [#6](https://github.com/DavideCarvalho/adonis-agora-payments/pull/6) [`5d2a895`](https://github.com/DavideCarvalho/adonis-agora-payments/commit/5d2a895a41736ea924c1851c71e59a29034b299f) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - Add the Polar driver (`payments.polar()`) — merchant-of-record billing for software, over
1789
+ the REST API (spec version `2026-04`) with no SDK dependency.
1790
+
1791
+ Polar is the legal seller of record and handles sales tax and VAT worldwide. That is a
1792
+ business arrangement the library does not model, but it decides the shape of the driver: a
1793
+ merchant of record controls the purchase surface, so **Polar has no direct charge
1794
+ endpoint**. Prices live on products you create in Polar, and money moves through Polar's
1795
+ own checkout.
1796
+
1797
+ **Supports** cards (plus the wallets and local methods Polar enables per country) through
1798
+ `createCheckout()`, orders as the canonical `Payment`, full and partial refunds,
1799
+ subscriptions, and orders-as-invoices. Amounts are already integers in the currency's
1800
+ smallest unit, so nothing is converted. `currency` is required — Polar bills in whatever
1801
+ you hand it, and a default would be a guess at the app's country that succeeds when it is
1802
+ wrong. Requests pin `Polar-Version: 2026-04` so a future default flip cannot move the wire
1803
+ format underneath you.
1804
+
1805
+ **Refuses**, rather than reporting a change Polar never made:
1806
+
1807
+ - `charge()` — there is no charge endpoint. Polar's two-step off-session route
1808
+ (`POST /v1/orders/` then `/finalize`) is behind a preview feature flag, is paid-plan
1809
+ only and needs a saved payment method, so the driver does not present it as the general
1810
+ case. The error points at `createCheckout()`.
1811
+ - `createSubscription({ amount | cycle })` — the price and interval belong to the product;
1812
+ dropping an `amount` would bill a figure nobody chose.
1813
+ - `createSubscription({ trialDays })` — Polar sets trials on the checkout, not on the
1814
+ subscriptions endpoint.
1815
+ - `createSubscription({ card })` — there is no tokenized-card input.
1816
+ - `updateSubscription({ amount | description })` — a Polar subscription has neither field.
1817
+ A plan switch goes through `metadata.productId`.
1818
+
1819
+ `createSubscription()` itself calls `POST /v1/subscriptions/`, which Polar allows **only
1820
+ for free products**; a paid plan has to go through checkout.
1821
+
1822
+ `externalReference` maps to `metadata.external_reference`, which Polar copies from the
1823
+ checkout onto the order and the subscription — the only thing tying an `order.paid` back to
1824
+ your own row. `refund()` with no amount reads the order's `refundable_amount` rather than
1825
+ its total, because Polar's refund amount is the net figure and it refunds tax alongside it.
1826
+
1827
+ Webhooks are verified against the Standard Webhooks scheme with a ±5 minute replay window.
1828
+ One trap worth naming: Polar's HMAC key is the **raw UTF-8 bytes of the secret**, `whsec_`
1829
+ prefix included — not the base64-decoded bytes the spec's default derivation (and Dodo
1830
+ Payments) uses. Its own SDK base64-encodes the secret before handing it to the reference
1831
+ library, which decodes it right back.
1832
+
1833
+ Written against the published API reference and covered by unit tests; **not yet exercised
1834
+ against a live Polar account.** Verify in the sandbox before taking real money.
1835
+
1836
+ - [#6](https://github.com/DavideCarvalho/adonis-agora-payments/pull/6) [`5d2a895`](https://github.com/DavideCarvalho/adonis-agora-payments/commit/5d2a895a41736ea924c1851c71e59a29034b299f) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - A won dispute now stops counting as lost revenue, and the two dispute events reach the diagnostics
1837
+ bus they were declared on.
1838
+
1839
+ `payment.dispute_warning` and `payment.dispute_closed` existed on the bus with payload types and were
1840
+ published by **nothing at all** — the drivers emitted them, the processor's switch had no case, so
1841
+ they fell to the no-op branch. That is the same shape as the bugs this package has spent a release
1842
+ removing: declared, typed, documented, wired to nothing.
1843
+
1844
+ - **`payment.dispute_closed` with `outcome: 'won'` puts the payment row back to `paid`.** A
1845
+ chargeback moves it to `disputed` and it stayed there forever; `revenue()` sums rows that are
1846
+ `paid`, so money that came back was written off permanently. Only `won` moves it. `lost` and
1847
+ `expired` are money that is gone, and `canceled` — the cardholder withdrawing — is deliberately
1848
+ **not** treated as a win: on Stripe a withdrawn dispute still has to be closed in your favour with
1849
+ evidence, so booking it would count revenue the acquirer has not returned. The row keeps its own
1850
+ amount, because a dispute's amount can differ from the charge's.
1851
+ - **A close with no `outcome` is refused.** It throws rather than defaulting, because defaulting
1852
+ would report a result the gateway never sent — a driver that cannot read the outcome is supposed to
1853
+ emit `payment.updated` instead, which both Stripe and Adyen do.
1854
+ - **`payment.dispute_warning` writes nothing.** No money has moved, so a payment that says `paid` is
1855
+ telling the truth. It publishes on the bus, carrying `reason` and `actionableUntil`, so a
1856
+ subscriber can put the alert in front of somebody while a refund still prevents the chargeback.
1857
+ - **`DisputeWebhookData` and `isDisputeWebhookData`** are exported. The guard is looser than the
1858
+ payment one on purpose: Stripe's early fraud warning object carries no amount or currency at all,
1859
+ and refusing it for that would throw away the earliest warning the library gets.
1860
+
1861
+ Also documents `payment.disputed` on the diagnostics page, which the processor has published since it
1862
+ was added and the table never listed — plus a test that fails when any event on the bus is missing
1863
+ from that table.
1864
+
1865
+ - [#6](https://github.com/DavideCarvalho/adonis-agora-payments/pull/6) [`5d2a895`](https://github.com/DavideCarvalho/adonis-agora-payments/commit/5d2a895a41736ea924c1851c71e59a29034b299f) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - **New driver: Razorpay** (India's dominant gateway, v1 API) — `payments.razorpay()`.
1866
+
1867
+ **Not yet run against a live account.** It is written against Razorpay's published v1 API
1868
+ reference and covered by unit tests that stub the HTTP layer; nothing in it has touched a real
1869
+ Razorpay account. Verify your flow with `rzp_test_…` keys before taking real money.
1870
+
1871
+ Orders, payments, refunds, payment links, native subscriptions and invoices over
1872
+ `https://api.razorpay.com/v1` via `fetch` (no SDK dependency). HTTP Basic auth with the key id
1873
+ as username and the key secret as password; `RAZORPAY_KEY_ID` / `RAZORPAY_KEY_SECRET` are the env
1874
+ fallbacks. There is no `sandbox` option, because test mode is a property of the key pair rather
1875
+ than of a different host. Multi-currency, so `currency` is required and has no default.
1876
+
1877
+ ```ts
1878
+ providers: {
1879
+ razorpay: payments.razorpay({ currency: 'inr' }),
1880
+ }
1881
+ ```
1882
+
1883
+ **Money stays in paise.** Razorpay's `amount` is already the integer minor unit, the same unit as
1884
+ this library's `Money`, so the driver does no conversion at all — ₹1990.00 is `199000` on both
1885
+ sides. The Brazilian drivers around it divide by 100 because their gateways want decimal reais;
1886
+ doing that here would bill a hundredth of the charge, and Razorpay would accept it.
1887
+
1888
+ **`charge()` creates an order.** Razorpay has no server-side "charge this customer" call: you
1889
+ create an order and the payer settles it in Razorpay Checkout. So `charge()` returns a `pending`
1890
+ payment whose `gatewayId` is the `order_…` id you hand to Checkout, `findPayment()` accepts an
1891
+ order, payment or payment-link id, and `refund()` accepts only a `pay_…` id. `idempotencyKey`
1892
+ becomes the order's `receipt` (account-level unique, 40 characters).
1893
+
1894
+ **`externalReference`** rides in `notes.external_reference` on orders and subscriptions, and as
1895
+ `reference_id` on payment links; `parseWebhook` looks for it on every entity the event carries,
1896
+ because the API reference does not state that an order's notes are copied onto its payment.
1897
+
1898
+ **Webhooks** are a hex HMAC-SHA256 over the raw body in `X-Razorpay-Signature`, verified
1899
+ timing-safe and rejected when missing or wrong; skipped only when no webhook secret is
1900
+ configured, so local development works. `event.id` is Razorpay's `x-razorpay-event-id`.
1901
+
1902
+ Where it refuses instead of pretending:
1903
+
1904
+ - **`supportedMethods` is `['undefined']` only.** An order names no instrument, and UPI — the
1905
+ method most Indian payers use — has no member in this package's `PaymentMethodName` union.
1906
+ Mapping it onto `pix` because both are instant bank rails would put a Brazilian label on an
1907
+ Indian payment, so the driver declares nothing it cannot promise; routing `credit_card` to it
1908
+ fails at the manager and `charge({ method })` throws. Read `payment.method` off the webhook.
1909
+ - **`authorized` maps to `pending`, not `paid`.** `BillingStatus` has no name for money that is
1910
+ held but not captured, and Razorpay voids an uncaptured authorization on its own. A
1911
+ `payment.authorized` webhook normalizes to `payment.updated`. `capturePayment()` (outside the
1912
+ driver contract) finishes the job for accounts with auto-capture off.
1913
+ - **`createSubscription` refuses `amount`, `cycle`, `method` and `card`** — all four live on the
1914
+ plan or on Razorpay's hosted authorization link — and refuses a subscription with no
1915
+ `metadata.totalCount`, because Razorpay has no open-ended subscription and guessing the number
1916
+ of cycles would silently cap or over-run the customer's billing.
1917
+ - **`updateSubscription` refuses `amount` and `description`.** Plans are immutable and a
1918
+ subscription has no description; it accepts a real plan swap or quantity change through
1919
+ `metadata`, sent with `schedule_change_at: 'now'` so the answer is not the old subscription.
1920
+ - **`charge({ card | paymentMethodId })` and `charge({ split })` throw**, as does
1921
+ `createCheckout({ cancelUrl })` and `createCheckout({ planId })`.
1922
+
1923
+ - [#6](https://github.com/DavideCarvalho/adonis-agora-payments/pull/6) [`5d2a895`](https://github.com/DavideCarvalho/adonis-agora-payments/commit/5d2a895a41736ea924c1851c71e59a29034b299f) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - `payments.driver('pix')` picked the provider and then told it nothing.
1924
+
1925
+ Routing resolved a gateway by payment method and handed back the plain driver, so the method itself
1926
+ only reached the charge when the caller repeated it — `driver('pix').charge({ method: 'pix' })`.
1927
+ Every driver that varies by method reads it off the charge: Stripe's `payment_method_types`, Asaas'
1928
+ and AbacatePay's `billingType`. Without it the charge was created with whatever the gateway's
1929
+ dashboard defaults are. It read as working, and a charge routed as Pix could come back a card.
1930
+
1931
+ `driver(method)` now returns the driver **bound to that method**, filling it in on `charge` and
1932
+ `createSubscription` — the two inputs that carry one:
1933
+
1934
+ ```ts
1935
+ getPayments().driver("pix").charge({ amount: 1990 });
1936
+ // reaches the driver as { amount: 1990, method: 'pix' }
1937
+ ```
1938
+
1939
+ An explicit method on the input still wins: routing is a default, not an override. A driver resolved
1940
+ by **name** comes back untouched, because `driver('stripe')` routed nothing and has nothing to
1941
+ thread. Repeated calls return the same object, so a caller can hold on to one.
1942
+
1943
+ **One behavior change worth knowing about:** `driver('pix')` no longer returns the identical object
1944
+ you put in the config map — it returns a method-bound view of it. `provider`, `supportedMethods`,
1945
+ `capabilities` and every method behave exactly as before, and absent optional members stay absent
1946
+ (the binding is a Proxy for that reason: a wrapper defining every method would turn "this gateway
1947
+ cannot do that" into "it can, until you call it"). Only reference equality against the raw driver
1948
+ instance changes, and only on the method-routed path.
1949
+
1950
+ - [#6](https://github.com/DavideCarvalho/adonis-agora-payments/pull/6) [`5d2a895`](https://github.com/DavideCarvalho/adonis-agora-payments/commit/5d2a895a41736ea924c1851c71e59a29034b299f) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - **New driver: Square** (Connect v2) — `payments.square()`.
1951
+
1952
+ **Not yet run against a live account.** It is written against Square's published Connect v2 API
1953
+ reference and covered by unit tests that stub the HTTP layer; nothing in it has touched a real
1954
+ Square account. Verify your flow in the Square Sandbox (`sandbox: true`) before taking real money.
1955
+
1956
+ Payments, refunds, customers, hosted payment links, native subscriptions and invoice search over
1957
+ `https://connect.squareup.com/v2` via `fetch` (no SDK dependency). Bearer access token,
1958
+ `SQUARE_ACCESS_TOKEN` / `SQUARE_LOCATION_ID` as env fallbacks, and a pinned `Square-Version`
1959
+ header (`2026-08-19`) so a Square release cannot change response shapes underneath the mappings.
1960
+ Multi-currency, so `currency` is required; `locationId` is required too and fails at **boot**,
1961
+ because Square's "defaults to the main location" fallback would otherwise book money against
1962
+ whichever location the account happens to list first.
1963
+
1964
+ ```ts
1965
+ providers: {
1966
+ square: payments.square({ locationId: env.get('SQUARE_LOCATION_ID'), currency: 'usd' }),
1967
+ }
1968
+ ```
1969
+
1970
+ **Money stays in integer minor units.** Square's `{ amount: 1990, currency: 'USD' }` is the same
1971
+ unit as this library's `Money`, so the driver converts nothing — $19.90 is `1990` on both sides,
1972
+ and ¥1990 is `1990` too, which a hardcoded `/100` would have billed as ¥19.90.
1973
+
1974
+ **`idempotency_key` is a body field**, not a header, on payments, refunds, subscriptions and
1975
+ payment links; `idempotencyKey` maps straight onto it. Square requires one on payments and
1976
+ refunds, so a call without it gets a generated UUID — enough for Square's own retry, not for
1977
+ yours.
1978
+
1979
+ **`externalReference`** is `reference_id` on a payment. On a checkout it goes to the order's
1980
+ `reference_id`, the order's `metadata.external_reference` and the link's `payment_note` at once,
1981
+ because a Square `PaymentLink` has no reference field of its own; `parseWebhook` reads
1982
+ `payment.reference_id` and falls back to `payment.note`. Square's published payment webhook
1983
+ examples do not show `reference_id`, so the docs page flags the hosted-link round trip as
1984
+ to-be-confirmed in Sandbox and names `event.data.orderId` as the fallback.
1985
+
1986
+ **Webhooks sign `notificationUrl + rawBody`** as a base64 HMAC-SHA256 in
1987
+ `x-square-hmacsha256-signature`. The URL is part of the signed material, so the driver must be
1988
+ told its own public address: configuring `webhookSignatureKey` **without** `notificationUrl`
1989
+ throws at boot rather than rejecting every genuine webhook or quietly skipping verification.
1990
+ With neither configured, verification is skipped so local development works.
1991
+
1992
+ Where it refuses instead of pretending:
1993
+
1994
+ - **`charge()` requires a `source_id`.** Square has no server-side "charge this customer" call, so
1995
+ a charge without `paymentMethodId` (or `card.token`) throws instead of inventing a flow.
1996
+ - **`APPROVED` maps to `pending`, not `paid`**, and a fully refunded payment reads `refunded` even
1997
+ though Square keeps calling it `COMPLETED`. `completePayment()` (outside the driver contract)
1998
+ captures a delayed-capture payment.
1999
+ - **`cancelSubscription(id, { atPeriodEnd: false })` throws.** Square's cancel always schedules
2000
+ for the end of the billing period; reporting an immediate cancellation would be a lie.
2001
+ - **`updateSubscription` refuses `amount` and `description`.** Square will not reprice a live
2002
+ subscription — `metadata: { planVariationId }` runs a real `swap-plan`, and
2003
+ `metadata: { cardId }` changes the card on file.
2004
+ - **`createSubscription` refuses `externalReference`.** A Square subscription has no reference or
2005
+ metadata field at all, so it would be silently dropped and could never come back on
2006
+ `subscription.updated`.
2007
+ - **`createSubscription` also refuses `cycle`, `trialDays`, `method` and `card`** — the cadence and
2008
+ the free trial live on the plan variation, and a Web Payments SDK token is single-use and must
2009
+ be saved with `POST /v2/cards` first.
2010
+ - **`createCustomer({ taxId })` throws** unless `metadata.taxIdType === 'eu_vat'`: a Square
2011
+ customer has exactly one tax field and no general-purpose one.
2012
+ - **`charge({ split })` and `createCheckout({ cancelUrl | trialDays })` throw.**
2013
+
2014
+ `supportedMethods` is `credit_card`, `debit_card` and `undefined` — the instrument is fixed by the
2015
+ browser token, not by the call, and a Cash App or Afterpay payment leaves `Payment.method` unset
2016
+ rather than being labelled a card.
2017
+
2018
+ - [#6](https://github.com/DavideCarvalho/adonis-agora-payments/pull/6) [`5d2a895`](https://github.com/DavideCarvalho/adonis-agora-payments/commit/5d2a895a41736ea924c1851c71e59a29034b299f) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - Stripe's `charge.dispute.created` fires for two different things, and the driver read both as one.
2019
+
2020
+ A **chargeback** means the cardholder's bank has already pulled the money back. An **inquiry** — the
2021
+ pre-dispute phase, called a retrieval or a request for information — means the bank is asking a
2022
+ question and **no funds have been withdrawn**. Stripe distinguishes them only by a status prefix:
2023
+ an inquiry's `status` starts with `warning_`. The driver ignored the status, so an inquiry moved the
2024
+ payment row to `disputed` and took a paid payment away over a question.
2025
+
2026
+ Now a chargeback stays `payment.disputed`, and an inquiry becomes **`payment.dispute_warning`** and
2027
+ moves nothing — the payment is still paid, because it is. The inquiry payload's
2028
+ `evidence_details.due_by` is carried through as `actionableUntil`, which is the whole value of the
2029
+ alert: Stripe's own guidance is that leaving an inquiry unanswered reads to the issuer as accepting
2030
+ the claim, and can produce a chargeback that is probably irreversible.
2031
+
2032
+ **`radar.early_fraud_warning.created` is now a `payment.dispute_warning` too.** It was passing
2033
+ through under its raw Stripe name — visible in the ledger, but not something a handler could react
2034
+ to without knowing Stripe's event vocabulary. It is the issuer's TC40/SAFE fraud report, arriving
2035
+ before any dispute exists; Stripe's published figure is that around 80% of them become a fraud
2036
+ dispute if you do nothing. It carries no deadline — the window closes when the chargeback is filed —
2037
+ so it carries Stripe's `actionable` flag and `fraud_type` as `reason` instead.
2038
+
2039
+ **`charge.dispute.closed` now carries the outcome.** It normalizes to `payment.dispute_closed` with
2040
+ `outcome: 'won' | 'lost' | 'expired'`. `warning_closed` — an inquiry that sat 120 days without
2041
+ escalating — is `expired` rather than `won`, because the networks send no explicit win for an
2042
+ inquiry: nothing was decided in your favour, the clock ran out the right way. A close whose status
2043
+ the driver cannot read stays a `payment.updated` rather than inventing a result.
2044
+
2045
+ `charge.dispute.updated`, `.funds_withdrawn` and `.funds_reinstated` are unchanged: movement inside
2046
+ an open dispute, not a resolution of it.
2047
+
2048
+ **If you have a handler on `payment.disputed`,** it will no longer fire for inquiries. That is the
2049
+ fix, but it is a behavior change: if you were relying on it to hear about inquiries at all, handle
2050
+ `payment.dispute_warning` as well.
2051
+
2052
+ - [#6](https://github.com/DavideCarvalho/adonis-agora-payments/pull/6) [`5d2a895`](https://github.com/DavideCarvalho/adonis-agora-payments/commit/5d2a895a41736ea924c1851c71e59a29034b299f) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - Paddle, Lemon Squeezy, Polar, Dodo Payments, Mollie and Efí adopt the widened contract.
2053
+ Four of the six were telling the billing layer something that was not true, and one of
2054
+ those lies handed paying-customer entitlement to people who had stopped paying.
2055
+
2056
+ **Breaking: a paused subscription is now `paused`, not `active`.** Paddle, Lemon Squeezy,
2057
+ Polar and Dodo all have a real paused state, and all four mapped it to `active` because
2058
+ `SubscriptionStatus` had no other word. It does now. A paused subscription exists and will
2059
+ bill again, but it is **not billing right now**, so reporting `active` granted access to
2060
+ someone who is not paying. Anything gating on `subscription.status === 'active'` will start
2061
+ refusing paused subscribers — that is the fix, not a regression. The gateway's own value is
2062
+ still on `subscription.payload.status` if you were relying on the old behaviour deliberately.
2063
+
2064
+ **Chargebacks reach `payment.disputed`, where a gateway has one.**
2065
+
2066
+ - **Paddle** has no `dispute.*` event: a chargeback _is_ an adjustment. `adjustment.created`
2067
+ with `action: 'chargeback'` — the only notification a Paddle seller gets that revenue was
2068
+ taken away — used to arrive as a bland `payment.updated`, leaving the stored payment
2069
+ saying `paid`. It is now `payment.disputed`, keyed by the transaction.
2070
+ `chargeback_warning`, `chargeback_reverse` and a later `adjustment.updated` stay
2071
+ `payment.updated`.
2072
+ - **Dodo Payments** forwards the whole dispute lifecycle even though it bears the
2073
+ liability. `dispute.opened` → `payment.disputed`, keyed by the payment, with `disputeId`,
2074
+ `disputeStage` and `disputeStatus` on `event.data` (the amount is read as cents whether
2075
+ Dodo sends a number or a decimal string). `won`, `lost`, `challenged`, `accepted`,
2076
+ `cancelled` and `expired` → `payment.updated`.
2077
+ - **Mollie** reports one two ways. On the classic webhook a chargeback re-fires the
2078
+ payment's `webhookUrl` with the same bare id and leaves `status` at `paid` — only
2079
+ `amountChargedBack` on the fetched payment says the bank pulled the money back, so the
2080
+ driver reads it and reports `payment.disputed`. On next-gen webhooks
2081
+ `chargeback.received` → `payment.disputed` and `chargeback.reversed` → `payment.updated`.
2082
+ A chargeback cannot be read back on its own (Mollie has no lookup by chargeback id), so
2083
+ an **id-only** next-gen chargeback event now throws and tells you to switch the webhook to
2084
+ the snapshot payload, rather than passing the money event through inert.
2085
+ - **Polar, Lemon Squeezy and Efí genuinely have no dispute notification**, and the drivers
2086
+ do not invent one. For the two merchants of record that is the deal, not a gap: the
2087
+ chargeback is raised against them and they absorb it, fee and representment included —
2088
+ and it is a large part of why anyone picks an MoR. Efí is Pix, which has no chargeback at
2089
+ all. Each provider page says so.
2090
+
2091
+ **Mollie also fixes an event-id collision this uncovered.** Mollie's own `status` stays
2092
+ `paid` through both a refund and a chargeback, so `mollie:<id>:<status>` produced an event
2093
+ id byte-identical to the earlier `payment.succeeded` one — and the idempotency ledger
2094
+ discarded the webhook that takes the money away as a replay. Those two transitions now get
2095
+ a `:refunded` / `:chargeback` suffix.
2096
+
2097
+ **`authorized` where the gateway separates authorization from capture.** Mollie's
2098
+ `authorized` payment status and Dodo's `requires_capture` both used to collapse into
2099
+ `pending`, which understated a held authorization. Dodo's `partially_captured` and
2100
+ `partially_captured_and_capturable` deliberately stay `pending`: part of the authorization
2101
+ has already settled, so "nothing captured" would be as wrong as `paid`.
2102
+
2103
+ **Payment methods are categories now, and Mollie and Dodo can finally route them.**
2104
+
2105
+ - **Mollie** goes from `credit_card | undefined` to `credit_card`, `bank_transfer`,
2106
+ `bank_debit`, `wallet`, `bnpl`, `voucher`, `undefined`. iDEAL, Bancontact, SEPA Direct
2107
+ Debit, PayPal, Klarna, Apple Pay, EPS, Przelewy24, BLIK, TWINT, MB WAY, Multibanco,
2108
+ Trustly, paysafecard and vouchers were all unroutable; each now sits in a category, and a
2109
+ category goes out as Mollie's own `method` **array** — which is exactly what a category
2110
+ is, since `bank_transfer` is iDEAL in the Netherlands and Bancontact in Belgium. To pin
2111
+ one brand, name it in the gateway's own field: `metadata.mollieMethod: 'ideal'`, validated
2112
+ against the category. `payment.method` comes back as the category too. `debit_card` is
2113
+ still refused, and re-checking confirmed why: Mollie folds debit cards into the single
2114
+ `creditcard` id and has no debit-only method to ask for.
2115
+ - **Dodo Payments** adds `upi`, `wallet`, `bank_transfer`, `bank_debit` and `bnpl` to
2116
+ `credit_card`, `debit_card` and `pix`, each mapping to the `allowed_payment_method_types`
2117
+ in it (plus the `credit`/`debit` fallbacks Dodo tells you to keep, because a checkout
2118
+ whose every listed method is unavailable simply fails). `payment.method` reports the
2119
+ category, so iDEAL/SEPA/Klarna/Apple Pay/UPI payments stop coming back `unknown`.
2120
+ - **Paddle** cannot route (its transaction API takes no payment-method argument, so
2121
+ `supportedMethods` stays `undefined`), but it _reports_: `method_details.type` now maps
2122
+ onto `wallet` (PayPal, Apple/Google/Samsung Pay, Alipay, WeChat Pay, the Korean wallets),
2123
+ `bank_transfer` (iDEAL, Bancontact, BLIK, MB WAY, wire), `pix` and `upi`. Only `card` had
2124
+ a name before.
2125
+
2126
+ **`idempotencyKey` is honoured where it deduplicates and refused where it does not.**
2127
+
2128
+ - **Mollie** sends `Idempotency-Key` on every POST it makes — now including `refund`,
2129
+ `createCustomer` and `createSubscription`, not just `charge` and `createCheckout`.
2130
+ `updateSubscription` **throws**: Mollie accepts the header on POST only, and a `PATCH` is
2131
+ repeatable by nature, so accepting a key there promised a deduplication Mollie never
2132
+ performs.
2133
+ - **Polar** sends `Idempotency-Key`, which it documents for POST, PATCH and DELETE, on
2134
+ `createCheckout`, `refund`, `createCustomer`, `createSubscription` and
2135
+ `updateSubscription`.
2136
+ - **Efí** uses the key as the **devolução id** in `PUT /v2/pix/{e2eid}/devolucao/{id}` —
2137
+ the id _is_ the deduplication on that API. BACEN allows 1–35 alphanumerics, so a key
2138
+ outside that charset throws instead of being silently replaced by a random id.
2139
+ - **Paddle, Lemon Squeezy and Dodo Payments have no deduplication mechanism at all**, so
2140
+ every entry point that takes a key now **throws** instead of accepting and dropping it.
2141
+ Paddle's `createCheckout` and Dodo's `charge`/`createCheckout` were previously accepting
2142
+ a key and ignoring it, which turned a caller's retry guarantee into a second charge. This
2143
+ is a behaviour change for anyone passing a key to those three: catch it, and deduplicate
2144
+ on your side by persisting the key before you call.
2145
+
2146
+ **`listInvoices` throws on Efí** instead of answering `[]`. An empty list is
2147
+ indistinguishable from "this customer has no invoices", which is the same silent shape as
2148
+ the bugs above; `capabilities.invoices` is `false`, so `PaymentsManager.assertCapability`
2149
+ already stops the documented path and the message is for whoever reaches the driver directly.
2150
+
2151
+ - [#6](https://github.com/DavideCarvalho/adonis-agora-payments/pull/6) [`5d2a895`](https://github.com/DavideCarvalho/adonis-agora-payments/commit/5d2a895a41736ea924c1851c71e59a29034b299f) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - Telescope as a debugger, not a second dashboard: three new diagnostics that answer why one
2152
+ payment behaved the way it did.
2153
+
2154
+ - **`gateway.request` / `gateway.request.failed`** — every call through the shared
2155
+ `httpRequest` transport now records provider, method, host, path, redacted query, HTTP
2156
+ status and duration, including the two outcomes that previously left no trace anywhere:
2157
+ a timeout and a non-2xx. Credentials are never recorded — headers are omitted entirely,
2158
+ credential-shaped query values are replaced with `[redacted]`, and the recorded error is
2159
+ built fresh rather than reusing the thrown one (which quotes the full URL). Request and
2160
+ response bodies are opt-in and off by default behind
2161
+ `configurePaymentsDiagnostics({ recordHttpBodies: true })`, because a charge body carries
2162
+ card and CPF data.
2163
+ - **`webhook.verification`** — says whether a delivery's signature verified and under which
2164
+ scheme (`hmac-sha256`, `standard-webhooks`, `rsa-sha256`, `sha256-token-prefix`,
2165
+ `shared-token`), or that nothing verified it through the shared helpers, which is the
2166
+ usual answer to "why is my endpoint unauthenticated". Fed automatically by
2167
+ `webhook_security`'s primitives; `reportWebhookVerification()` is exported so a driver
2168
+ verifying inside its own SDK can report too.
2169
+ - **Per-delivery correlation** — the webhook route opens an `AsyncLocalStorage` trace per
2170
+ delivery attempt, and every payments event published inside it is stamped with the same
2171
+ `traceId`, so received → verified → ledgered → handler → synced reads as one chain for
2172
+ one event. `PaymentsWatcher` surfaces it, preferring the envelope's own trace id.
2173
+
2174
+ `PaymentsWatcher` entries are now shaped rather than spread blindly: `event`, `traceId`,
2175
+ then the fields you scan a timeline by, then the rest of the payload.
2176
+
2177
+ ### Patch Changes
2178
+
2179
+ - [#6](https://github.com/DavideCarvalho/adonis-agora-payments/pull/6) [`5d2a895`](https://github.com/DavideCarvalho/adonis-agora-payments/commit/5d2a895a41736ea924c1851c71e59a29034b299f) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - Two Asaas statuses meaning **the customer paid** were reading as unpaid.
2180
+
2181
+ `RECEIVED_IN_CASH` — a receipt confirmed by hand in the Asaas UI, the customer paid at the counter —
2182
+ and `DUNNING_RECEIVED` — the debt settled through the credit bureau after a _negativação_ — both
2183
+ fell through the status map's `pending` default. So someone who had paid read as never having paid,
2184
+ and stayed locked out of what they bought. `PAYMENT_DUNNING_RECEIVED` also had no webhook mapping,
2185
+ which meant the one event announcing that a written-off debt came back ran no sync at all.
2186
+
2187
+ The rest of the statuses Asaas has and the driver did not, now that they are all named rather than
2188
+ silently agreeing with a default:
2189
+
2190
+ - `REFUND_REQUESTED` and `REFUND_IN_PROGRESS` stay **`paid`**. A refund asked for or scheduled has
2191
+ settled in neither case, and Asaas can still deny one (`PAYMENT_REFUND_DENIED`). `pending` claimed
2192
+ the charge was never paid; `refunded` would write off money still in the account.
2193
+ - `DUNNING_REQUESTED` is **`failed`** — overdue and escalated, same as `OVERDUE`.
2194
+ - `AWAITING_RISK_ANALYSIS` is **`pending`**, which the default already got right by accident and now
2195
+ says on purpose. Asaas' own guidance is to wait before releasing the product.
2196
+
2197
+ New webhook mappings: `PAYMENT_CREDIT_CARD_CAPTURE_REFUSED` and `PAYMENT_REPROVED_BY_RISK_ANALYSIS`
2198
+ → `payment.failed`. `PAYMENT_PARTIALLY_REFUNDED` → **`payment.updated`, deliberately not
2199
+ `payment.refunded`**: that handler overwrites the row's status with `refunded` and its amount with
2200
+ the refunded amount, so routing a R$10 refund on a R$100 charge there would drop R$90 of revenue
2201
+ instead of subtracting R$10. Until the billing tables carry a refunded amount, an update is the
2202
+ arithmetic-safe half. `PAYMENT_REFUND_IN_PROGRESS`, `PAYMENT_REFUND_DENIED`,
2203
+ `PAYMENT_AWAITING_RISK_ANALYSIS`, `PAYMENT_APPROVED_BY_RISK_ANALYSIS`, `PAYMENT_DUNNING_REQUESTED`,
2204
+ `PAYMENT_RECEIVED_IN_CASH_UNDONE`, `PAYMENT_DELETED` and `PAYMENT_RESTORED` are updates too — named
2205
+ rather than arriving as unrecognized types.
2206
+
2207
+ **If you were compensating for this** — treating `pending` Asaas rows as possibly-paid, or polling
2208
+ `findPayment` after a dunning — you can stop. Rows that were already wrong stay wrong until the
2209
+ payment is re-synced; `payments:sync` fixes them.
2210
+
2211
+ - [#4](https://github.com/DavideCarvalho/adonis-agora-payments/pull/4) [`c27c81e`](https://github.com/DavideCarvalho/adonis-agora-payments/commit/c27c81e022661b2931553fc152cb675ff7bb27c8) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - `node ace add` now registers the webhook-handlers Assembler hook itself, so nothing has to be added
2212
+ to `adonisrc.ts` by hand.
2213
+
2214
+ The docs also overstated what that hook does. Discovery of `app/payment_handlers/` has always worked
2215
+ without it — the provider falls back to scanning the folder at boot — so a handler scaffolded with
2216
+ `make:webhook-handler` was already picked up. The hook generates a build-time barrel that removes the
2217
+ scan; it is an optimization, not a requirement, and the pages that presented it as a required step
2218
+ now say so.
2219
+
2220
+ Also documents the worker that `dispatcher: 'durable'` and `'queue'` depend on (`durable:work` /
2221
+ `queue:listen`) — without it webhooks are accepted and enqueued but never processed, while the
2222
+ endpoint keeps answering `200`.
2223
+
2224
+ - [#6](https://github.com/DavideCarvalho/adonis-agora-payments/pull/6) [`5d2a895`](https://github.com/DavideCarvalho/adonis-agora-payments/commit/5d2a895a41736ea924c1851c71e59a29034b299f) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - A dispute **lost** now takes the payment row off `paid`, even when no `payment.disputed` ever
2225
+ arrived.
2226
+
2227
+ The processor moved the row back to `paid` on a won close and left every other outcome alone, which
2228
+ quietly assumed a chargeback event had already moved it to `disputed`. Several gateways never send
2229
+ one: Razorpay documents that it does not debit provisionally at all, PayPal's dispute opens at an
2230
+ inquiry stage that takes nothing, and Woovi only blocks the balance during a Pix MED. On those the
2231
+ sequence is `payment.dispute_warning` → `payment.dispute_closed` with `lost`, and nothing in between
2232
+ ever moved the row — so a payment whose money is definitively gone kept reading `paid`, and
2233
+ `revenue()` kept counting it.
2234
+
2235
+ `expired` and `canceled` still move nothing, deliberately. Expired means the window closed with no
2236
+ verdict published; canceled means the cardholder withdrew, and on Stripe a withdrawn dispute still
2237
+ has to be closed in your favour with evidence. Neither is a statement about where the money ended up.
2238
+
2239
+ - [#6](https://github.com/DavideCarvalho/adonis-agora-payments/pull/6) [`5d2a895`](https://github.com/DavideCarvalho/adonis-agora-payments/commit/5d2a895a41736ea924c1851c71e59a29034b299f) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - Fix: in durable mode, no webhook was ever processed
2240
+
2241
+ `WebhookDispatcher` built an anonymous `class PaymentsWebhookWorkflow extends BaseWorkflow`
2242
+ at dispatch time and called its inherited static `dispatch`. That class declares no
2243
+ `static workflow = { name }` and is registered on no engine, so `@adonis-agora/durable`
2244
+ answered
2245
+
2246
+ ```
2247
+ workflow class PaymentsWebhookWorkflow has no registered name — does it declare `static workflow = { name }`?
2248
+ ```
2249
+
2250
+ for every event. `dispatchAll` collected the throw as a failed event, the route answered
2251
+ `500`, the gateway redelivered forever, and no payment was ever confirmed. This happened in
2252
+ every app with `@adonis-agora/durable` installed — which is precisely what the default
2253
+ `dispatcher: 'auto'` selects, so the default configuration was the broken one.
2254
+
2255
+ The dispatcher now takes the app's engine (the provider wires it from the container) and
2256
+ registers a named workflow — `payments-webhook`, version `1` — before the first
2257
+ `engine.start`. The run id stays random per delivery on purpose: `engine.start` is
2258
+ idempotent by run id and returns the prior run's state for a repeat, so a run id derived
2259
+ from the event would turn the gateway's redelivery of a FAILED event into a silent no-op.
2260
+ Deduplication belongs to the ledger, which decides from the row's state.
2261
+
2262
+ 1125 unit tests passed over this path because nothing had ever driven it. The dispatcher
2263
+ spec now exercises durable against an engine that enforces durable's actual rule — `start`
2264
+ an unregistered name and it throws — and the fix was proven by mutation in both directions.
2265
+
2266
+ - [#6](https://github.com/DavideCarvalho/adonis-agora-payments/pull/6) [`5d2a895`](https://github.com/DavideCarvalho/adonis-agora-payments/commit/5d2a895a41736ea924c1851c71e59a29034b299f) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - Say how many gateways ship, and put the gaps in the table
2267
+
2268
+ The package advertised four gateways — Stripe, AbacatePay, Asaas and Woovi — while eighteen
2269
+ shipped. The count was written on four surfaces nobody edits when a driver lands (two npm
2270
+ descriptions, two docs descriptions), and ten drivers had never reached the npm keywords, so
2271
+ the package was unfindable by the name of the gateway it already supported.
2272
+
2273
+ `driver_registry.spec.ts` now fails when a description stops matching the number of drivers on
2274
+ disk, and when a driver is missing from the keywords.
2275
+
2276
+ The providers comparison table gained the three columns a reader was going to the source for:
2277
+
2278
+ - **Charge** — `server` or `checkout only`. Four gateways (InfinitePay, Lemon Squeezy, Paddle,
2279
+ Polar) throw on `charge()` and collect through their own hosted page; that difference outranks
2280
+ every other column and was documented nowhere near the table.
2281
+ - **Disputes** — true on Stripe alone.
2282
+ - **Webhook auth** — false on Efí and InfinitePay, whose gateways sign nothing.
2283
+
2284
+ All three are generated from the drivers and gated by the same spec. A new **When it is a dash**
2285
+ section turns each gap into the route through it — the one the driver's own error message names.
2286
+
2287
+ - [#3](https://github.com/DavideCarvalho/adonis-agora-payments/pull/3) [`0fb29c9`](https://github.com/DavideCarvalho/adonis-agora-payments/commit/0fb29c923cec08e7d6e14d071b8d0c644aef4bba) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - Docs: patterns is now a nested section with per-gateway examples — Pix and subscriptions show Asaas,
2288
+ Woovi, AbacatePay and Stripe side by side, and a new page compares the four places business logic can
2289
+ live, including a durable workflow that subscribes itself with `@OnDiagnostic`.
2290
+
2291
+ - [#6](https://github.com/DavideCarvalho/adonis-agora-payments/pull/6) [`5d2a895`](https://github.com/DavideCarvalho/adonis-agora-payments/commit/5d2a895a41736ea924c1851c71e59a29034b299f) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - Three regressions from this release's own changes
2292
+
2293
+ `config.methods` is written in terms of the KEYS under `config.providers`, and `driver()`
2294
+ was handing the routing check the driver's own `provider` string instead. The two are the
2295
+ same only by coincidence — `providers: { primary: payments.stripe(…) }` with
2296
+ `methods: { credit_card: 'primary' }` made `driver('credit_card').charge(...)` refuse a
2297
+ charge that was routed exactly right. Every existing routing test used a key equal to the
2298
+ provider name, which is why nothing caught it. The bound-proxy cache is now keyed by
2299
+ provider AND method for the same reason.
2300
+
2301
+ `payments:health` grew from four checks to six, and its own description, docblock and
2302
+ plain-text output did not follow: it printed the disputes that carry a deadline and never
2303
+ the ones that do not — on precisely the installs the new `open_disputes` check was added
2304
+ for, since Asaas' published webhook examples never carry a deadline at all. The count fired
2305
+ naming nobody. It now names them, the description matches, and `--rejected-window` exposes
2306
+ the threshold `billingHealth` already accepted.
2307
+
2308
+ Mollie's CLASSIC webhook reported `'unconfigured'`, the state that refuses to boot. There
2309
+ is no credential to configure on that flow and nothing insecure about not having one: the
2310
+ request carries only `id=tr_xxx` and the driver authenticates by reading that payment back
2311
+ with your API key. It is `'unsupported'` now, so the supported configuration boots — and
2312
+ `allowUnverifiedWebhooks` is not needed for it, which matters because reaching for it would
2313
+ also have silenced a genuinely missing next-gen secret.
2314
+
2315
+ - [#2](https://github.com/DavideCarvalho/adonis-agora-payments/pull/2) [`836ec88`](https://github.com/DavideCarvalho/adonis-agora-payments/commit/836ec881c351ce737b05754ee21cbf788bcb79c3) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - Fix webhook retries, which were dead code, and deepen the docs.
2316
+
2317
+ Every retry short-circuited on the idempotency ledger and silently did nothing. The first attempt
2318
+ claimed the event; when its handler threw, the row was marked `failed` — and `recordWebhookEvent`
2319
+ refused any event it had already seen, so the retry returned `false` before running anything. This
2320
+ affected the in-process dispatcher and the durable path alike: a webhook whose handler failed once
2321
+ was never processed, and it looked exactly like a webhook that was never delivered.
2322
+
2323
+ An event whose previous attempt **failed** is now claimable again, so the retry re-runs it. A
2324
+ genuine redelivery — an event still in flight, or already processed — is refused exactly as before.
2325
+
2326
+ Docs gain a Concepts section (money as an integer, the driver contract and routing, the payment
2327
+ lifecycle, idempotency) plus patterns, production, troubleshooting, cli and api-reference pages, and
2328
+ document `billing.dispatcher` and `billingOverview`, which shipped undocumented.
2329
+
2330
+ - [#6](https://github.com/DavideCarvalho/adonis-agora-payments/pull/6) [`5d2a895`](https://github.com/DavideCarvalho/adonis-agora-payments/commit/5d2a895a41736ea924c1851c71e59a29034b299f) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - **Woovi charges were being created for one hundredth of their amount.**
2331
+
2332
+ OpenPix documents `value` as _"o valor em centavos da cobrança Pix"_ — the same integer minor unit
2333
+ this package uses everywhere. The driver ran `toDecimal()` over it on the way out and
2334
+ `fromDecimal()` on the way back, so `charge({ amount: 1990 })` sent `value: 19.9` and the gateway
2335
+ created a **20 centavo** charge. The same conversion ran on `createCheckout` and
2336
+ `createSubscription`, and in reverse when reading a charge or a webhook back — so a R$19,90 payment
2337
+ was also _reported_ as 20 centavos, which is why the two halves agreed with each other and the tests
2338
+ agreed with both.
2339
+
2340
+ Nothing is converted at this boundary now. The neighbouring Brazilian drivers are genuinely the
2341
+ other way round — Asaas and AbacatePay work in decimal reais and still convert — so the driver and
2342
+ its page now say which is which rather than leaving it to look like an inconsistency.
2343
+
2344
+ The unit is pinned by tests in both directions, and the old tests that asserted the converted figure
2345
+ were wrong in exactly the way the code was.
2346
+
2347
+ **If you are live on Woovi, check your charges.** Anything created through this driver was for 1/100
2348
+ of the intended amount; the library cannot repair a charge the gateway already settled.
2349
+
3
2350
  ## 0.2.0
4
2351
 
5
2352
  ### Minor Changes