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