@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.
- package/CHANGELOG.md +2347 -0
- package/dist/assets/spa/assets/index-CT5Zon6P.css +1 -0
- package/dist/assets/spa/assets/index-CvEbJEnq.js +41 -0
- package/dist/assets/spa/assets/index-CvEbJEnq.js.map +1 -0
- package/dist/assets/spa/index.html +20 -0
- package/dist/commands/main.d.ts +4 -3
- package/dist/commands/main.d.ts.map +1 -1
- package/dist/commands/main.js +9 -2
- package/dist/commands/main.js.map +1 -1
- package/dist/commands/payments_health.d.ts +31 -0
- package/dist/commands/payments_health.d.ts.map +1 -0
- package/dist/commands/payments_health.js +113 -0
- package/dist/commands/payments_health.js.map +1 -0
- package/dist/commands/payments_sync.d.ts +18 -4
- package/dist/commands/payments_sync.d.ts.map +1 -1
- package/dist/commands/payments_sync.js +136 -40
- package/dist/commands/payments_sync.js.map +1 -1
- package/dist/configure.d.ts +12 -5
- package/dist/configure.d.ts.map +1 -1
- package/dist/configure.js +18 -5
- package/dist/configure.js.map +1 -1
- package/dist/providers/dashboard_provider.d.ts +97 -0
- package/dist/providers/dashboard_provider.d.ts.map +1 -0
- package/dist/providers/dashboard_provider.js +430 -0
- package/dist/providers/dashboard_provider.js.map +1 -0
- package/dist/providers/payments_client_provider.d.ts +41 -0
- package/dist/providers/payments_client_provider.d.ts.map +1 -0
- package/dist/providers/payments_client_provider.js +95 -0
- package/dist/providers/payments_client_provider.js.map +1 -0
- package/dist/providers/payments_provider.d.ts +35 -0
- package/dist/providers/payments_provider.d.ts.map +1 -1
- package/dist/providers/payments_provider.js +250 -36
- package/dist/providers/payments_provider.js.map +1 -1
- package/dist/src/billing/billing_health.d.ts +85 -0
- package/dist/src/billing/billing_health.d.ts.map +1 -0
- package/dist/src/billing/billing_health.js +155 -0
- package/dist/src/billing/billing_health.js.map +1 -0
- package/dist/src/billing/billing_overview.d.ts +4 -3
- package/dist/src/billing/billing_overview.d.ts.map +1 -1
- package/dist/src/billing/billing_overview.js +14 -5
- package/dist/src/billing/billing_overview.js.map +1 -1
- package/dist/src/billing/billing_store.d.ts +634 -5
- package/dist/src/billing/billing_store.d.ts.map +1 -1
- package/dist/src/billing/billing_store.js +18 -1
- package/dist/src/billing/billing_store.js.map +1 -1
- package/dist/src/billing/index.d.ts +2 -1
- package/dist/src/billing/index.d.ts.map +1 -1
- package/dist/src/billing/index.js +1 -0
- package/dist/src/billing/index.js.map +1 -1
- package/dist/src/billing/list_query.d.ts +16 -0
- package/dist/src/billing/list_query.d.ts.map +1 -0
- package/dist/src/billing/list_query.js +28 -0
- package/dist/src/billing/list_query.js.map +1 -0
- package/dist/src/billing/lucid_billing_store.d.ts +122 -4
- package/dist/src/billing/lucid_billing_store.d.ts.map +1 -1
- package/dist/src/billing/lucid_billing_store.js +811 -13
- package/dist/src/billing/lucid_billing_store.js.map +1 -1
- package/dist/src/billing/mixins/index.d.ts +3 -0
- package/dist/src/billing/mixins/index.d.ts.map +1 -1
- package/dist/src/billing/mixins/index.js +3 -0
- package/dist/src/billing/mixins/index.js.map +1 -1
- package/dist/src/billing/mixins/with_audit_event.d.ts +48 -0
- package/dist/src/billing/mixins/with_audit_event.d.ts.map +1 -0
- package/dist/src/billing/mixins/with_audit_event.js +77 -0
- package/dist/src/billing/mixins/with_audit_event.js.map +1 -0
- package/dist/src/billing/mixins/with_customer.d.ts +39 -0
- package/dist/src/billing/mixins/with_customer.d.ts.map +1 -0
- package/dist/src/billing/mixins/with_customer.js +68 -0
- package/dist/src/billing/mixins/with_customer.js.map +1 -0
- package/dist/src/billing/mixins/with_dispute.d.ts +68 -0
- package/dist/src/billing/mixins/with_dispute.d.ts.map +1 -0
- package/dist/src/billing/mixins/with_dispute.js +83 -0
- package/dist/src/billing/mixins/with_dispute.js.map +1 -0
- package/dist/src/billing/mixins/with_payment.d.ts +42 -0
- package/dist/src/billing/mixins/with_payment.d.ts.map +1 -1
- package/dist/src/billing/mixins/with_payment.js +29 -3
- package/dist/src/billing/mixins/with_payment.js.map +1 -1
- package/dist/src/billing/mixins/with_subscription.d.ts +7 -0
- package/dist/src/billing/mixins/with_subscription.d.ts.map +1 -1
- package/dist/src/billing/mixins/with_subscription.js +15 -1
- package/dist/src/billing/mixins/with_subscription.js.map +1 -1
- package/dist/src/billing/mixins/with_usage_event.d.ts +7 -0
- package/dist/src/billing/mixins/with_usage_event.d.ts.map +1 -1
- package/dist/src/billing/mixins/with_usage_event.js +15 -1
- package/dist/src/billing/mixins/with_usage_event.js.map +1 -1
- package/dist/src/billing/mixins/with_webhook_event.d.ts +18 -0
- package/dist/src/billing/mixins/with_webhook_event.d.ts.map +1 -1
- package/dist/src/billing/mixins/with_webhook_event.js +18 -1
- package/dist/src/billing/mixins/with_webhook_event.js.map +1 -1
- package/dist/src/billing/resolve_dispatch.d.ts +22 -0
- package/dist/src/billing/resolve_dispatch.d.ts.map +1 -0
- package/dist/src/billing/resolve_dispatch.js +44 -0
- package/dist/src/billing/resolve_dispatch.js.map +1 -0
- package/dist/src/billing/resolve_store.d.ts +18 -0
- package/dist/src/billing/resolve_store.d.ts.map +1 -0
- package/dist/src/billing/resolve_store.js +35 -0
- package/dist/src/billing/resolve_store.js.map +1 -0
- package/dist/src/billing/schema.d.ts +108 -0
- package/dist/src/billing/schema.d.ts.map +1 -0
- package/dist/src/billing/schema.js +350 -0
- package/dist/src/billing/schema.js.map +1 -0
- package/dist/src/billing/webhook_dispatcher.d.ts +95 -16
- package/dist/src/billing/webhook_dispatcher.d.ts.map +1 -1
- package/dist/src/billing/webhook_dispatcher.js +186 -33
- package/dist/src/billing/webhook_dispatcher.js.map +1 -1
- package/dist/src/billing/webhook_events.d.ts +67 -1
- package/dist/src/billing/webhook_events.d.ts.map +1 -1
- package/dist/src/billing/webhook_events.js +15 -0
- package/dist/src/billing/webhook_events.js.map +1 -1
- package/dist/src/billing/webhook_processor.d.ts.map +1 -1
- package/dist/src/billing/webhook_processor.js +325 -5
- package/dist/src/billing/webhook_processor.js.map +1 -1
- package/dist/src/client/define_config.d.ts +150 -0
- package/dist/src/client/define_config.d.ts.map +1 -0
- package/dist/src/client/define_config.js +98 -0
- package/dist/src/client/define_config.js.map +1 -0
- package/dist/src/client/handlers.d.ts +71 -0
- package/dist/src/client/handlers.d.ts.map +1 -0
- package/dist/src/client/handlers.js +119 -0
- package/dist/src/client/handlers.js.map +1 -0
- package/dist/src/client/index.d.ts +16 -0
- package/dist/src/client/index.d.ts.map +1 -0
- package/dist/src/client/index.js +13 -0
- package/dist/src/client/index.js.map +1 -0
- package/dist/src/client/statuses.d.ts +38 -0
- package/dist/src/client/statuses.d.ts.map +1 -0
- package/dist/src/client/statuses.js +42 -0
- package/dist/src/client/statuses.js.map +1 -0
- package/dist/src/dashboard/actions.d.ts +143 -0
- package/dist/src/dashboard/actions.d.ts.map +1 -0
- package/dist/src/dashboard/actions.js +112 -0
- package/dist/src/dashboard/actions.js.map +1 -0
- package/dist/src/dashboard/auth.d.ts +136 -0
- package/dist/src/dashboard/auth.d.ts.map +1 -0
- package/dist/src/dashboard/auth.js +152 -0
- package/dist/src/dashboard/auth.js.map +1 -0
- package/dist/src/dashboard/define_config.d.ts +80 -0
- package/dist/src/dashboard/define_config.d.ts.map +1 -0
- package/dist/src/dashboard/define_config.js +83 -0
- package/dist/src/dashboard/define_config.js.map +1 -0
- package/dist/src/dashboard/handlers.d.ts +281 -0
- package/dist/src/dashboard/handlers.d.ts.map +1 -0
- package/dist/src/dashboard/handlers.js +960 -0
- package/dist/src/dashboard/handlers.js.map +1 -0
- package/dist/src/dashboard/index.d.ts +18 -0
- package/dist/src/dashboard/index.d.ts.map +1 -0
- package/dist/src/dashboard/index.js +14 -0
- package/dist/src/dashboard/index.js.map +1 -0
- package/dist/src/dashboard/login_page.d.ts +23 -0
- package/dist/src/dashboard/login_page.d.ts.map +1 -0
- package/dist/src/dashboard/login_page.js +170 -0
- package/dist/src/dashboard/login_page.js.map +1 -0
- package/dist/src/dashboard/period.d.ts +31 -0
- package/dist/src/dashboard/period.d.ts.map +1 -0
- package/dist/src/dashboard/period.js +44 -0
- package/dist/src/dashboard/period.js.map +1 -0
- package/dist/src/dashboard/session_cookie.d.ts +42 -0
- package/dist/src/dashboard/session_cookie.d.ts.map +1 -0
- package/dist/src/dashboard/session_cookie.js +97 -0
- package/dist/src/dashboard/session_cookie.js.map +1 -0
- package/dist/src/dashboard/spa.d.ts +24 -0
- package/dist/src/dashboard/spa.d.ts.map +1 -0
- package/dist/src/dashboard/spa.js +52 -0
- package/dist/src/dashboard/spa.js.map +1 -0
- package/dist/src/define_config.d.ts +244 -9
- package/dist/src/define_config.d.ts.map +1 -1
- package/dist/src/define_config.js +97 -1
- package/dist/src/define_config.js.map +1 -1
- package/dist/src/diagnostics.d.ts +287 -7
- package/dist/src/diagnostics.d.ts.map +1 -1
- package/dist/src/diagnostics.js +317 -1
- package/dist/src/diagnostics.js.map +1 -1
- package/dist/src/driver.d.ts +139 -5
- package/dist/src/driver.d.ts.map +1 -1
- package/dist/src/drivers/abacate.d.ts +12 -2
- package/dist/src/drivers/abacate.d.ts.map +1 -1
- package/dist/src/drivers/abacate.js +112 -19
- package/dist/src/drivers/abacate.js.map +1 -1
- package/dist/src/drivers/adyen.d.ts +164 -0
- package/dist/src/drivers/adyen.d.ts.map +1 -0
- package/dist/src/drivers/adyen.js +678 -0
- package/dist/src/drivers/adyen.js.map +1 -0
- package/dist/src/drivers/asaas.d.ts +52 -2
- package/dist/src/drivers/asaas.d.ts.map +1 -1
- package/dist/src/drivers/asaas.js +364 -29
- package/dist/src/drivers/asaas.js.map +1 -1
- package/dist/src/drivers/dodo.d.ts +135 -0
- package/dist/src/drivers/dodo.d.ts.map +1 -0
- package/dist/src/drivers/dodo.js +864 -0
- package/dist/src/drivers/dodo.js.map +1 -0
- package/dist/src/drivers/efi.d.ts +98 -0
- package/dist/src/drivers/efi.d.ts.map +1 -0
- package/dist/src/drivers/efi.js +628 -0
- package/dist/src/drivers/efi.js.map +1 -0
- package/dist/src/drivers/infinitepay.d.ts +156 -0
- package/dist/src/drivers/infinitepay.d.ts.map +1 -0
- package/dist/src/drivers/infinitepay.js +321 -0
- package/dist/src/drivers/infinitepay.js.map +1 -0
- package/dist/src/drivers/lemonsqueezy.d.ts +125 -0
- package/dist/src/drivers/lemonsqueezy.d.ts.map +1 -0
- package/dist/src/drivers/lemonsqueezy.js +632 -0
- package/dist/src/drivers/lemonsqueezy.js.map +1 -0
- package/dist/src/drivers/mercadopago.d.ts +97 -0
- package/dist/src/drivers/mercadopago.d.ts.map +1 -0
- package/dist/src/drivers/mercadopago.js +844 -0
- package/dist/src/drivers/mercadopago.js.map +1 -0
- package/dist/src/drivers/mollie.d.ts +125 -0
- package/dist/src/drivers/mollie.d.ts.map +1 -0
- package/dist/src/drivers/mollie.js +812 -0
- package/dist/src/drivers/mollie.js.map +1 -0
- package/dist/src/drivers/paddle.d.ts +148 -0
- package/dist/src/drivers/paddle.d.ts.map +1 -0
- package/dist/src/drivers/paddle.js +738 -0
- package/dist/src/drivers/paddle.js.map +1 -0
- package/dist/src/drivers/pagarme.d.ts +97 -0
- package/dist/src/drivers/pagarme.d.ts.map +1 -0
- package/dist/src/drivers/pagarme.js +829 -0
- package/dist/src/drivers/pagarme.js.map +1 -0
- package/dist/src/drivers/pagbank.d.ts +57 -0
- package/dist/src/drivers/pagbank.d.ts.map +1 -0
- package/dist/src/drivers/pagbank.js +499 -0
- package/dist/src/drivers/pagbank.js.map +1 -0
- package/dist/src/drivers/paypal.d.ts +120 -0
- package/dist/src/drivers/paypal.d.ts.map +1 -0
- package/dist/src/drivers/paypal.js +891 -0
- package/dist/src/drivers/paypal.js.map +1 -0
- package/dist/src/drivers/polar.d.ts +118 -0
- package/dist/src/drivers/polar.d.ts.map +1 -0
- package/dist/src/drivers/polar.js +604 -0
- package/dist/src/drivers/polar.js.map +1 -0
- package/dist/src/drivers/razorpay.d.ts +103 -0
- package/dist/src/drivers/razorpay.d.ts.map +1 -0
- package/dist/src/drivers/razorpay.js +867 -0
- package/dist/src/drivers/razorpay.js.map +1 -0
- package/dist/src/drivers/shared.d.ts +33 -0
- package/dist/src/drivers/shared.d.ts.map +1 -0
- package/dist/src/drivers/shared.js +37 -0
- package/dist/src/drivers/shared.js.map +1 -0
- package/dist/src/drivers/square.d.ts +133 -0
- package/dist/src/drivers/square.d.ts.map +1 -0
- package/dist/src/drivers/square.js +905 -0
- package/dist/src/drivers/square.js.map +1 -0
- package/dist/src/drivers/stripe.d.ts +40 -3
- package/dist/src/drivers/stripe.d.ts.map +1 -1
- package/dist/src/drivers/stripe.js +806 -25
- package/dist/src/drivers/stripe.js.map +1 -1
- package/dist/src/drivers/woovi.d.ts +10 -3
- package/dist/src/drivers/woovi.d.ts.map +1 -1
- package/dist/src/drivers/woovi.js +162 -29
- package/dist/src/drivers/woovi.js.map +1 -1
- package/dist/src/ensure_customer.d.ts +34 -1
- package/dist/src/ensure_customer.d.ts.map +1 -1
- package/dist/src/ensure_customer.js +29 -4
- package/dist/src/ensure_customer.js.map +1 -1
- package/dist/src/http.d.ts +37 -3
- package/dist/src/http.d.ts.map +1 -1
- package/dist/src/http.js +69 -5
- package/dist/src/http.js.map +1 -1
- package/dist/src/index.d.ts +40 -10
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +22 -4
- package/dist/src/index.js.map +1 -1
- package/dist/src/invoice/emit_invoice.d.ts.map +1 -1
- package/dist/src/invoice/emit_invoice.js +31 -11
- package/dist/src/invoice/emit_invoice.js.map +1 -1
- package/dist/src/money.d.ts +23 -5
- package/dist/src/money.d.ts.map +1 -1
- package/dist/src/money.js +72 -7
- package/dist/src/money.js.map +1 -1
- package/dist/src/payments_manager.d.ts +8 -1
- package/dist/src/payments_manager.d.ts.map +1 -1
- package/dist/src/payments_manager.js +168 -6
- package/dist/src/payments_manager.js.map +1 -1
- package/dist/src/services/main.d.ts +38 -0
- package/dist/src/services/main.d.ts.map +1 -1
- package/dist/src/services/main.js +130 -0
- package/dist/src/services/main.js.map +1 -1
- package/dist/src/telescope/index.d.ts +2 -0
- package/dist/src/telescope/index.d.ts.map +1 -1
- package/dist/src/telescope/index.js +1 -0
- package/dist/src/telescope/index.js.map +1 -1
- package/dist/src/telescope/payments_watcher.d.ts +10 -4
- package/dist/src/telescope/payments_watcher.d.ts.map +1 -1
- package/dist/src/telescope/payments_watcher.js +65 -13
- package/dist/src/telescope/payments_watcher.js.map +1 -1
- package/dist/src/telescope/register.d.ts +62 -0
- package/dist/src/telescope/register.d.ts.map +1 -0
- package/dist/src/telescope/register.js +48 -0
- package/dist/src/telescope/register.js.map +1 -0
- package/dist/src/testing/fake_payments.d.ts +62 -0
- package/dist/src/testing/fake_payments.d.ts.map +1 -0
- package/dist/src/testing/fake_payments.js +82 -0
- package/dist/src/testing/fake_payments.js.map +1 -0
- package/dist/src/testing/in_memory_billing_store.d.ts +151 -2
- package/dist/src/testing/in_memory_billing_store.d.ts.map +1 -1
- package/dist/src/testing/in_memory_billing_store.js +517 -4
- package/dist/src/testing/in_memory_billing_store.js.map +1 -1
- package/dist/src/testing/index.d.ts +2 -1
- package/dist/src/testing/index.d.ts.map +1 -1
- package/dist/src/testing/index.js +1 -0
- package/dist/src/testing/index.js.map +1 -1
- package/dist/src/types.d.ts +161 -5
- package/dist/src/types.d.ts.map +1 -1
- package/dist/src/types.js +30 -1
- package/dist/src/types.js.map +1 -1
- package/dist/src/webhook_handlers.d.ts +96 -2
- package/dist/src/webhook_handlers.d.ts.map +1 -1
- package/dist/src/webhook_handlers.js +91 -10
- package/dist/src/webhook_handlers.js.map +1 -1
- package/dist/src/webhook_security.d.ts +94 -3
- package/dist/src/webhook_security.d.ts.map +1 -1
- package/dist/src/webhook_security.js +173 -9
- package/dist/src/webhook_security.js.map +1 -1
- package/dist/stubs/config/payments.stub +13 -0
- package/dist/stubs/config/payments_client.stub +42 -0
- package/dist/stubs/config/payments_dashboard.stub +38 -0
- package/dist/stubs/database/migrations/create_billing_tables.stub +35 -77
- package/dist/stubs/make/webhook_handler/main.stub +29 -11
- 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
|