@adonis-agora/payments 0.2.0 → 0.4.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 +2375 -0
- package/dist/assets/spa/assets/index-B7rU8eIL.css +2 -0
- package/dist/assets/spa/assets/index-BcM2VH5k.js +10 -0
- package/dist/assets/spa/assets/index-BcM2VH5k.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/make_billable.d.ts.map +1 -1
- package/dist/commands/make_billable.js +1 -1
- package/dist/commands/make_billable.js.map +1 -1
- package/dist/commands/make_webhook_handler.d.ts.map +1 -1
- package/dist/commands/make_webhook_handler.js +1 -1
- package/dist/commands/make_webhook_handler.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/commands/payments_webhook.d.ts.map +1 -1
- package/dist/commands/payments_webhook.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 +4 -3
- package/dist/src/billing/index.d.ts.map +1 -1
- package/dist/src/billing/index.js +2 -1
- 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 +5 -2
- package/dist/src/billing/mixins/index.d.ts.map +1 -1
- package/dist/src/billing/mixins/index.js +5 -2
- 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_billable.d.ts.map +1 -1
- 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 +15 -0
- package/dist/src/client/index.d.ts.map +1 -0
- package/dist/src/client/index.js +12 -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 +17 -0
- package/dist/src/dashboard/index.d.ts.map +1 -0
- package/dist/src/dashboard/index.js +13 -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 +245 -10
- 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 +13 -3
- 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 +53 -3
- 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 +41 -4
- package/dist/src/drivers/stripe.d.ts.map +1 -1
- package/dist/src/drivers/stripe.js +823 -26
- package/dist/src/drivers/stripe.js.map +1 -1
- package/dist/src/drivers/woovi.d.ts +11 -4
- 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/hooks/webhook_handlers.d.ts.map +1 -1
- package/dist/src/hooks/webhook_handlers.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 +54 -26
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +29 -12
- package/dist/src/index.js.map +1 -1
- package/dist/src/invoice/drivers/asaas.d.ts.map +1 -1
- package/dist/src/invoice/drivers/asaas.js.map +1 -1
- package/dist/src/invoice/drivers/enotas.d.ts.map +1 -1
- package/dist/src/invoice/drivers/enotas.js.map +1 -1
- package/dist/src/invoice/drivers/focus.d.ts.map +1 -1
- package/dist/src/invoice/drivers/focus.js.map +1 -1
- package/dist/src/invoice/drivers/plugnotas.d.ts.map +1 -1
- package/dist/src/invoice/drivers/plugnotas.js.map +1 -1
- package/dist/src/invoice/drivers/tecnospeed.d.ts.map +1 -1
- package/dist/src/invoice/drivers/tecnospeed.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/invoice/invoice_manager.d.ts.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 +66 -14
- 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/fake_payments_driver.d.ts +1 -1
- package/dist/src/testing/fake_payments_driver.d.ts.map +1 -1
- package/dist/src/testing/fake_payments_driver.js.map +1 -1
- 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 +3 -2
- 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/testing/mutable_clock.d.ts.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/main.js.map +1 -1
- package/dist/stubs/make/webhook_handler/main.stub +29 -11
- package/package.json +67 -32
|
@@ -0,0 +1,891 @@
|
|
|
1
|
+
import { publishPaymentDiagnostics, publishRefundDiagnostics, publishSubscriptionDiagnostics, } from '../diagnostics.js';
|
|
2
|
+
import { headerValue, httpRequest, isNotFound } from '../http.js';
|
|
3
|
+
import { emitInvoiceIfRequested } from '../invoice/emit_invoice.js';
|
|
4
|
+
import { formatDecimal, fromDecimal } from '../money.js';
|
|
5
|
+
import { requireCredential, requireCurrency } from './shared.js';
|
|
6
|
+
/**
|
|
7
|
+
* The two currencies where PayPal disagrees with ISO 4217. PayPal's currency-codes table
|
|
8
|
+
* marks HUF, JPY and TWD as "does not support decimals"; ISO gives HUF and TWD two minor
|
|
9
|
+
* units, so `formatDecimal` would send `1990.00` and PayPal would reject it. JPY needs no
|
|
10
|
+
* entry here — ISO already says zero.
|
|
11
|
+
*/
|
|
12
|
+
const PAYPAL_UNDIVIDED_CURRENCIES = new Set(['huf', 'twd']);
|
|
13
|
+
/** Refresh the OAuth token this many ms before PayPal's own expiry. */
|
|
14
|
+
const TOKEN_SAFETY_MARGIN_MS = 60_000;
|
|
15
|
+
/** What PayPal's idempotency guidance says a `PayPal-Request-Id` fits in — a UUID. */
|
|
16
|
+
const REQUEST_ID_MAX_LENGTH = 38;
|
|
17
|
+
/**
|
|
18
|
+
* PayPal driver — Orders v2 for money in, Payments v2 for refunds, Subscriptions v1 for
|
|
19
|
+
* recurring billing. Multi-currency, OAuth2 client credentials, plain REST (no SDK).
|
|
20
|
+
*
|
|
21
|
+
* Two things about PayPal shape this driver more than anything else:
|
|
22
|
+
*
|
|
23
|
+
* 1. It is a **wallet**. A payment normally needs the payer to approve it on paypal.com,
|
|
24
|
+
* so {@link createCheckout} is the real entry point; {@link charge} only works against
|
|
25
|
+
* a payment method the payer already vaulted, and refuses otherwise.
|
|
26
|
+
* 2. Webhook verification is an **API call**, not a local HMAC — see {@link parseWebhook}.
|
|
27
|
+
*/
|
|
28
|
+
export class PayPalDriver {
|
|
29
|
+
provider = 'paypal';
|
|
30
|
+
/**
|
|
31
|
+
* `wallet` and `undefined`, and the split is which call is being routed.
|
|
32
|
+
*
|
|
33
|
+
* {@link PayPalDriver.createCheckout} hands the payer to PayPal, who picks the funding
|
|
34
|
+
* source on their own page — a card, a bank, a local method — so `undefined` ("let the
|
|
35
|
+
* customer choose") is the only promise that call can keep. {@link PayPalDriver.charge}
|
|
36
|
+
* is different: it charges a **vaulted PayPal account** (`payment_source.paypal.vault_id`)
|
|
37
|
+
* and nothing else, which is a stored-balance wallet by definition. `credit_card` is
|
|
38
|
+
* still absent — the driver never sends card data, so routing a card charge here would
|
|
39
|
+
* reach a driver that cannot make one.
|
|
40
|
+
*/
|
|
41
|
+
supportedMethods = ['wallet', 'undefined'];
|
|
42
|
+
capabilities = { refunds: true, invoices: false, subscriptions: true };
|
|
43
|
+
#baseUrl;
|
|
44
|
+
#basicAuth;
|
|
45
|
+
#currency;
|
|
46
|
+
#webhookId;
|
|
47
|
+
#invoiceCtx;
|
|
48
|
+
/** Cached client-credentials token. `expiresAt` comes from PayPal's own `expires_in`. */
|
|
49
|
+
#token;
|
|
50
|
+
constructor(ctx, config) {
|
|
51
|
+
this.#invoiceCtx = ctx;
|
|
52
|
+
const clientId = requireCredential({
|
|
53
|
+
driver: 'paypal',
|
|
54
|
+
option: 'clientId',
|
|
55
|
+
env: 'PAYPAL_CLIENT_ID',
|
|
56
|
+
value: config.clientId,
|
|
57
|
+
});
|
|
58
|
+
const clientSecret = requireCredential({
|
|
59
|
+
driver: 'paypal',
|
|
60
|
+
option: 'clientSecret',
|
|
61
|
+
env: 'PAYPAL_CLIENT_SECRET',
|
|
62
|
+
value: config.clientSecret,
|
|
63
|
+
});
|
|
64
|
+
this.#basicAuth = Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
|
|
65
|
+
this.#currency = requireCurrency('paypal', config.currency);
|
|
66
|
+
const sandbox = config.sandbox ?? process.env.NODE_ENV !== 'production';
|
|
67
|
+
this.#baseUrl = sandbox ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com';
|
|
68
|
+
this.#webhookId = config.webhookId ?? process.env.PAYPAL_WEBHOOK_ID;
|
|
69
|
+
}
|
|
70
|
+
// ── Customers ────────────────────────────────────────────────────────────────────────
|
|
71
|
+
async createCustomer(_input) {
|
|
72
|
+
throw new Error('[payments] PayPal has no customer resource to create. A customer only comes into ' +
|
|
73
|
+
'existence as a side effect of vaulting a payment method (`POST /v3/vault/setup-tokens`), ' +
|
|
74
|
+
'and its id is one you choose. Keep customers on your own records and pass the vault ' +
|
|
75
|
+
'token id as `paymentMethodId` on the charge.');
|
|
76
|
+
}
|
|
77
|
+
async findCustomer(_customerId) {
|
|
78
|
+
throw new Error('[payments] PayPal cannot look a customer up. The Vault only lists the payment tokens ' +
|
|
79
|
+
'belonging to a customer id you assigned (`GET /v3/vault/payment-tokens?customer_id=`); ' +
|
|
80
|
+
'there is no customer to read back.');
|
|
81
|
+
}
|
|
82
|
+
async updateCustomer(_customerId, _input) {
|
|
83
|
+
throw new Error('[payments] PayPal has no customer resource to update. Keep customer details on your ' +
|
|
84
|
+
'own records.');
|
|
85
|
+
}
|
|
86
|
+
// ── Payments ─────────────────────────────────────────────────────────────────────────
|
|
87
|
+
/**
|
|
88
|
+
* Charge a vaulted payment method server-to-server. PayPal calls this the "single shot"
|
|
89
|
+
* flow: an order with `payment_source.paypal.vault_id` is created and captured without
|
|
90
|
+
* the payer being present. Everything else needs approval on paypal.com, which no
|
|
91
|
+
* server-side call can stand in for.
|
|
92
|
+
*/
|
|
93
|
+
async charge(input) {
|
|
94
|
+
const vaultId = input.paymentMethodId ??
|
|
95
|
+
input.card?.token ??
|
|
96
|
+
(typeof input.metadata?.vaultId === 'string' ? input.metadata.vaultId : undefined);
|
|
97
|
+
if (vaultId === undefined) {
|
|
98
|
+
throw new Error('[payments] PayPal cannot charge without the payer approving the payment. Use ' +
|
|
99
|
+
'`createCheckout()` and redirect them, or pass an already-vaulted payment method ' +
|
|
100
|
+
'token as `paymentMethodId` for a server-side charge.');
|
|
101
|
+
}
|
|
102
|
+
if (input.method !== undefined && input.method !== 'wallet' && input.method !== 'undefined') {
|
|
103
|
+
throw new Error(`[payments] PayPal charges the vaulted PayPal account behind the token and nothing else, so "${input.method}" is a promise this call cannot keep — the funding source is whatever the payer has attached to that account. Drop \`method\`, or route the charge as \`wallet\`.`);
|
|
104
|
+
}
|
|
105
|
+
// PayPal documents `PayPal-Request-Id` as mandatory for single-step create-order calls.
|
|
106
|
+
// Generating one here would defeat it: a retry would mint a new key and charge twice.
|
|
107
|
+
if (input.idempotencyKey === undefined) {
|
|
108
|
+
throw new Error('[payments] PayPal requires an idempotency key on a vaulted server-side charge — ' +
|
|
109
|
+
'pass `idempotencyKey`. It becomes the `PayPal-Request-Id` header, which is the ' +
|
|
110
|
+
'only thing PayPal deduplicates a repeated charge on.');
|
|
111
|
+
}
|
|
112
|
+
const currency = input.currency ?? this.#currency;
|
|
113
|
+
const body = {
|
|
114
|
+
intent: 'CAPTURE',
|
|
115
|
+
purchase_units: [
|
|
116
|
+
{
|
|
117
|
+
amount: {
|
|
118
|
+
currency_code: currency.toUpperCase(),
|
|
119
|
+
value: this.#toValue(input.amount, currency),
|
|
120
|
+
},
|
|
121
|
+
...(input.externalReference !== undefined ? { custom_id: input.externalReference } : {}),
|
|
122
|
+
...(input.description !== undefined ? { description: input.description } : {}),
|
|
123
|
+
},
|
|
124
|
+
],
|
|
125
|
+
payment_source: { paypal: { vault_id: vaultId } },
|
|
126
|
+
};
|
|
127
|
+
let order = await this.#request('/v2/checkout/orders', {
|
|
128
|
+
method: 'POST',
|
|
129
|
+
body,
|
|
130
|
+
headers: { ...this.#requestId(input.idempotencyKey), Prefer: 'return=representation' },
|
|
131
|
+
});
|
|
132
|
+
// A vaulted CAPTURE order usually comes back COMPLETED; when PayPal only authorized it,
|
|
133
|
+
// capture explicitly rather than reporting a charge that never took the money.
|
|
134
|
+
if (order.status !== 'COMPLETED') {
|
|
135
|
+
order = await this.#request(`/v2/checkout/orders/${order.id}/capture`, {
|
|
136
|
+
method: 'POST',
|
|
137
|
+
headers: { ...this.#requestId(input.idempotencyKey), Prefer: 'return=representation' },
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
const capture = order.purchase_units?.[0]?.payments?.captures?.[0];
|
|
141
|
+
if (capture === undefined) {
|
|
142
|
+
throw new Error(`[payments] PayPal order ${order.id} came back "${order.status}" with no capture. ` +
|
|
143
|
+
`The money was not taken — inspect order ${order.id} before retrying.`);
|
|
144
|
+
}
|
|
145
|
+
const payment = this.#mapCapture(capture, currency);
|
|
146
|
+
// The order was created with `payment_source.paypal`, so the money came out of a PayPal
|
|
147
|
+
// account. `order.payment_source` says which one when PayPal echoes it back.
|
|
148
|
+
payment.method = this.#mapPaymentSource(order.payment_source) ?? 'wallet';
|
|
149
|
+
await emitInvoiceIfRequested(this.#invoiceCtx, input, payment, this);
|
|
150
|
+
publishPaymentDiagnostics(payment);
|
|
151
|
+
return payment;
|
|
152
|
+
}
|
|
153
|
+
/** Find a payment by its **capture** id — the id `charge()` and the webhooks return. */
|
|
154
|
+
async findPayment(gatewayId) {
|
|
155
|
+
try {
|
|
156
|
+
const capture = await this.#request(`/v2/payments/captures/${gatewayId}`);
|
|
157
|
+
return this.#mapCapture(capture, this.#currency);
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
if (isNotFound(error))
|
|
161
|
+
return null;
|
|
162
|
+
throw error;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
async refund(paymentGatewayId, amount, options) {
|
|
166
|
+
const currency = this.#currency;
|
|
167
|
+
// An empty body means "refund everything left"; an `amount` makes it partial.
|
|
168
|
+
const body = amount !== undefined
|
|
169
|
+
? {
|
|
170
|
+
amount: {
|
|
171
|
+
currency_code: currency.toUpperCase(),
|
|
172
|
+
value: this.#toValue(amount, currency),
|
|
173
|
+
},
|
|
174
|
+
}
|
|
175
|
+
: {};
|
|
176
|
+
const data = await this.#request(`/v2/payments/captures/${paymentGatewayId}/refund`, {
|
|
177
|
+
method: 'POST',
|
|
178
|
+
body,
|
|
179
|
+
headers: {
|
|
180
|
+
Prefer: 'return=representation',
|
|
181
|
+
// The refund endpoint documents `PayPal-Request-Id`, the same header the charge
|
|
182
|
+
// uses. Without it a retried refund job refunds the capture twice.
|
|
183
|
+
...this.#requestId(options?.idempotencyKey),
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
const refund = {
|
|
187
|
+
id: data.id,
|
|
188
|
+
gatewayId: data.id,
|
|
189
|
+
provider: this.provider,
|
|
190
|
+
amount: this.#mapMoney(data.amount, currency),
|
|
191
|
+
status: data.status === 'COMPLETED'
|
|
192
|
+
? 'succeeded'
|
|
193
|
+
: data.status === 'FAILED' || data.status === 'CANCELLED'
|
|
194
|
+
? 'failed'
|
|
195
|
+
: 'pending',
|
|
196
|
+
createdAt: data.create_time ?? new Date().toISOString(),
|
|
197
|
+
};
|
|
198
|
+
publishRefundDiagnostics(refund);
|
|
199
|
+
return refund;
|
|
200
|
+
}
|
|
201
|
+
// ── Checkout ─────────────────────────────────────────────────────────────────────────
|
|
202
|
+
/**
|
|
203
|
+
* The real entry point for PayPal. Returns the URL to send the payer to; the money only
|
|
204
|
+
* moves once they approve it there and you capture the order (or, for `planId`, once
|
|
205
|
+
* they approve the subscription).
|
|
206
|
+
*/
|
|
207
|
+
async createCheckout(input) {
|
|
208
|
+
const currency = input.currency ?? this.#currency;
|
|
209
|
+
// `custom_id` and not `reference_id`: the capture PayPal sends on the webhook carries
|
|
210
|
+
// `custom_id`, while `reference_id` stays behind on the purchase unit.
|
|
211
|
+
const externalReference = input.externalReference;
|
|
212
|
+
if (input.planId !== undefined) {
|
|
213
|
+
const subscription = await this.#createSubscription({
|
|
214
|
+
planId: input.planId,
|
|
215
|
+
returnUrl: input.successUrl,
|
|
216
|
+
...(input.cancelUrl !== undefined ? { cancelUrl: input.cancelUrl } : {}),
|
|
217
|
+
...(externalReference !== undefined ? { customId: externalReference } : {}),
|
|
218
|
+
...(input.idempotencyKey !== undefined ? { requestId: input.idempotencyKey } : {}),
|
|
219
|
+
});
|
|
220
|
+
return {
|
|
221
|
+
id: subscription.id,
|
|
222
|
+
gatewayId: subscription.id,
|
|
223
|
+
provider: this.provider,
|
|
224
|
+
url: this.#approvalUrl(subscription.links),
|
|
225
|
+
status: subscription.status === 'ACTIVE' ? 'complete' : 'open',
|
|
226
|
+
subscriptionId: subscription.id,
|
|
227
|
+
...(input.customerId !== undefined ? { customerId: input.customerId } : {}),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
const body = {
|
|
231
|
+
intent: 'CAPTURE',
|
|
232
|
+
purchase_units: [
|
|
233
|
+
{
|
|
234
|
+
amount: {
|
|
235
|
+
currency_code: currency.toUpperCase(),
|
|
236
|
+
value: this.#toValue(input.amount, currency),
|
|
237
|
+
},
|
|
238
|
+
...(externalReference !== undefined ? { custom_id: externalReference } : {}),
|
|
239
|
+
...(input.description !== undefined ? { description: input.description } : {}),
|
|
240
|
+
},
|
|
241
|
+
],
|
|
242
|
+
payment_source: {
|
|
243
|
+
paypal: {
|
|
244
|
+
experience_context: {
|
|
245
|
+
return_url: input.successUrl,
|
|
246
|
+
...(input.cancelUrl !== undefined ? { cancel_url: input.cancelUrl } : {}),
|
|
247
|
+
user_action: 'PAY_NOW',
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
};
|
|
252
|
+
const order = await this.#request('/v2/checkout/orders', {
|
|
253
|
+
method: 'POST',
|
|
254
|
+
body,
|
|
255
|
+
headers: {
|
|
256
|
+
Prefer: 'return=representation',
|
|
257
|
+
...this.#requestId(input.idempotencyKey),
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
return {
|
|
261
|
+
id: order.id,
|
|
262
|
+
gatewayId: order.id,
|
|
263
|
+
provider: this.provider,
|
|
264
|
+
url: this.#approvalUrl(order.links),
|
|
265
|
+
status: order.status === 'COMPLETED' ? 'complete' : 'open',
|
|
266
|
+
amount: { amount: input.amount, currency },
|
|
267
|
+
...(input.customerId !== undefined ? { customerId: input.customerId } : {}),
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
// ── Subscriptions ────────────────────────────────────────────────────────────────────
|
|
271
|
+
/**
|
|
272
|
+
* Create a subscription against an existing PayPal **plan** (`P-…`). It comes back
|
|
273
|
+
* `APPROVAL_PENDING`: the payer still has to approve it, and the link to send them to
|
|
274
|
+
* is in `subscription.payload.links` (rel `approve`). `createCheckout({ planId })`
|
|
275
|
+
* returns that URL directly and is usually what you want.
|
|
276
|
+
*/
|
|
277
|
+
async createSubscription(input) {
|
|
278
|
+
// Amount, cycle and trial all live on the PayPal plan, not on the subscription.
|
|
279
|
+
// Accepting them here would report a price the gateway never charges.
|
|
280
|
+
const planLevel = [
|
|
281
|
+
input.amount !== undefined ? 'amount' : undefined,
|
|
282
|
+
input.cycle !== undefined ? 'cycle' : undefined,
|
|
283
|
+
input.trialDays !== undefined ? 'trialDays' : undefined,
|
|
284
|
+
].filter((field) => field !== undefined);
|
|
285
|
+
if (planLevel.length > 0) {
|
|
286
|
+
throw new Error(`[payments] PayPal prices subscriptions on the plan, so ${planLevel.join(', ')} cannot ` +
|
|
287
|
+
`be applied here. Create a plan with that price, cycle and trial (\`POST /v1/billing/plans\`) and pass its \`P-…\` id as \`planId\` (yours was ${input.planId}).`);
|
|
288
|
+
}
|
|
289
|
+
const data = await this.#createSubscription({
|
|
290
|
+
planId: input.planId,
|
|
291
|
+
...(input.externalReference !== undefined ? { customId: input.externalReference } : {}),
|
|
292
|
+
...(input.startDate !== undefined ? { startTime: input.startDate } : {}),
|
|
293
|
+
// `POST /v1/billing/subscriptions` takes `PayPal-Request-Id` and holds the key for 72
|
|
294
|
+
// hours; without it a retry starts a second subscription billing the same person.
|
|
295
|
+
...(input.idempotencyKey !== undefined ? { requestId: input.idempotencyKey } : {}),
|
|
296
|
+
});
|
|
297
|
+
const subscription = this.#mapSubscription(data);
|
|
298
|
+
publishSubscriptionDiagnostics(subscription, 'subscription.created');
|
|
299
|
+
return subscription;
|
|
300
|
+
}
|
|
301
|
+
async cancelSubscription(subscriptionGatewayId, options) {
|
|
302
|
+
if (options?.atPeriodEnd === true) {
|
|
303
|
+
throw new Error('[payments] PayPal cancels a subscription immediately — there is no cancel-at-period-end ' +
|
|
304
|
+
'flag. Cancel it when the period ends, or suspend it (`POST /v1/billing/subscriptions/' +
|
|
305
|
+
'{id}/suspend`) and keep the grace period on your own records.');
|
|
306
|
+
}
|
|
307
|
+
await this.#requestNoContent(`/v1/billing/subscriptions/${subscriptionGatewayId}/cancel`, {
|
|
308
|
+
reason: 'Canceled by the merchant.',
|
|
309
|
+
});
|
|
310
|
+
// The cancel call answers 204 with no body; read the subscription back rather than
|
|
311
|
+
// inventing the post-cancel state.
|
|
312
|
+
const data = await this.#request(`/v1/billing/subscriptions/${subscriptionGatewayId}`);
|
|
313
|
+
const subscription = this.#mapSubscription(data);
|
|
314
|
+
publishSubscriptionDiagnostics(subscription, 'subscription.canceled');
|
|
315
|
+
return subscription;
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Change the recurring price. PayPal applies it as a JSON Patch against the plan's
|
|
319
|
+
* REGULAR billing cycle — the sequence is read off the subscription first, because
|
|
320
|
+
* patching sequence 1 blindly rewrites the *trial* price on a plan that has one.
|
|
321
|
+
*/
|
|
322
|
+
async updateSubscription(subscriptionGatewayId, input) {
|
|
323
|
+
if (input.idempotencyKey !== undefined) {
|
|
324
|
+
throw new Error('[payments] PayPal does not deduplicate a subscription update: `PATCH /v1/billing/subscriptions/{id}` documents no `PayPal-Request-Id`, unlike the create call. The patch is a price replacement, so a repeat is harmless in itself — but accepting a key the API never sees would promise a guarantee nothing enforces, so it is refused rather than dropped.');
|
|
325
|
+
}
|
|
326
|
+
if (input.description !== undefined) {
|
|
327
|
+
throw new Error('[payments] PayPal subscriptions have no description — it belongs to the catalog ' +
|
|
328
|
+
'product behind the plan. Update the product, or keep the description on your ' +
|
|
329
|
+
'own record.');
|
|
330
|
+
}
|
|
331
|
+
if (input.amount === undefined) {
|
|
332
|
+
throw new Error('[payments] PayPal subscription update needs an `amount`; there is nothing else on ' +
|
|
333
|
+
'the shared input it can change.');
|
|
334
|
+
}
|
|
335
|
+
const current = await this.#request(`/v1/billing/subscriptions/${subscriptionGatewayId}?fields=plan`);
|
|
336
|
+
const regular = current.plan?.billing_cycles?.find((cycle) => cycle.tenure_type === 'REGULAR' && cycle.sequence !== undefined);
|
|
337
|
+
if (regular === undefined) {
|
|
338
|
+
throw new Error(`[payments] PayPal subscription ${subscriptionGatewayId} exposes no REGULAR billing ` +
|
|
339
|
+
`cycle to reprice. Change the price on the plan behind ${subscriptionGatewayId}, or move the subscriber to a new plan (\`POST /v1/billing/subscriptions/{id}/revise\`).`);
|
|
340
|
+
}
|
|
341
|
+
const currency = regular.pricing_scheme?.fixed_price?.currency_code?.toLowerCase() ?? this.#currency;
|
|
342
|
+
const patch = [
|
|
343
|
+
{
|
|
344
|
+
op: 'replace',
|
|
345
|
+
path: `/plan/billing_cycles/@sequence==${regular.sequence}/pricing_scheme/fixed_price`,
|
|
346
|
+
value: {
|
|
347
|
+
currency_code: currency.toUpperCase(),
|
|
348
|
+
value: this.#toValue(input.amount, currency),
|
|
349
|
+
},
|
|
350
|
+
},
|
|
351
|
+
];
|
|
352
|
+
await this.#requestNoContent(`/v1/billing/subscriptions/${subscriptionGatewayId}`, patch, {
|
|
353
|
+
method: 'PATCH',
|
|
354
|
+
});
|
|
355
|
+
const data = await this.#request(`/v1/billing/subscriptions/${subscriptionGatewayId}?fields=plan`);
|
|
356
|
+
return this.#mapSubscription(data);
|
|
357
|
+
}
|
|
358
|
+
async findSubscription(gatewayId) {
|
|
359
|
+
try {
|
|
360
|
+
const data = await this.#request(`/v1/billing/subscriptions/${gatewayId}?fields=plan`);
|
|
361
|
+
return this.#mapSubscription(data);
|
|
362
|
+
}
|
|
363
|
+
catch (error) {
|
|
364
|
+
if (isNotFound(error))
|
|
365
|
+
return null;
|
|
366
|
+
throw error;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
// ── Invoices ─────────────────────────────────────────────────────────────────────────
|
|
370
|
+
async listInvoices(_customerId) {
|
|
371
|
+
throw new Error('[payments] PayPal cannot list invoices for a customer: `GET /v2/invoicing/invoices` ' +
|
|
372
|
+
'takes only paging, and `POST /v2/invoicing/search-invoices` filters by recipient ' +
|
|
373
|
+
'email, not by a customer id. Search by email yourself, or use an invoice provider ' +
|
|
374
|
+
'with `invoice: true`.');
|
|
375
|
+
}
|
|
376
|
+
// ── Webhooks ─────────────────────────────────────────────────────────────────────────
|
|
377
|
+
/**
|
|
378
|
+
* Verify the webhook with PayPal and normalize it.
|
|
379
|
+
*
|
|
380
|
+
* PayPal has no local signature to check: verification is a POST to
|
|
381
|
+
* `/v1/notifications/verify-webhook-signature`, so **every webhook costs a round trip**
|
|
382
|
+
* (plus the OAuth call when the cached token has expired). That is PayPal's scheme —
|
|
383
|
+
* there is no offline HMAC to substitute for it.
|
|
384
|
+
*
|
|
385
|
+
* Without a `webhookId` there is nothing to verify against at all, so the event is
|
|
386
|
+
* parsed unverified — the same "unconfigured means unenforced" rule the other drivers
|
|
387
|
+
* follow, and what makes local development work without a dashboard webhook.
|
|
388
|
+
*/
|
|
389
|
+
/**
|
|
390
|
+
* Whether a delivery to `POST /payments/webhook/:provider` can be authenticated.
|
|
391
|
+
*
|
|
392
|
+
* Without the webhook id `parseWebhook` normalizes the body without ever calling PayPal's
|
|
393
|
+
* verify-signature endpoint.
|
|
394
|
+
*/
|
|
395
|
+
get webhookVerification() {
|
|
396
|
+
return this.#webhookId !== undefined ? 'configured' : 'unconfigured';
|
|
397
|
+
}
|
|
398
|
+
async parseWebhook(rawBody, headers) {
|
|
399
|
+
if (this.#webhookId === undefined)
|
|
400
|
+
return this.#normalize(rawBody);
|
|
401
|
+
const body = {
|
|
402
|
+
auth_algo: headerValue(headers, 'paypal-auth-algo') ?? '',
|
|
403
|
+
cert_url: headerValue(headers, 'paypal-cert-url') ?? '',
|
|
404
|
+
transmission_id: headerValue(headers, 'paypal-transmission-id') ?? '',
|
|
405
|
+
transmission_sig: headerValue(headers, 'paypal-transmission-sig') ?? '',
|
|
406
|
+
transmission_time: headerValue(headers, 'paypal-transmission-time') ?? '',
|
|
407
|
+
webhook_id: this.#webhookId,
|
|
408
|
+
// PayPal takes the event as JSON, not as the raw string; it re-serializes it its own
|
|
409
|
+
// way, so a body that does not parse can never verify.
|
|
410
|
+
webhook_event: JSON.parse(rawBody),
|
|
411
|
+
};
|
|
412
|
+
const result = await this.#request('/v1/notifications/verify-webhook-signature', { method: 'POST', body });
|
|
413
|
+
if (result.verification_status !== 'SUCCESS') {
|
|
414
|
+
throw new Error('[payments] Invalid PayPal webhook signature.');
|
|
415
|
+
}
|
|
416
|
+
return this.#normalize(rawBody);
|
|
417
|
+
}
|
|
418
|
+
// ── Mappers ──────────────────────────────────────────────────────────────────────────
|
|
419
|
+
#normalize(rawBody) {
|
|
420
|
+
const payload = JSON.parse(rawBody);
|
|
421
|
+
const eventType = payload.event_type ?? 'unknown';
|
|
422
|
+
const resource = payload.resource ?? {};
|
|
423
|
+
// The dispute events decide their type from the resource (the lifecycle stage), and
|
|
424
|
+
// the payload then has to know which type it is producing to carry the outcome.
|
|
425
|
+
const type = this.#mapWebhookType(eventType, resource);
|
|
426
|
+
return {
|
|
427
|
+
id: payload.id ?? `${eventType}-${Date.now()}`,
|
|
428
|
+
provider: this.provider,
|
|
429
|
+
type,
|
|
430
|
+
createdAt: payload.create_time ?? new Date().toISOString(),
|
|
431
|
+
data: this.#mapWebhookData(eventType, resource, type),
|
|
432
|
+
raw: payload,
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* PayPal's `payment_source` → the canonical {@link PaymentMethodType}.
|
|
437
|
+
*
|
|
438
|
+
* Exactly one key of the object is populated, and it names the funding source: `paypal`
|
|
439
|
+
* and `venmo` are stored-balance wallets, `apple_pay`/`google_pay` device ones, `card`
|
|
440
|
+
* is a card, and the European local methods push from the payer's own bank. Anything
|
|
441
|
+
* unrecognized is left unset rather than guessed at.
|
|
442
|
+
*/
|
|
443
|
+
#mapPaymentSource(source) {
|
|
444
|
+
if (source === undefined)
|
|
445
|
+
return undefined;
|
|
446
|
+
const key = Object.keys(source)[0];
|
|
447
|
+
switch (key) {
|
|
448
|
+
case 'paypal':
|
|
449
|
+
case 'venmo':
|
|
450
|
+
case 'apple_pay':
|
|
451
|
+
case 'google_pay':
|
|
452
|
+
return 'wallet';
|
|
453
|
+
case 'card':
|
|
454
|
+
return 'card';
|
|
455
|
+
case 'ideal':
|
|
456
|
+
case 'bancontact':
|
|
457
|
+
case 'eps':
|
|
458
|
+
case 'giropay':
|
|
459
|
+
case 'p24':
|
|
460
|
+
case 'sofort':
|
|
461
|
+
case 'blik':
|
|
462
|
+
case 'trustly':
|
|
463
|
+
case 'multibanco':
|
|
464
|
+
case 'mybank':
|
|
465
|
+
return 'bank_transfer';
|
|
466
|
+
default:
|
|
467
|
+
return undefined;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
#mapWebhookType(eventType, resource) {
|
|
471
|
+
switch (eventType) {
|
|
472
|
+
case 'PAYMENT.CAPTURE.COMPLETED':
|
|
473
|
+
case 'PAYMENT.SALE.COMPLETED':
|
|
474
|
+
return 'payment.succeeded';
|
|
475
|
+
case 'PAYMENT.CAPTURE.DECLINED':
|
|
476
|
+
case 'PAYMENT.SALE.DENIED':
|
|
477
|
+
return 'payment.failed';
|
|
478
|
+
// ── The dispute family ───────────────────────────────────────────────────────────
|
|
479
|
+
// PayPal's dispute is NOT a card-scheme chargeback: it has a lifecycle of its own,
|
|
480
|
+
// and `CUSTOMER.DISPUTE.CREATED` fires at two very different points in it. PayPal's
|
|
481
|
+
// own sandbox guide has you assert `dispute_life_cycle_stage` is `INQUIRY` for one
|
|
482
|
+
// test and `CHARGEBACK` for the next, on the same event.
|
|
483
|
+
//
|
|
484
|
+
// `INQUIRY` is PayPal's own words "a customer and merchant interact in an attempt to
|
|
485
|
+
// resolve a dispute without escalation to PayPal" — a 20-day window in the Resolution
|
|
486
|
+
// Center that a refund closes. Nothing is adjudicated and nothing is debited; calling
|
|
487
|
+
// it `payment.disputed` moves a paid row over money still in the account, which is
|
|
488
|
+
// the same bug Stripe's inquiry and Adyen's notification-of-chargeback had.
|
|
489
|
+
// `CHARGEBACK` and the two appeal stages are the claim: PayPal is now investigating
|
|
490
|
+
// and will debit the account if it decides for the buyer.
|
|
491
|
+
//
|
|
492
|
+
// With no stage on the resource this stays `payment.disputed` — the mapping it has
|
|
493
|
+
// always had — rather than downgrading a dispute the driver could not read.
|
|
494
|
+
case 'CUSTOMER.DISPUTE.CREATED':
|
|
495
|
+
// The deprecated spelling PayPal's own reference says `CUSTOMER.DISPUTE.CREATED`
|
|
496
|
+
// supersedes; an account still subscribed to it should not silently miss the dispute.
|
|
497
|
+
case 'RISK.DISPUTE.CREATED':
|
|
498
|
+
return this.#disputeStage(resource) === 'INQUIRY'
|
|
499
|
+
? 'payment.dispute_warning'
|
|
500
|
+
: 'payment.disputed';
|
|
501
|
+
// PayPal sends **no dedicated "escalated to a claim" event**: an inquiry becoming a
|
|
502
|
+
// chargeback arrives as a plain `CUSTOMER.DISPUTE.UPDATED` carrying the new stage. It
|
|
503
|
+
// is therefore the only place a row that opened as a warning can learn the money is
|
|
504
|
+
// now at stake, so an UPDATED past the inquiry stage is the chargeback. An UPDATED
|
|
505
|
+
// still in `INQUIRY` (a message, evidence, an offer) moves nothing, and one on an
|
|
506
|
+
// already `RESOLVED` dispute is bookkeeping after the fact.
|
|
507
|
+
case 'CUSTOMER.DISPUTE.UPDATED': {
|
|
508
|
+
const dispute = resource;
|
|
509
|
+
const stage = this.#disputeStage(resource);
|
|
510
|
+
return stage === undefined || stage === 'INQUIRY' || dispute.status === 'RESOLVED'
|
|
511
|
+
? 'payment.updated'
|
|
512
|
+
: 'payment.disputed';
|
|
513
|
+
}
|
|
514
|
+
// The outcome lives in `dispute_outcome.outcome_code`, and only three of the seven
|
|
515
|
+
// codes say who kept the money — see {@link PayPalDriver.#disputeOutcome}. Without a
|
|
516
|
+
// readable one this stays an update rather than inventing a result.
|
|
517
|
+
case 'CUSTOMER.DISPUTE.RESOLVED':
|
|
518
|
+
return this.#disputeOutcome(resource) === undefined
|
|
519
|
+
? 'payment.updated'
|
|
520
|
+
: 'payment.dispute_closed';
|
|
521
|
+
case 'PAYMENT.CAPTURE.REFUNDED':
|
|
522
|
+
case 'PAYMENT.CAPTURE.REVERSED':
|
|
523
|
+
case 'PAYMENT.SALE.REFUNDED':
|
|
524
|
+
case 'PAYMENT.SALE.REVERSED':
|
|
525
|
+
return 'payment.refunded';
|
|
526
|
+
case 'CHECKOUT.ORDER.APPROVED':
|
|
527
|
+
case 'CHECKOUT.ORDER.COMPLETED':
|
|
528
|
+
case 'PAYMENT.CAPTURE.PENDING':
|
|
529
|
+
case 'PAYMENT.SALE.PENDING':
|
|
530
|
+
// An authorization is money HELD, not money moved: PayPal reserves it for about 29
|
|
531
|
+
// days and voids it if nobody captures. There is deliberately no `payment.authorized`
|
|
532
|
+
// event for it to become, so it is an update — the `status: 'authorized'` on the
|
|
533
|
+
// payload is what a handler reads to tell it apart from the others here.
|
|
534
|
+
case 'PAYMENT.AUTHORIZATION.CREATED':
|
|
535
|
+
case 'PAYMENT.AUTHORIZATION.VOIDED':
|
|
536
|
+
return 'payment.updated';
|
|
537
|
+
case 'BILLING.SUBSCRIPTION.CREATED':
|
|
538
|
+
return 'subscription.created';
|
|
539
|
+
case 'BILLING.SUBSCRIPTION.ACTIVATED':
|
|
540
|
+
case 'BILLING.SUBSCRIPTION.UPDATED':
|
|
541
|
+
case 'BILLING.SUBSCRIPTION.SUSPENDED':
|
|
542
|
+
case 'BILLING.SUBSCRIPTION.PAYMENT.FAILED':
|
|
543
|
+
return 'subscription.updated';
|
|
544
|
+
case 'BILLING.SUBSCRIPTION.CANCELLED':
|
|
545
|
+
case 'BILLING.SUBSCRIPTION.EXPIRED':
|
|
546
|
+
return 'subscription.canceled';
|
|
547
|
+
default:
|
|
548
|
+
return eventType.toLowerCase();
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* The dispute's lifecycle stage — `INQUIRY`, `CHARGEBACK`, `PRE_ARBITRATION` or
|
|
553
|
+
* `ARBITRATION` — or `undefined` when the resource is not a dispute or does not carry
|
|
554
|
+
* one.
|
|
555
|
+
*
|
|
556
|
+
* A caveat worth knowing: PayPal documents the stages and the fund holds separately, and
|
|
557
|
+
* never in the same sentence. The dispute guide says PayPal "holds the disputed payment
|
|
558
|
+
* until resolution" for internal disputes, while its own sandbox matrix enumerates both
|
|
559
|
+
* "Dispute NO HOLD" and "Dispute WITH HOLD" scenarios, and only ever says "PayPal debits
|
|
560
|
+
* the merchant's account" on a case resolved in the buyer's favour. So the reference
|
|
561
|
+
* does **not** state that a `CHARGEBACK`-stage dispute has already taken the money — it
|
|
562
|
+
* states that the claim is being adjudicated and that losing it debits the account. The
|
|
563
|
+
* split here follows the stage PayPal itself uses to separate "you and the buyer are
|
|
564
|
+
* talking" from "PayPal is deciding", which is the line an operator can act on.
|
|
565
|
+
*/
|
|
566
|
+
#disputeStage(resource) {
|
|
567
|
+
const stage = resource.dispute_life_cycle_stage;
|
|
568
|
+
return typeof stage === 'string' ? stage : undefined;
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* `dispute_outcome.outcome_code` → the canonical outcome, or `undefined`.
|
|
572
|
+
*
|
|
573
|
+
* PayPal's enum has seven values and only three of them name who ends up with the money:
|
|
574
|
+
* `RESOLVED_SELLER_FAVOUR`, `RESOLVED_BUYER_FAVOUR` and `CANCELED_BY_BUYER` ("the
|
|
575
|
+
* customer canceled the dispute"). The rest deliberately return `undefined`:
|
|
576
|
+
*
|
|
577
|
+
* - `RESOLVED_WITH_PAYOUT` is "PayPal provided the merchant **or customer** with
|
|
578
|
+
* protection and the case is resolved" — it does not say which, and guessing decides
|
|
579
|
+
* whether the row goes back to `paid`.
|
|
580
|
+
* - `ACCEPTED` and `DENIED` are marked DEPRECATED in PayPal's current schema and their
|
|
581
|
+
* descriptions ("PayPal accepted the dispute" / "PayPal denied the dispute") name the
|
|
582
|
+
* dispute rather than the party, unlike every other code in the same enum.
|
|
583
|
+
* - `NONE` is "a dispute was created for the same transaction ID, and the previous
|
|
584
|
+
* dispute was closed **without any decision**", which is the definition of no outcome.
|
|
585
|
+
*
|
|
586
|
+
* Each of those stays `payment.updated` carrying the full resource on `event.raw`.
|
|
587
|
+
*/
|
|
588
|
+
#disputeOutcome(resource) {
|
|
589
|
+
switch (resource.dispute_outcome?.outcome_code) {
|
|
590
|
+
case 'RESOLVED_SELLER_FAVOUR':
|
|
591
|
+
return 'won';
|
|
592
|
+
case 'RESOLVED_BUYER_FAVOUR':
|
|
593
|
+
return 'lost';
|
|
594
|
+
case 'CANCELED_BY_BUYER':
|
|
595
|
+
return 'canceled';
|
|
596
|
+
default:
|
|
597
|
+
return undefined;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
#mapWebhookData(eventType, resource, type) {
|
|
601
|
+
if (eventType.startsWith('BILLING.SUBSCRIPTION.')) {
|
|
602
|
+
const subscription = resource;
|
|
603
|
+
return {
|
|
604
|
+
gatewayId: subscription.id,
|
|
605
|
+
customerId: subscription.subscriber?.payer_id ?? subscription.subscriber?.email_address ?? '',
|
|
606
|
+
status: this.#mapSubscriptionStatus(subscription.status),
|
|
607
|
+
...(subscription.plan_id !== undefined ? { planId: subscription.plan_id } : {}),
|
|
608
|
+
...(subscription.custom_id !== undefined
|
|
609
|
+
? { externalReference: subscription.custom_id }
|
|
610
|
+
: {}),
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
if (eventType.startsWith('PAYMENT.SALE.')) {
|
|
614
|
+
// Sales carry money as `amount.total` + `amount.currency`, and the app's own
|
|
615
|
+
// reference as `custom` — neither spelled the way captures spell them.
|
|
616
|
+
const sale = resource;
|
|
617
|
+
const currency = (sale.amount?.currency ?? this.#currency).toLowerCase();
|
|
618
|
+
return {
|
|
619
|
+
gatewayId: sale.id,
|
|
620
|
+
amount: this.#fromValue(sale.amount?.total, currency),
|
|
621
|
+
currency,
|
|
622
|
+
...(sale.billing_agreement_id !== undefined
|
|
623
|
+
? { subscriptionId: sale.billing_agreement_id }
|
|
624
|
+
: {}),
|
|
625
|
+
...(sale.custom !== undefined ? { externalReference: sale.custom } : {}),
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
if (eventType.endsWith('DISPUTE.CREATED') || eventType.startsWith('CUSTOMER.DISPUTE.')) {
|
|
629
|
+
// The dispute names the money as `dispute_amount` and the payment as the SELLER's
|
|
630
|
+
// side of the disputed transaction — `seller_transaction_id` is the capture id this
|
|
631
|
+
// driver keys payments on, and `buyer_transaction_id` is the buyer's view of the same
|
|
632
|
+
// money, which would find no row here. A dispute can cover several transactions; the
|
|
633
|
+
// first is the one the row is written against and `event.raw` carries the rest.
|
|
634
|
+
const dispute = resource;
|
|
635
|
+
const currency = (dispute.dispute_amount?.currency_code ?? this.#currency).toLowerCase();
|
|
636
|
+
const transaction = dispute.disputed_transactions?.[0];
|
|
637
|
+
const outcome = type === 'payment.dispute_closed' ? this.#disputeOutcome(resource) : undefined;
|
|
638
|
+
return {
|
|
639
|
+
gatewayId: transaction?.seller_transaction_id ?? '',
|
|
640
|
+
amount: this.#fromValue(dispute.dispute_amount?.value, currency),
|
|
641
|
+
currency,
|
|
642
|
+
...(dispute.dispute_id !== undefined ? { disputeId: dispute.dispute_id } : {}),
|
|
643
|
+
...(dispute.reason !== undefined ? { reason: dispute.reason } : {}),
|
|
644
|
+
// The whole value of an inquiry: PayPal closes an unanswered dispute in the
|
|
645
|
+
// customer's favour when this passes, so it is the date the alert is worth acting
|
|
646
|
+
// on. Already RFC 3339, which is the ISO 8601 `actionableUntil` is declared as.
|
|
647
|
+
...(dispute.seller_response_due_date !== undefined
|
|
648
|
+
? { actionableUntil: dispute.seller_response_due_date }
|
|
649
|
+
: {}),
|
|
650
|
+
...(dispute.dispute_life_cycle_stage !== undefined
|
|
651
|
+
? { disputeStage: dispute.dispute_life_cycle_stage }
|
|
652
|
+
: {}),
|
|
653
|
+
...(outcome !== undefined ? { outcome } : {}),
|
|
654
|
+
...(transaction?.custom !== undefined ? { externalReference: transaction.custom } : {}),
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
if (eventType.startsWith('PAYMENT.AUTHORIZATION.')) {
|
|
658
|
+
// An Authorization is shaped like a Capture — `id`, `status`, `amount`, `custom_id` —
|
|
659
|
+
// so the same reader works; what differs is that the money is only held.
|
|
660
|
+
const authorization = resource;
|
|
661
|
+
const currency = (authorization.amount?.currency_code ?? this.#currency).toLowerCase();
|
|
662
|
+
return {
|
|
663
|
+
gatewayId: authorization.id,
|
|
664
|
+
amount: this.#fromValue(authorization.amount?.value, currency),
|
|
665
|
+
currency,
|
|
666
|
+
status: 'authorized',
|
|
667
|
+
...(authorization.custom_id !== undefined
|
|
668
|
+
? { externalReference: authorization.custom_id }
|
|
669
|
+
: {}),
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
if (eventType.startsWith('CHECKOUT.ORDER.')) {
|
|
673
|
+
const order = resource;
|
|
674
|
+
const unit = order.purchase_units?.[0];
|
|
675
|
+
const currency = (unit?.amount?.currency_code ?? this.#currency).toLowerCase();
|
|
676
|
+
return {
|
|
677
|
+
gatewayId: order.id,
|
|
678
|
+
amount: this.#fromValue(unit?.amount?.value, currency),
|
|
679
|
+
currency,
|
|
680
|
+
...(unit?.custom_id !== undefined ? { externalReference: unit.custom_id } : {}),
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
const capture = resource;
|
|
684
|
+
const currency = (capture.amount?.currency_code ?? this.#currency).toLowerCase();
|
|
685
|
+
return {
|
|
686
|
+
gatewayId: capture.id,
|
|
687
|
+
amount: this.#fromValue(capture.amount?.value, currency),
|
|
688
|
+
currency,
|
|
689
|
+
...(capture.custom_id !== undefined ? { externalReference: capture.custom_id } : {}),
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
#mapCapture(data, fallbackCurrency) {
|
|
693
|
+
const statusMap = {
|
|
694
|
+
COMPLETED: 'paid',
|
|
695
|
+
// The canonical set has no partial-refund state and the money did settle, so this
|
|
696
|
+
// stays `paid`; the refund is a Refund of its own.
|
|
697
|
+
PARTIALLY_REFUNDED: 'paid',
|
|
698
|
+
PENDING: 'pending',
|
|
699
|
+
DECLINED: 'failed',
|
|
700
|
+
FAILED: 'failed',
|
|
701
|
+
REFUNDED: 'refunded',
|
|
702
|
+
};
|
|
703
|
+
const result = {
|
|
704
|
+
id: data.id,
|
|
705
|
+
gatewayId: data.id,
|
|
706
|
+
provider: this.provider,
|
|
707
|
+
amount: this.#mapMoney(data.amount, fallbackCurrency),
|
|
708
|
+
status: statusMap[data.status ?? ''] ?? 'pending',
|
|
709
|
+
payload: data,
|
|
710
|
+
createdAt: data.create_time ?? new Date().toISOString(),
|
|
711
|
+
};
|
|
712
|
+
if (data.status === 'COMPLETED') {
|
|
713
|
+
result.paidAt = data.update_time ?? data.create_time ?? new Date().toISOString();
|
|
714
|
+
}
|
|
715
|
+
return result;
|
|
716
|
+
}
|
|
717
|
+
#mapSubscription(data) {
|
|
718
|
+
const regular = data.plan?.billing_cycles?.find((cycle) => cycle.tenure_type === 'REGULAR');
|
|
719
|
+
const price = regular?.pricing_scheme?.fixed_price;
|
|
720
|
+
return {
|
|
721
|
+
id: data.id,
|
|
722
|
+
gatewayId: data.id,
|
|
723
|
+
provider: this.provider,
|
|
724
|
+
customerId: data.subscriber?.payer_id ?? data.subscriber?.email_address ?? '',
|
|
725
|
+
status: this.#mapSubscriptionStatus(data.status),
|
|
726
|
+
planId: data.plan_id ?? '',
|
|
727
|
+
...(price !== undefined
|
|
728
|
+
? { amount: this.#mapMoney(price, price.currency_code.toLowerCase()) }
|
|
729
|
+
: {}),
|
|
730
|
+
...(data.start_time !== undefined ? { currentPeriodStart: data.start_time } : {}),
|
|
731
|
+
...(data.billing_info?.next_billing_time !== undefined
|
|
732
|
+
? { currentPeriodEnd: data.billing_info.next_billing_time }
|
|
733
|
+
: {}),
|
|
734
|
+
payload: data,
|
|
735
|
+
createdAt: data.create_time ?? data.start_time ?? new Date().toISOString(),
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
#mapSubscriptionStatus(status) {
|
|
739
|
+
switch (status) {
|
|
740
|
+
case 'ACTIVE':
|
|
741
|
+
return 'active';
|
|
742
|
+
// Suspended is PayPal's pause: it exists, it bills nothing today, and `/activate`
|
|
743
|
+
// starts it again. `past_due` said the subscriber owed money, which a merchant-
|
|
744
|
+
// initiated suspension does not — and this driver's own `cancelSubscription` points
|
|
745
|
+
// at suspend as the way to hold a subscription open. Either way it entitles nobody.
|
|
746
|
+
case 'SUSPENDED':
|
|
747
|
+
return 'paused';
|
|
748
|
+
case 'CANCELLED':
|
|
749
|
+
return 'canceled';
|
|
750
|
+
case 'EXPIRED':
|
|
751
|
+
return 'ended';
|
|
752
|
+
// APPROVAL_PENDING and APPROVED both mean "the payer has not finished" — PayPal
|
|
753
|
+
// has not charged anything yet.
|
|
754
|
+
default:
|
|
755
|
+
return 'incomplete';
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
#mapMoney(money, fallbackCurrency) {
|
|
759
|
+
const currency = (money?.currency_code ?? fallbackCurrency).toLowerCase();
|
|
760
|
+
return { amount: this.#fromValue(money?.value, currency), currency };
|
|
761
|
+
}
|
|
762
|
+
/** The link the payer must be sent to; PayPal names it `payer-action` or `approve`. */
|
|
763
|
+
#approvalUrl(links) {
|
|
764
|
+
const link = links?.find((candidate) => candidate.rel === 'payer-action') ??
|
|
765
|
+
links?.find((candidate) => candidate.rel === 'approve');
|
|
766
|
+
return link?.href ?? '';
|
|
767
|
+
}
|
|
768
|
+
async #createSubscription(options) {
|
|
769
|
+
const body = {
|
|
770
|
+
plan_id: options.planId,
|
|
771
|
+
...(options.customId !== undefined ? { custom_id: options.customId } : {}),
|
|
772
|
+
...(options.startTime !== undefined ? { start_time: options.startTime } : {}),
|
|
773
|
+
...(options.returnUrl !== undefined
|
|
774
|
+
? {
|
|
775
|
+
application_context: {
|
|
776
|
+
return_url: options.returnUrl,
|
|
777
|
+
...(options.cancelUrl !== undefined ? { cancel_url: options.cancelUrl } : {}),
|
|
778
|
+
user_action: 'SUBSCRIBE_NOW',
|
|
779
|
+
},
|
|
780
|
+
}
|
|
781
|
+
: {}),
|
|
782
|
+
};
|
|
783
|
+
return this.#request('/v1/billing/subscriptions', {
|
|
784
|
+
method: 'POST',
|
|
785
|
+
body,
|
|
786
|
+
headers: {
|
|
787
|
+
Prefer: 'return=representation',
|
|
788
|
+
...this.#requestId(options.requestId),
|
|
789
|
+
},
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
/**
|
|
793
|
+
* PayPal's idempotency header, with the limit its own guidance names.
|
|
794
|
+
*
|
|
795
|
+
* The per-endpoint schemas allow up to 10 000 characters, but PayPal's idempotency page
|
|
796
|
+
* recommends a UUID "because it meets the 38 single-byte character limit" — so 38 is the
|
|
797
|
+
* number that survives every endpoint, and a longer key is refused here rather than
|
|
798
|
+
* accepted by one call and rejected by the next.
|
|
799
|
+
*/
|
|
800
|
+
#requestId(key) {
|
|
801
|
+
if (key === undefined)
|
|
802
|
+
return {};
|
|
803
|
+
if (key.length > REQUEST_ID_MAX_LENGTH) {
|
|
804
|
+
throw new Error(`[payments] PayPal's \`PayPal-Request-Id\` is documented at ${REQUEST_ID_MAX_LENGTH} single-byte characters (a UUID); got ${key.length}.`);
|
|
805
|
+
}
|
|
806
|
+
return { 'PayPal-Request-Id': key };
|
|
807
|
+
}
|
|
808
|
+
// ── Money ────────────────────────────────────────────────────────────────────────────
|
|
809
|
+
/** Minor units → the decimal string PayPal's `amount.value` wants for that currency. */
|
|
810
|
+
#toValue(amount, currency) {
|
|
811
|
+
if (PAYPAL_UNDIVIDED_CURRENCIES.has(currency.toLowerCase())) {
|
|
812
|
+
if (amount % 100 !== 0) {
|
|
813
|
+
throw new Error(`[payments] PayPal does not accept decimals in ${currency.toUpperCase()}, and ` +
|
|
814
|
+
`${amount} is not a whole ${currency.toUpperCase()}. Charge a round amount.`);
|
|
815
|
+
}
|
|
816
|
+
return String(amount / 100);
|
|
817
|
+
}
|
|
818
|
+
return formatDecimal(amount, currency);
|
|
819
|
+
}
|
|
820
|
+
/** PayPal's decimal string → minor units. */
|
|
821
|
+
#fromValue(value, currency) {
|
|
822
|
+
const parsed = Number(value ?? 0);
|
|
823
|
+
if (Number.isNaN(parsed))
|
|
824
|
+
return 0;
|
|
825
|
+
return fromDecimal(parsed, currency);
|
|
826
|
+
}
|
|
827
|
+
// ── Transport ────────────────────────────────────────────────────────────────────────
|
|
828
|
+
async #request(path, options = {}) {
|
|
829
|
+
return httpRequest(path, {
|
|
830
|
+
baseUrl: this.#baseUrl,
|
|
831
|
+
bearerToken: await this.#accessToken(),
|
|
832
|
+
...(options.method !== undefined ? { method: options.method } : {}),
|
|
833
|
+
...(options.body !== undefined ? { body: options.body } : {}),
|
|
834
|
+
...(options.headers !== undefined ? { headers: options.headers } : {}),
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
/**
|
|
838
|
+
* The two calls PayPal answers with `204 No Content` (cancel, JSON Patch). `httpRequest`
|
|
839
|
+
* always parses a JSON body, which throws on an empty one — and the JSON Patch body is
|
|
840
|
+
* an array, not an object. Errors are shaped the same way so `isNotFound` still works.
|
|
841
|
+
*/
|
|
842
|
+
async #requestNoContent(path, body, options = {}) {
|
|
843
|
+
const response = await fetch(`${this.#baseUrl}${path}`, {
|
|
844
|
+
method: options.method ?? 'POST',
|
|
845
|
+
headers: {
|
|
846
|
+
'Content-Type': 'application/json',
|
|
847
|
+
Authorization: `Bearer ${await this.#accessToken()}`,
|
|
848
|
+
},
|
|
849
|
+
body: JSON.stringify(body),
|
|
850
|
+
});
|
|
851
|
+
if (!response.ok)
|
|
852
|
+
throw await this.#error(response);
|
|
853
|
+
}
|
|
854
|
+
/** Error normalization matching `httpRequest`, for the two calls that bypass it. */
|
|
855
|
+
async #error(response) {
|
|
856
|
+
const text = await response.text();
|
|
857
|
+
return Object.assign(new Error(`[payments] HTTP request failed (${response.status}): ${text}`), { status: response.status });
|
|
858
|
+
}
|
|
859
|
+
/**
|
|
860
|
+
* The client-credentials token, cached until PayPal's own `expires_in` says it is spent
|
|
861
|
+
* (minus a safety margin). Caching it for any fixed period of our own choosing is how a
|
|
862
|
+
* deploy works fine for an hour and then 401s on every charge.
|
|
863
|
+
*/
|
|
864
|
+
async #accessToken() {
|
|
865
|
+
const cached = this.#token;
|
|
866
|
+
if (cached !== undefined && Date.now() < cached.expiresAt)
|
|
867
|
+
return cached.value;
|
|
868
|
+
const response = await fetch(`${this.#baseUrl}/v1/oauth2/token`, {
|
|
869
|
+
method: 'POST',
|
|
870
|
+
headers: {
|
|
871
|
+
Authorization: `Basic ${this.#basicAuth}`,
|
|
872
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
873
|
+
},
|
|
874
|
+
body: 'grant_type=client_credentials',
|
|
875
|
+
});
|
|
876
|
+
if (!response.ok)
|
|
877
|
+
throw await this.#error(response);
|
|
878
|
+
const data = (await response.json());
|
|
879
|
+
if (data.access_token === undefined) {
|
|
880
|
+
throw new Error('[payments] PayPal returned no access token for the client credentials.');
|
|
881
|
+
}
|
|
882
|
+
// No `expires_in` means we know nothing about the lifetime, so don't cache it.
|
|
883
|
+
const ttlMs = typeof data.expires_in === 'number' ? data.expires_in * 1000 : 0;
|
|
884
|
+
this.#token = {
|
|
885
|
+
value: data.access_token,
|
|
886
|
+
expiresAt: Date.now() + Math.max(0, ttlMs - TOKEN_SAFETY_MARGIN_MS),
|
|
887
|
+
};
|
|
888
|
+
return data.access_token;
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
//# sourceMappingURL=paypal.js.map
|