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