@adonis-agora/payments 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2347 -0
- package/dist/assets/spa/assets/index-CT5Zon6P.css +1 -0
- package/dist/assets/spa/assets/index-CvEbJEnq.js +41 -0
- package/dist/assets/spa/assets/index-CvEbJEnq.js.map +1 -0
- package/dist/assets/spa/index.html +20 -0
- package/dist/commands/main.d.ts +4 -3
- package/dist/commands/main.d.ts.map +1 -1
- package/dist/commands/main.js +9 -2
- package/dist/commands/main.js.map +1 -1
- package/dist/commands/payments_health.d.ts +31 -0
- package/dist/commands/payments_health.d.ts.map +1 -0
- package/dist/commands/payments_health.js +113 -0
- package/dist/commands/payments_health.js.map +1 -0
- package/dist/commands/payments_sync.d.ts +18 -4
- package/dist/commands/payments_sync.d.ts.map +1 -1
- package/dist/commands/payments_sync.js +136 -40
- package/dist/commands/payments_sync.js.map +1 -1
- package/dist/configure.d.ts +12 -5
- package/dist/configure.d.ts.map +1 -1
- package/dist/configure.js +18 -5
- package/dist/configure.js.map +1 -1
- package/dist/providers/dashboard_provider.d.ts +97 -0
- package/dist/providers/dashboard_provider.d.ts.map +1 -0
- package/dist/providers/dashboard_provider.js +430 -0
- package/dist/providers/dashboard_provider.js.map +1 -0
- package/dist/providers/payments_client_provider.d.ts +41 -0
- package/dist/providers/payments_client_provider.d.ts.map +1 -0
- package/dist/providers/payments_client_provider.js +95 -0
- package/dist/providers/payments_client_provider.js.map +1 -0
- package/dist/providers/payments_provider.d.ts +35 -0
- package/dist/providers/payments_provider.d.ts.map +1 -1
- package/dist/providers/payments_provider.js +250 -36
- package/dist/providers/payments_provider.js.map +1 -1
- package/dist/src/billing/billing_health.d.ts +85 -0
- package/dist/src/billing/billing_health.d.ts.map +1 -0
- package/dist/src/billing/billing_health.js +155 -0
- package/dist/src/billing/billing_health.js.map +1 -0
- package/dist/src/billing/billing_overview.d.ts +4 -3
- package/dist/src/billing/billing_overview.d.ts.map +1 -1
- package/dist/src/billing/billing_overview.js +14 -5
- package/dist/src/billing/billing_overview.js.map +1 -1
- package/dist/src/billing/billing_store.d.ts +634 -5
- package/dist/src/billing/billing_store.d.ts.map +1 -1
- package/dist/src/billing/billing_store.js +18 -1
- package/dist/src/billing/billing_store.js.map +1 -1
- package/dist/src/billing/index.d.ts +2 -1
- package/dist/src/billing/index.d.ts.map +1 -1
- package/dist/src/billing/index.js +1 -0
- package/dist/src/billing/index.js.map +1 -1
- package/dist/src/billing/list_query.d.ts +16 -0
- package/dist/src/billing/list_query.d.ts.map +1 -0
- package/dist/src/billing/list_query.js +28 -0
- package/dist/src/billing/list_query.js.map +1 -0
- package/dist/src/billing/lucid_billing_store.d.ts +122 -4
- package/dist/src/billing/lucid_billing_store.d.ts.map +1 -1
- package/dist/src/billing/lucid_billing_store.js +811 -13
- package/dist/src/billing/lucid_billing_store.js.map +1 -1
- package/dist/src/billing/mixins/index.d.ts +3 -0
- package/dist/src/billing/mixins/index.d.ts.map +1 -1
- package/dist/src/billing/mixins/index.js +3 -0
- package/dist/src/billing/mixins/index.js.map +1 -1
- package/dist/src/billing/mixins/with_audit_event.d.ts +48 -0
- package/dist/src/billing/mixins/with_audit_event.d.ts.map +1 -0
- package/dist/src/billing/mixins/with_audit_event.js +77 -0
- package/dist/src/billing/mixins/with_audit_event.js.map +1 -0
- package/dist/src/billing/mixins/with_customer.d.ts +39 -0
- package/dist/src/billing/mixins/with_customer.d.ts.map +1 -0
- package/dist/src/billing/mixins/with_customer.js +68 -0
- package/dist/src/billing/mixins/with_customer.js.map +1 -0
- package/dist/src/billing/mixins/with_dispute.d.ts +68 -0
- package/dist/src/billing/mixins/with_dispute.d.ts.map +1 -0
- package/dist/src/billing/mixins/with_dispute.js +83 -0
- package/dist/src/billing/mixins/with_dispute.js.map +1 -0
- package/dist/src/billing/mixins/with_payment.d.ts +42 -0
- package/dist/src/billing/mixins/with_payment.d.ts.map +1 -1
- package/dist/src/billing/mixins/with_payment.js +29 -3
- package/dist/src/billing/mixins/with_payment.js.map +1 -1
- package/dist/src/billing/mixins/with_subscription.d.ts +7 -0
- package/dist/src/billing/mixins/with_subscription.d.ts.map +1 -1
- package/dist/src/billing/mixins/with_subscription.js +15 -1
- package/dist/src/billing/mixins/with_subscription.js.map +1 -1
- package/dist/src/billing/mixins/with_usage_event.d.ts +7 -0
- package/dist/src/billing/mixins/with_usage_event.d.ts.map +1 -1
- package/dist/src/billing/mixins/with_usage_event.js +15 -1
- package/dist/src/billing/mixins/with_usage_event.js.map +1 -1
- package/dist/src/billing/mixins/with_webhook_event.d.ts +18 -0
- package/dist/src/billing/mixins/with_webhook_event.d.ts.map +1 -1
- package/dist/src/billing/mixins/with_webhook_event.js +18 -1
- package/dist/src/billing/mixins/with_webhook_event.js.map +1 -1
- package/dist/src/billing/resolve_dispatch.d.ts +22 -0
- package/dist/src/billing/resolve_dispatch.d.ts.map +1 -0
- package/dist/src/billing/resolve_dispatch.js +44 -0
- package/dist/src/billing/resolve_dispatch.js.map +1 -0
- package/dist/src/billing/resolve_store.d.ts +18 -0
- package/dist/src/billing/resolve_store.d.ts.map +1 -0
- package/dist/src/billing/resolve_store.js +35 -0
- package/dist/src/billing/resolve_store.js.map +1 -0
- package/dist/src/billing/schema.d.ts +108 -0
- package/dist/src/billing/schema.d.ts.map +1 -0
- package/dist/src/billing/schema.js +350 -0
- package/dist/src/billing/schema.js.map +1 -0
- package/dist/src/billing/webhook_dispatcher.d.ts +95 -16
- package/dist/src/billing/webhook_dispatcher.d.ts.map +1 -1
- package/dist/src/billing/webhook_dispatcher.js +186 -33
- package/dist/src/billing/webhook_dispatcher.js.map +1 -1
- package/dist/src/billing/webhook_events.d.ts +67 -1
- package/dist/src/billing/webhook_events.d.ts.map +1 -1
- package/dist/src/billing/webhook_events.js +15 -0
- package/dist/src/billing/webhook_events.js.map +1 -1
- package/dist/src/billing/webhook_processor.d.ts.map +1 -1
- package/dist/src/billing/webhook_processor.js +325 -5
- package/dist/src/billing/webhook_processor.js.map +1 -1
- package/dist/src/client/define_config.d.ts +150 -0
- package/dist/src/client/define_config.d.ts.map +1 -0
- package/dist/src/client/define_config.js +98 -0
- package/dist/src/client/define_config.js.map +1 -0
- package/dist/src/client/handlers.d.ts +71 -0
- package/dist/src/client/handlers.d.ts.map +1 -0
- package/dist/src/client/handlers.js +119 -0
- package/dist/src/client/handlers.js.map +1 -0
- package/dist/src/client/index.d.ts +16 -0
- package/dist/src/client/index.d.ts.map +1 -0
- package/dist/src/client/index.js +13 -0
- package/dist/src/client/index.js.map +1 -0
- package/dist/src/client/statuses.d.ts +38 -0
- package/dist/src/client/statuses.d.ts.map +1 -0
- package/dist/src/client/statuses.js +42 -0
- package/dist/src/client/statuses.js.map +1 -0
- package/dist/src/dashboard/actions.d.ts +143 -0
- package/dist/src/dashboard/actions.d.ts.map +1 -0
- package/dist/src/dashboard/actions.js +112 -0
- package/dist/src/dashboard/actions.js.map +1 -0
- package/dist/src/dashboard/auth.d.ts +136 -0
- package/dist/src/dashboard/auth.d.ts.map +1 -0
- package/dist/src/dashboard/auth.js +152 -0
- package/dist/src/dashboard/auth.js.map +1 -0
- package/dist/src/dashboard/define_config.d.ts +80 -0
- package/dist/src/dashboard/define_config.d.ts.map +1 -0
- package/dist/src/dashboard/define_config.js +83 -0
- package/dist/src/dashboard/define_config.js.map +1 -0
- package/dist/src/dashboard/handlers.d.ts +281 -0
- package/dist/src/dashboard/handlers.d.ts.map +1 -0
- package/dist/src/dashboard/handlers.js +960 -0
- package/dist/src/dashboard/handlers.js.map +1 -0
- package/dist/src/dashboard/index.d.ts +18 -0
- package/dist/src/dashboard/index.d.ts.map +1 -0
- package/dist/src/dashboard/index.js +14 -0
- package/dist/src/dashboard/index.js.map +1 -0
- package/dist/src/dashboard/login_page.d.ts +23 -0
- package/dist/src/dashboard/login_page.d.ts.map +1 -0
- package/dist/src/dashboard/login_page.js +170 -0
- package/dist/src/dashboard/login_page.js.map +1 -0
- package/dist/src/dashboard/period.d.ts +31 -0
- package/dist/src/dashboard/period.d.ts.map +1 -0
- package/dist/src/dashboard/period.js +44 -0
- package/dist/src/dashboard/period.js.map +1 -0
- package/dist/src/dashboard/session_cookie.d.ts +42 -0
- package/dist/src/dashboard/session_cookie.d.ts.map +1 -0
- package/dist/src/dashboard/session_cookie.js +97 -0
- package/dist/src/dashboard/session_cookie.js.map +1 -0
- package/dist/src/dashboard/spa.d.ts +24 -0
- package/dist/src/dashboard/spa.d.ts.map +1 -0
- package/dist/src/dashboard/spa.js +52 -0
- package/dist/src/dashboard/spa.js.map +1 -0
- package/dist/src/define_config.d.ts +244 -9
- package/dist/src/define_config.d.ts.map +1 -1
- package/dist/src/define_config.js +97 -1
- package/dist/src/define_config.js.map +1 -1
- package/dist/src/diagnostics.d.ts +287 -7
- package/dist/src/diagnostics.d.ts.map +1 -1
- package/dist/src/diagnostics.js +317 -1
- package/dist/src/diagnostics.js.map +1 -1
- package/dist/src/driver.d.ts +139 -5
- package/dist/src/driver.d.ts.map +1 -1
- package/dist/src/drivers/abacate.d.ts +12 -2
- package/dist/src/drivers/abacate.d.ts.map +1 -1
- package/dist/src/drivers/abacate.js +112 -19
- package/dist/src/drivers/abacate.js.map +1 -1
- package/dist/src/drivers/adyen.d.ts +164 -0
- package/dist/src/drivers/adyen.d.ts.map +1 -0
- package/dist/src/drivers/adyen.js +678 -0
- package/dist/src/drivers/adyen.js.map +1 -0
- package/dist/src/drivers/asaas.d.ts +52 -2
- package/dist/src/drivers/asaas.d.ts.map +1 -1
- package/dist/src/drivers/asaas.js +364 -29
- package/dist/src/drivers/asaas.js.map +1 -1
- package/dist/src/drivers/dodo.d.ts +135 -0
- package/dist/src/drivers/dodo.d.ts.map +1 -0
- package/dist/src/drivers/dodo.js +864 -0
- package/dist/src/drivers/dodo.js.map +1 -0
- package/dist/src/drivers/efi.d.ts +98 -0
- package/dist/src/drivers/efi.d.ts.map +1 -0
- package/dist/src/drivers/efi.js +628 -0
- package/dist/src/drivers/efi.js.map +1 -0
- package/dist/src/drivers/infinitepay.d.ts +156 -0
- package/dist/src/drivers/infinitepay.d.ts.map +1 -0
- package/dist/src/drivers/infinitepay.js +321 -0
- package/dist/src/drivers/infinitepay.js.map +1 -0
- package/dist/src/drivers/lemonsqueezy.d.ts +125 -0
- package/dist/src/drivers/lemonsqueezy.d.ts.map +1 -0
- package/dist/src/drivers/lemonsqueezy.js +632 -0
- package/dist/src/drivers/lemonsqueezy.js.map +1 -0
- package/dist/src/drivers/mercadopago.d.ts +97 -0
- package/dist/src/drivers/mercadopago.d.ts.map +1 -0
- package/dist/src/drivers/mercadopago.js +844 -0
- package/dist/src/drivers/mercadopago.js.map +1 -0
- package/dist/src/drivers/mollie.d.ts +125 -0
- package/dist/src/drivers/mollie.d.ts.map +1 -0
- package/dist/src/drivers/mollie.js +812 -0
- package/dist/src/drivers/mollie.js.map +1 -0
- package/dist/src/drivers/paddle.d.ts +148 -0
- package/dist/src/drivers/paddle.d.ts.map +1 -0
- package/dist/src/drivers/paddle.js +738 -0
- package/dist/src/drivers/paddle.js.map +1 -0
- package/dist/src/drivers/pagarme.d.ts +97 -0
- package/dist/src/drivers/pagarme.d.ts.map +1 -0
- package/dist/src/drivers/pagarme.js +829 -0
- package/dist/src/drivers/pagarme.js.map +1 -0
- package/dist/src/drivers/pagbank.d.ts +57 -0
- package/dist/src/drivers/pagbank.d.ts.map +1 -0
- package/dist/src/drivers/pagbank.js +499 -0
- package/dist/src/drivers/pagbank.js.map +1 -0
- package/dist/src/drivers/paypal.d.ts +120 -0
- package/dist/src/drivers/paypal.d.ts.map +1 -0
- package/dist/src/drivers/paypal.js +891 -0
- package/dist/src/drivers/paypal.js.map +1 -0
- package/dist/src/drivers/polar.d.ts +118 -0
- package/dist/src/drivers/polar.d.ts.map +1 -0
- package/dist/src/drivers/polar.js +604 -0
- package/dist/src/drivers/polar.js.map +1 -0
- package/dist/src/drivers/razorpay.d.ts +103 -0
- package/dist/src/drivers/razorpay.d.ts.map +1 -0
- package/dist/src/drivers/razorpay.js +867 -0
- package/dist/src/drivers/razorpay.js.map +1 -0
- package/dist/src/drivers/shared.d.ts +33 -0
- package/dist/src/drivers/shared.d.ts.map +1 -0
- package/dist/src/drivers/shared.js +37 -0
- package/dist/src/drivers/shared.js.map +1 -0
- package/dist/src/drivers/square.d.ts +133 -0
- package/dist/src/drivers/square.d.ts.map +1 -0
- package/dist/src/drivers/square.js +905 -0
- package/dist/src/drivers/square.js.map +1 -0
- package/dist/src/drivers/stripe.d.ts +40 -3
- package/dist/src/drivers/stripe.d.ts.map +1 -1
- package/dist/src/drivers/stripe.js +806 -25
- package/dist/src/drivers/stripe.js.map +1 -1
- package/dist/src/drivers/woovi.d.ts +10 -3
- package/dist/src/drivers/woovi.d.ts.map +1 -1
- package/dist/src/drivers/woovi.js +162 -29
- package/dist/src/drivers/woovi.js.map +1 -1
- package/dist/src/ensure_customer.d.ts +34 -1
- package/dist/src/ensure_customer.d.ts.map +1 -1
- package/dist/src/ensure_customer.js +29 -4
- package/dist/src/ensure_customer.js.map +1 -1
- package/dist/src/http.d.ts +37 -3
- package/dist/src/http.d.ts.map +1 -1
- package/dist/src/http.js +69 -5
- package/dist/src/http.js.map +1 -1
- package/dist/src/index.d.ts +40 -10
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +22 -4
- package/dist/src/index.js.map +1 -1
- package/dist/src/invoice/emit_invoice.d.ts.map +1 -1
- package/dist/src/invoice/emit_invoice.js +31 -11
- package/dist/src/invoice/emit_invoice.js.map +1 -1
- package/dist/src/money.d.ts +23 -5
- package/dist/src/money.d.ts.map +1 -1
- package/dist/src/money.js +72 -7
- package/dist/src/money.js.map +1 -1
- package/dist/src/payments_manager.d.ts +8 -1
- package/dist/src/payments_manager.d.ts.map +1 -1
- package/dist/src/payments_manager.js +168 -6
- package/dist/src/payments_manager.js.map +1 -1
- package/dist/src/services/main.d.ts +38 -0
- package/dist/src/services/main.d.ts.map +1 -1
- package/dist/src/services/main.js +130 -0
- package/dist/src/services/main.js.map +1 -1
- package/dist/src/telescope/index.d.ts +2 -0
- package/dist/src/telescope/index.d.ts.map +1 -1
- package/dist/src/telescope/index.js +1 -0
- package/dist/src/telescope/index.js.map +1 -1
- package/dist/src/telescope/payments_watcher.d.ts +10 -4
- package/dist/src/telescope/payments_watcher.d.ts.map +1 -1
- package/dist/src/telescope/payments_watcher.js +65 -13
- package/dist/src/telescope/payments_watcher.js.map +1 -1
- package/dist/src/telescope/register.d.ts +62 -0
- package/dist/src/telescope/register.d.ts.map +1 -0
- package/dist/src/telescope/register.js +48 -0
- package/dist/src/telescope/register.js.map +1 -0
- package/dist/src/testing/fake_payments.d.ts +62 -0
- package/dist/src/testing/fake_payments.d.ts.map +1 -0
- package/dist/src/testing/fake_payments.js +82 -0
- package/dist/src/testing/fake_payments.js.map +1 -0
- package/dist/src/testing/in_memory_billing_store.d.ts +151 -2
- package/dist/src/testing/in_memory_billing_store.d.ts.map +1 -1
- package/dist/src/testing/in_memory_billing_store.js +517 -4
- package/dist/src/testing/in_memory_billing_store.js.map +1 -1
- package/dist/src/testing/index.d.ts +2 -1
- package/dist/src/testing/index.d.ts.map +1 -1
- package/dist/src/testing/index.js +1 -0
- package/dist/src/testing/index.js.map +1 -1
- package/dist/src/types.d.ts +161 -5
- package/dist/src/types.d.ts.map +1 -1
- package/dist/src/types.js +30 -1
- package/dist/src/types.js.map +1 -1
- package/dist/src/webhook_handlers.d.ts +96 -2
- package/dist/src/webhook_handlers.d.ts.map +1 -1
- package/dist/src/webhook_handlers.js +91 -10
- package/dist/src/webhook_handlers.js.map +1 -1
- package/dist/src/webhook_security.d.ts +94 -3
- package/dist/src/webhook_security.d.ts.map +1 -1
- package/dist/src/webhook_security.js +173 -9
- package/dist/src/webhook_security.js.map +1 -1
- package/dist/stubs/config/payments.stub +13 -0
- package/dist/stubs/config/payments_client.stub +42 -0
- package/dist/stubs/config/payments_dashboard.stub +38 -0
- package/dist/stubs/database/migrations/create_billing_tables.stub +35 -77
- package/dist/stubs/make/webhook_handler/main.stub +29 -11
- package/package.json +60 -25
|
@@ -0,0 +1,632 @@
|
|
|
1
|
+
import { createHmac } from 'node:crypto';
|
|
2
|
+
import { publishRefundDiagnostics, publishSubscriptionDiagnostics } from '../diagnostics.js';
|
|
3
|
+
import { headerValue, isNotFound } from '../http.js';
|
|
4
|
+
import { safeCompare } from '../webhook_security.js';
|
|
5
|
+
import { requireCredential } from './shared.js';
|
|
6
|
+
/**
|
|
7
|
+
* Lemon Squeezy driver — the v1 REST API on `api.lemonsqueezy.com`.
|
|
8
|
+
*
|
|
9
|
+
* Lemon Squeezy is a merchant of record: it is the seller of record on the buyer's
|
|
10
|
+
* statement, calculates and remits sales tax/VAT worldwide, and pays you out. You sell to
|
|
11
|
+
* Lemon Squeezy; Lemon Squeezy sells to your customer.
|
|
12
|
+
*
|
|
13
|
+
* Two consequences shape this driver. First, there is no server-side charge endpoint at
|
|
14
|
+
* all — a purchase begins with a hosted checkout, so `charge()` throws and
|
|
15
|
+
* `createCheckout()` is the entry point. Second, subscriptions are created by a customer
|
|
16
|
+
* completing a checkout for a subscription variant, never by an API call, so
|
|
17
|
+
* `createSubscription()` throws too.
|
|
18
|
+
*
|
|
19
|
+
* The API is JSON:API (`application/vnd.api+json`), which is unusual enough that the
|
|
20
|
+
* request helper and every mapper below are written around `data.attributes`.
|
|
21
|
+
*/
|
|
22
|
+
export class LemonSqueezyDriver {
|
|
23
|
+
provider = 'lemonsqueezy';
|
|
24
|
+
/**
|
|
25
|
+
* The hosted checkout decides which methods to offer from the buyer's country and the
|
|
26
|
+
* store's settings, and the API takes no payment-method argument — so `'undefined'`
|
|
27
|
+
* ("the customer chooses at checkout") is the only honest canonical name. Routing
|
|
28
|
+
* `credit_card` here is refused by the manager, which is correct: this driver cannot
|
|
29
|
+
* promise the payment will be a card rather than PayPal.
|
|
30
|
+
*/
|
|
31
|
+
supportedMethods = ['undefined'];
|
|
32
|
+
/**
|
|
33
|
+
* `disputes: false`. Lemon Squeezy has no dispute API and no dispute webhook — see
|
|
34
|
+
* `#mapWebhookType`. As merchant of record it "typically manages these disputes on
|
|
35
|
+
* behalf of the seller", so there is nothing here to read and nothing to submit.
|
|
36
|
+
*/
|
|
37
|
+
capabilities = { refunds: true, invoices: true, subscriptions: true, disputes: false };
|
|
38
|
+
#baseUrl = 'https://api.lemonsqueezy.com/v1';
|
|
39
|
+
#apiKey;
|
|
40
|
+
#storeId;
|
|
41
|
+
#webhookSecret;
|
|
42
|
+
constructor(_ctx, config) {
|
|
43
|
+
this.#apiKey = requireCredential({
|
|
44
|
+
driver: 'lemonsqueezy',
|
|
45
|
+
option: 'apiKey',
|
|
46
|
+
env: 'LEMONSQUEEZY_API_KEY',
|
|
47
|
+
value: config.apiKey,
|
|
48
|
+
});
|
|
49
|
+
this.#storeId = String(config.storeId);
|
|
50
|
+
// An empty env var is "unset", not "a secret that can never match" — otherwise a
|
|
51
|
+
// blank `LEMONSQUEEZY_WEBHOOK_SECRET` fails every webhook with a signature error
|
|
52
|
+
// instead of the message telling you to configure it.
|
|
53
|
+
const webhookSecret = config.webhookSecret ?? process.env.LEMONSQUEEZY_WEBHOOK_SECRET;
|
|
54
|
+
this.#webhookSecret = webhookSecret === '' ? undefined : webhookSecret;
|
|
55
|
+
}
|
|
56
|
+
// ── Customers ────────────────────────────────────────────────────────────────────────
|
|
57
|
+
async createCustomer(input) {
|
|
58
|
+
this.#refuseIdempotencyKey(input.idempotencyKey, 'createCustomer');
|
|
59
|
+
if (input.name === undefined || input.email === undefined) {
|
|
60
|
+
throw new Error('[payments] Lemon Squeezy requires both a name and an email on a customer.');
|
|
61
|
+
}
|
|
62
|
+
this.#refuseTaxId(input.taxId);
|
|
63
|
+
const document = await this.#request('/customers', {
|
|
64
|
+
method: 'POST',
|
|
65
|
+
body: {
|
|
66
|
+
data: {
|
|
67
|
+
type: 'customers',
|
|
68
|
+
attributes: {
|
|
69
|
+
name: input.name,
|
|
70
|
+
email: input.email,
|
|
71
|
+
...(typeof input.metadata?.city === 'string' ? { city: input.metadata.city } : {}),
|
|
72
|
+
...(typeof input.metadata?.region === 'string'
|
|
73
|
+
? { region: input.metadata.region }
|
|
74
|
+
: {}),
|
|
75
|
+
...(typeof input.metadata?.country === 'string'
|
|
76
|
+
? { country: input.metadata.country }
|
|
77
|
+
: {}),
|
|
78
|
+
},
|
|
79
|
+
relationships: { store: { data: { type: 'stores', id: this.#storeId } } },
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
return this.#mapCustomer(document.data);
|
|
84
|
+
}
|
|
85
|
+
async findCustomer(customerId) {
|
|
86
|
+
try {
|
|
87
|
+
const document = await this.#request(`/customers/${encodeURIComponent(customerId)}`);
|
|
88
|
+
return this.#mapCustomer(document.data);
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
if (isNotFound(error))
|
|
92
|
+
return null;
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
async updateCustomer(customerId, input) {
|
|
97
|
+
this.#refuseTaxId(input.taxId);
|
|
98
|
+
const document = await this.#request(`/customers/${encodeURIComponent(customerId)}`, {
|
|
99
|
+
method: 'PATCH',
|
|
100
|
+
body: {
|
|
101
|
+
data: {
|
|
102
|
+
type: 'customers',
|
|
103
|
+
id: String(customerId),
|
|
104
|
+
attributes: {
|
|
105
|
+
...(input.name !== undefined ? { name: input.name } : {}),
|
|
106
|
+
...(input.email !== undefined ? { email: input.email } : {}),
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
return this.#mapCustomer(document.data);
|
|
112
|
+
}
|
|
113
|
+
// ── Payments ─────────────────────────────────────────────────────────────────────────
|
|
114
|
+
/**
|
|
115
|
+
* Lemon Squeezy has no endpoint that takes money. As merchant of record it owns the
|
|
116
|
+
* payment page: the only way to start a purchase is a hosted checkout, so returning a
|
|
117
|
+
* `Payment` here would report a charge that was never attempted.
|
|
118
|
+
*/
|
|
119
|
+
async charge(_input) {
|
|
120
|
+
throw new Error('[payments] Lemon Squeezy cannot charge server-side — as merchant of record it ' +
|
|
121
|
+
'collects every payment through its hosted checkout. Use `createCheckout()` and ' +
|
|
122
|
+
'act on the `order_created` webhook.');
|
|
123
|
+
}
|
|
124
|
+
async findPayment(gatewayId) {
|
|
125
|
+
try {
|
|
126
|
+
const document = await this.#request(`/orders/${encodeURIComponent(gatewayId)}`);
|
|
127
|
+
return this.#mapPayment(document.data);
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
if (isNotFound(error))
|
|
131
|
+
return null;
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Refunds an **order** (`paymentGatewayId` is an order id, the same id `findPayment`
|
|
137
|
+
* takes). `amount` is in cents, like everywhere else here; omit it for a full refund.
|
|
138
|
+
* Renewal charges are subscription invoices with their own ids and their own refund
|
|
139
|
+
* endpoint — this method does not reach them.
|
|
140
|
+
*
|
|
141
|
+
* `options.idempotencyKey` is REFUSED, not ignored: Lemon Squeezy has no deduplication
|
|
142
|
+
* mechanism, so accepting a key would turn a retry guarantee into a second refund.
|
|
143
|
+
*/
|
|
144
|
+
async refund(paymentGatewayId, amount, options) {
|
|
145
|
+
this.#refuseIdempotencyKey(options?.idempotencyKey, 'refund');
|
|
146
|
+
const document = await this.#request(`/orders/${encodeURIComponent(paymentGatewayId)}/refund`, {
|
|
147
|
+
method: 'POST',
|
|
148
|
+
body: {
|
|
149
|
+
data: {
|
|
150
|
+
type: 'orders',
|
|
151
|
+
id: String(paymentGatewayId),
|
|
152
|
+
attributes: { ...(amount !== undefined ? { amount } : {}) },
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
const order = document.data.attributes;
|
|
157
|
+
const refund = {
|
|
158
|
+
id: document.data.id,
|
|
159
|
+
gatewayId: document.data.id,
|
|
160
|
+
provider: this.provider,
|
|
161
|
+
amount: { amount: amount ?? order.total, currency: order.currency.toLowerCase() },
|
|
162
|
+
// The refund endpoint returns the order, not a refund resource: `refunded` is the
|
|
163
|
+
// only confirmation the API gives that the money went back.
|
|
164
|
+
status: order.refunded === true || order.status === 'refunded' ? 'succeeded' : 'pending',
|
|
165
|
+
createdAt: order.refunded_at ?? new Date().toISOString(),
|
|
166
|
+
};
|
|
167
|
+
publishRefundDiagnostics(refund);
|
|
168
|
+
return refund;
|
|
169
|
+
}
|
|
170
|
+
// ── Checkout ─────────────────────────────────────────────────────────────────────────
|
|
171
|
+
/**
|
|
172
|
+
* Creates a hosted checkout for a **variant** — `planId` is a Lemon Squeezy variant id
|
|
173
|
+
* and is required, because a checkout always sells something from the catalog. Pass
|
|
174
|
+
* `amount` to override the variant's price (`custom_price`, in cents).
|
|
175
|
+
*/
|
|
176
|
+
async createCheckout(input) {
|
|
177
|
+
this.#refuseIdempotencyKey(input.idempotencyKey, 'createCheckout');
|
|
178
|
+
if (input.planId === undefined) {
|
|
179
|
+
throw new Error('[payments] Lemon Squeezy checkouts always sell a catalog variant. Pass the ' +
|
|
180
|
+
'variant id as `planId` (use `amount` to override its price).');
|
|
181
|
+
}
|
|
182
|
+
if (input.trialDays !== undefined) {
|
|
183
|
+
// A Lemon Squeezy trial is a property of the variant, not of the checkout.
|
|
184
|
+
// Accepting the option here would bill a customer who was promised a trial.
|
|
185
|
+
throw new Error('[payments] Lemon Squeezy configures trials on the variant, not per checkout. ' +
|
|
186
|
+
'Create a variant with a free trial and pass its id as `planId`.');
|
|
187
|
+
}
|
|
188
|
+
const custom = this.#customData(input);
|
|
189
|
+
const document = await this.#request('/checkouts', {
|
|
190
|
+
method: 'POST',
|
|
191
|
+
body: {
|
|
192
|
+
data: {
|
|
193
|
+
type: 'checkouts',
|
|
194
|
+
attributes: {
|
|
195
|
+
// Lemon Squeezy's money is an integer of the smallest currency unit, the
|
|
196
|
+
// same unit this package works in — `custom_price: 1990` is $19.90. No
|
|
197
|
+
// `formatDecimal`, no division. (API reference, the checkout object:
|
|
198
|
+
// "a positive integer in cents representing the custom price".)
|
|
199
|
+
...(input.amount > 0 ? { custom_price: input.amount } : {}),
|
|
200
|
+
product_options: {
|
|
201
|
+
...(input.description !== undefined ? { name: input.description } : {}),
|
|
202
|
+
// Lemon Squeezy has one post-purchase URL, not a success/cancel pair.
|
|
203
|
+
redirect_url: input.successUrl,
|
|
204
|
+
},
|
|
205
|
+
checkout_data: {
|
|
206
|
+
...(typeof input.metadata?.email === 'string'
|
|
207
|
+
? { email: input.metadata.email }
|
|
208
|
+
: {}),
|
|
209
|
+
...(typeof input.metadata?.name === 'string' ? { name: input.metadata.name } : {}),
|
|
210
|
+
...(typeof input.metadata?.taxNumber === 'string'
|
|
211
|
+
? { tax_number: input.metadata.taxNumber }
|
|
212
|
+
: {}),
|
|
213
|
+
...(custom !== undefined ? { custom } : {}),
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
relationships: {
|
|
217
|
+
store: { data: { type: 'stores', id: this.#storeId } },
|
|
218
|
+
variant: { data: { type: 'variants', id: String(input.planId) } },
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
const attributes = document.data.attributes;
|
|
224
|
+
return {
|
|
225
|
+
id: document.data.id,
|
|
226
|
+
gatewayId: document.data.id,
|
|
227
|
+
provider: this.provider,
|
|
228
|
+
url: attributes.url,
|
|
229
|
+
status: 'open',
|
|
230
|
+
// No `amount`: the checkout response states a price but never a currency (the store
|
|
231
|
+
// sets it), and this package will not invent one. The currency arrives on the
|
|
232
|
+
// `order_created` webhook, where it is read off the order.
|
|
233
|
+
...(input.customerId !== undefined ? { customerId: input.customerId } : {}),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
// ── Subscriptions ────────────────────────────────────────────────────────────────────
|
|
237
|
+
/**
|
|
238
|
+
* Lemon Squeezy's API has no create-subscription endpoint: a subscription exists only
|
|
239
|
+
* once a customer has completed a checkout for a subscription variant. Anything
|
|
240
|
+
* returned here would be a subscription Lemon Squeezy has never heard of.
|
|
241
|
+
*/
|
|
242
|
+
async createSubscription(_input) {
|
|
243
|
+
throw new Error('[payments] Lemon Squeezy cannot create a subscription over the API — it creates ' +
|
|
244
|
+
'one when a customer completes a checkout for a subscription variant. Call ' +
|
|
245
|
+
"`createCheckout({ planId: '<variant id>' })` and read the id off the " +
|
|
246
|
+
'`subscription_created` webhook.');
|
|
247
|
+
}
|
|
248
|
+
async cancelSubscription(subscriptionGatewayId, options) {
|
|
249
|
+
if (options?.atPeriodEnd === false) {
|
|
250
|
+
// Cancelling puts the subscription in `cancelled` and lets it run to `ends_at`;
|
|
251
|
+
// there is no immediate-termination endpoint, and pretending otherwise would leave
|
|
252
|
+
// a customer with access the caller believes was revoked.
|
|
253
|
+
throw new Error('[payments] Lemon Squeezy cancels at the end of the current billing period and ' +
|
|
254
|
+
'has no immediate cancellation. Call `cancelSubscription(id)` without ' +
|
|
255
|
+
'`atPeriodEnd: false`, and revoke access yourself on `ends_at`.');
|
|
256
|
+
}
|
|
257
|
+
const document = await this.#request(`/subscriptions/${encodeURIComponent(subscriptionGatewayId)}`, { method: 'DELETE' });
|
|
258
|
+
const subscription = this.#mapSubscription(document.data);
|
|
259
|
+
publishSubscriptionDiagnostics(subscription, 'subscription.canceled');
|
|
260
|
+
return subscription;
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* The only change Lemon Squeezy accepts on a subscription that this contract can carry
|
|
264
|
+
* is a plan swap, and it is expressed as a variant id — so it is read from
|
|
265
|
+
* `metadata.variantId`. There is no writable amount (the price belongs to the variant),
|
|
266
|
+
* no description, and no custom-data field; each is refused rather than dropped.
|
|
267
|
+
*/
|
|
268
|
+
async updateSubscription(subscriptionGatewayId, input) {
|
|
269
|
+
this.#refuseIdempotencyKey(input.idempotencyKey, 'updateSubscription');
|
|
270
|
+
if (input.amount !== undefined) {
|
|
271
|
+
throw new Error('[payments] Lemon Squeezy has no editable amount on a subscription — the price ' +
|
|
272
|
+
'belongs to the variant. Swap plans with ' +
|
|
273
|
+
"`updateSubscription(id, { metadata: { variantId: '<variant id>' } })`.");
|
|
274
|
+
}
|
|
275
|
+
if (input.description !== undefined) {
|
|
276
|
+
throw new Error('[payments] Lemon Squeezy subscriptions have no description field.');
|
|
277
|
+
}
|
|
278
|
+
const variantId = input.metadata?.variantId;
|
|
279
|
+
if (variantId === undefined) {
|
|
280
|
+
throw new Error('[payments] Lemon Squeezy only accepts a plan swap here. Pass ' +
|
|
281
|
+
'`metadata.variantId` with the variant to move the subscription to.');
|
|
282
|
+
}
|
|
283
|
+
const document = await this.#request(`/subscriptions/${encodeURIComponent(subscriptionGatewayId)}`, {
|
|
284
|
+
method: 'PATCH',
|
|
285
|
+
body: {
|
|
286
|
+
data: {
|
|
287
|
+
type: 'subscriptions',
|
|
288
|
+
id: String(subscriptionGatewayId),
|
|
289
|
+
attributes: {
|
|
290
|
+
variant_id: Number(variantId),
|
|
291
|
+
...(input.metadata?.invoiceImmediately !== undefined
|
|
292
|
+
? { invoice_immediately: Boolean(input.metadata.invoiceImmediately) }
|
|
293
|
+
: {}),
|
|
294
|
+
...(input.metadata?.disableProrations !== undefined
|
|
295
|
+
? { disable_prorations: Boolean(input.metadata.disableProrations) }
|
|
296
|
+
: {}),
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
},
|
|
300
|
+
});
|
|
301
|
+
return this.#mapSubscription(document.data);
|
|
302
|
+
}
|
|
303
|
+
async findSubscription(gatewayId) {
|
|
304
|
+
try {
|
|
305
|
+
const document = await this.#request(`/subscriptions/${encodeURIComponent(gatewayId)}`);
|
|
306
|
+
return this.#mapSubscription(document.data);
|
|
307
|
+
}
|
|
308
|
+
catch (error) {
|
|
309
|
+
if (isNotFound(error))
|
|
310
|
+
return null;
|
|
311
|
+
throw error;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
// ── Invoices ─────────────────────────────────────────────────────────────────────────
|
|
315
|
+
/**
|
|
316
|
+
* Lists the customer's **orders** — the receipts Lemon Squeezy issues for a purchase.
|
|
317
|
+
* `GET /v1/orders` and `GET /v1/subscription-invoices` have no customer filter, so this
|
|
318
|
+
* walks the customer's own `orders` relationship instead. Renewal charges live under
|
|
319
|
+
* `/v1/subscription-invoices?filter[subscription_id]=…` and are not listed here.
|
|
320
|
+
*/
|
|
321
|
+
async listInvoices(customerId) {
|
|
322
|
+
const collection = await this.#request(`/customers/${encodeURIComponent(customerId)}/orders`);
|
|
323
|
+
return collection.data.map((resource) => {
|
|
324
|
+
const order = resource.attributes;
|
|
325
|
+
const statusMap = {
|
|
326
|
+
paid: 'paid',
|
|
327
|
+
pending: 'open',
|
|
328
|
+
failed: 'uncollectible',
|
|
329
|
+
refunded: 'void',
|
|
330
|
+
};
|
|
331
|
+
return {
|
|
332
|
+
id: resource.id,
|
|
333
|
+
gatewayId: resource.id,
|
|
334
|
+
provider: this.provider,
|
|
335
|
+
...(order.customer_id !== undefined ? { customerId: String(order.customer_id) } : {}),
|
|
336
|
+
status: statusMap[order.status] ?? 'open',
|
|
337
|
+
amount: { amount: order.total, currency: order.currency.toLowerCase() },
|
|
338
|
+
createdAt: order.created_at ?? new Date().toISOString(),
|
|
339
|
+
...(order.urls?.receipt !== undefined ? { hostedPdfUrl: order.urls.receipt } : {}),
|
|
340
|
+
...(order.order_number !== undefined ? { number: String(order.order_number) } : {}),
|
|
341
|
+
payload: order,
|
|
342
|
+
};
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
// ── Webhooks ─────────────────────────────────────────────────────────────────────────
|
|
346
|
+
/**
|
|
347
|
+
* Whether a delivery to `POST /payments/webhook/:provider` can be authenticated.
|
|
348
|
+
*
|
|
349
|
+
* `parseWebhook` already refuses without the secret — declaring it moves the refusal to boot.
|
|
350
|
+
*/
|
|
351
|
+
get webhookVerification() {
|
|
352
|
+
return this.#webhookSecret !== undefined ? 'configured' : 'unconfigured';
|
|
353
|
+
}
|
|
354
|
+
parseWebhook(rawBody, headers) {
|
|
355
|
+
if (this.#webhookSecret === undefined) {
|
|
356
|
+
throw new Error('[payments] Lemon Squeezy webhook processing requires the signing secret set on ' +
|
|
357
|
+
'the webhook in the dashboard. Set `LEMONSQUEEZY_WEBHOOK_SECRET` or pass ' +
|
|
358
|
+
'`webhookSecret` to `payments.lemonsqueezy()`.');
|
|
359
|
+
}
|
|
360
|
+
const signature = headerValue(headers, 'x-signature');
|
|
361
|
+
if (signature === undefined || signature === '') {
|
|
362
|
+
throw new Error('[payments] Missing `X-Signature` header on Lemon Squeezy webhook request.');
|
|
363
|
+
}
|
|
364
|
+
// HMAC-SHA256 over the raw body, sent as **hex**. The shared `verifyHmacSignature`
|
|
365
|
+
// helper digests to base64, so it does not fit here.
|
|
366
|
+
const expected = createHmac('sha256', this.#webhookSecret)
|
|
367
|
+
.update(rawBody, 'utf8')
|
|
368
|
+
.digest('hex');
|
|
369
|
+
if (!safeCompare(signature, expected)) {
|
|
370
|
+
throw new Error('[payments] Invalid Lemon Squeezy webhook signature.');
|
|
371
|
+
}
|
|
372
|
+
const payload = JSON.parse(rawBody);
|
|
373
|
+
const eventName = payload.meta?.event_name ?? 'unknown';
|
|
374
|
+
return {
|
|
375
|
+
id: this.#webhookEventId(eventName, payload),
|
|
376
|
+
provider: this.provider,
|
|
377
|
+
type: this.#mapWebhookType(eventName, payload),
|
|
378
|
+
...(typeof payload.data?.attributes?.created_at === 'string'
|
|
379
|
+
? { createdAt: payload.data.attributes.created_at }
|
|
380
|
+
: {}),
|
|
381
|
+
data: this.#mapWebhookData(eventName, payload),
|
|
382
|
+
raw: payload,
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
// ── Mappers ──────────────────────────────────────────────────────────────────────────
|
|
386
|
+
#mapCustomer(resource) {
|
|
387
|
+
const attributes = resource.attributes;
|
|
388
|
+
return {
|
|
389
|
+
id: resource.id,
|
|
390
|
+
...(attributes.email !== undefined ? { email: attributes.email } : {}),
|
|
391
|
+
...(attributes.name !== undefined ? { name: attributes.name } : {}),
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
#mapPayment(resource) {
|
|
395
|
+
const order = resource.attributes;
|
|
396
|
+
const statusMap = {
|
|
397
|
+
paid: 'paid',
|
|
398
|
+
pending: 'pending',
|
|
399
|
+
failed: 'failed',
|
|
400
|
+
refunded: 'refunded',
|
|
401
|
+
};
|
|
402
|
+
const result = {
|
|
403
|
+
id: resource.id,
|
|
404
|
+
gatewayId: resource.id,
|
|
405
|
+
provider: this.provider,
|
|
406
|
+
// Already an integer of the smallest unit — no `fromDecimal` here.
|
|
407
|
+
amount: { amount: order.total, currency: order.currency.toLowerCase() },
|
|
408
|
+
status: order.refunded === true ? 'refunded' : (statusMap[order.status] ?? 'pending'),
|
|
409
|
+
payload: order,
|
|
410
|
+
createdAt: order.created_at ?? new Date().toISOString(),
|
|
411
|
+
};
|
|
412
|
+
if (order.customer_id !== undefined)
|
|
413
|
+
result.customerId = String(order.customer_id);
|
|
414
|
+
if (order.urls?.receipt !== undefined)
|
|
415
|
+
result.hostedUrl = order.urls.receipt;
|
|
416
|
+
// Lemon Squeezy has no `paid_at`: an order only exists once payment succeeded, so
|
|
417
|
+
// `created_at` is the moment it was paid.
|
|
418
|
+
if (order.status === 'paid' && order.created_at !== undefined)
|
|
419
|
+
result.paidAt = order.created_at;
|
|
420
|
+
return result;
|
|
421
|
+
}
|
|
422
|
+
#mapSubscription(resource) {
|
|
423
|
+
const attributes = resource.attributes;
|
|
424
|
+
const statusMap = {
|
|
425
|
+
on_trial: 'trialing',
|
|
426
|
+
active: 'active',
|
|
427
|
+
past_due: 'past_due',
|
|
428
|
+
unpaid: 'past_due',
|
|
429
|
+
cancelled: 'canceled',
|
|
430
|
+
expired: 'ended',
|
|
431
|
+
// A paused Lemon Squeezy subscription still exists and will resume — but it is not
|
|
432
|
+
// billing now, so reporting it as `active` entitled a subscriber who is not paying.
|
|
433
|
+
paused: 'paused',
|
|
434
|
+
};
|
|
435
|
+
return {
|
|
436
|
+
id: resource.id,
|
|
437
|
+
gatewayId: resource.id,
|
|
438
|
+
provider: this.provider,
|
|
439
|
+
customerId: attributes.customer_id !== undefined ? String(attributes.customer_id) : '',
|
|
440
|
+
status: statusMap[attributes.status] ?? 'active',
|
|
441
|
+
planId: attributes.variant_id !== undefined ? String(attributes.variant_id) : '',
|
|
442
|
+
// No `amount`: a Lemon Squeezy subscription object carries no price at all — the
|
|
443
|
+
// price belongs to the variant, and the charged total only appears on each
|
|
444
|
+
// subscription invoice.
|
|
445
|
+
...(attributes.trial_ends_at ? { trialEndsAt: attributes.trial_ends_at } : {}),
|
|
446
|
+
...(attributes.ends_at ? { endsAt: attributes.ends_at } : {}),
|
|
447
|
+
// `renews_at` is the next collection date, i.e. the end of the current period.
|
|
448
|
+
...(attributes.renews_at ? { currentPeriodEnd: attributes.renews_at } : {}),
|
|
449
|
+
payload: attributes,
|
|
450
|
+
createdAt: attributes.created_at ?? new Date().toISOString(),
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
#mapWebhookType(eventName, payload) {
|
|
454
|
+
switch (eventName) {
|
|
455
|
+
case 'order_created': {
|
|
456
|
+
// `order_created` fires for every order Lemon Squeezy records, including ones
|
|
457
|
+
// that failed or were refunded before delivery — so the order's own status, not
|
|
458
|
+
// the event name, decides what this means.
|
|
459
|
+
const status = payload.data?.attributes?.status;
|
|
460
|
+
if (status === 'refunded')
|
|
461
|
+
return 'payment.refunded';
|
|
462
|
+
if (status === 'failed')
|
|
463
|
+
return 'payment.failed';
|
|
464
|
+
if (status === 'paid')
|
|
465
|
+
return 'payment.succeeded';
|
|
466
|
+
return 'payment.updated';
|
|
467
|
+
}
|
|
468
|
+
case 'order_refunded':
|
|
469
|
+
case 'subscription_payment_refunded':
|
|
470
|
+
return 'payment.refunded';
|
|
471
|
+
case 'subscription_payment_success':
|
|
472
|
+
case 'subscription_payment_recovered':
|
|
473
|
+
return 'payment.succeeded';
|
|
474
|
+
case 'subscription_payment_failed':
|
|
475
|
+
return 'payment.failed';
|
|
476
|
+
case 'subscription_created':
|
|
477
|
+
return 'subscription.created';
|
|
478
|
+
case 'subscription_updated':
|
|
479
|
+
case 'subscription_plan_changed':
|
|
480
|
+
case 'subscription_paused':
|
|
481
|
+
case 'subscription_unpaused':
|
|
482
|
+
case 'subscription_resumed':
|
|
483
|
+
return 'subscription.updated';
|
|
484
|
+
case 'subscription_cancelled':
|
|
485
|
+
case 'subscription_expired':
|
|
486
|
+
return 'subscription.canceled';
|
|
487
|
+
default:
|
|
488
|
+
// `license_key_*`, `affiliate_activated`, `customer_updated` — passed through so an
|
|
489
|
+
// app handler can subscribe by the Lemon Squeezy name.
|
|
490
|
+
//
|
|
491
|
+
// There is deliberately no dispute type here: **Lemon Squeezy has NO dispute or
|
|
492
|
+
// chargeback event at all** — no warning, no chargeback, no resolution. The whole
|
|
493
|
+
// catalogue is `order_created`, `order_refunded`, the `subscription_*` family and
|
|
494
|
+
// `license_key_created` / `license_key_updated`; there is not even an
|
|
495
|
+
// `order_updated`, so an order that Lemon Squeezy later marks `fraudulent` (its
|
|
496
|
+
// status for a charged-back order) reaches this driver through no event whatsoever.
|
|
497
|
+
//
|
|
498
|
+
// That is the merchant-of-record bargain rather than an oversight: Lemon Squeezy is
|
|
499
|
+
// the seller on the buyer's statement, the chargeback is raised against Lemon
|
|
500
|
+
// Squeezy, and Lemon Squeezy "typically manages these disputes on behalf of the
|
|
501
|
+
// seller", deducting the amount plus a $15 dispute fee from the next payout. There
|
|
502
|
+
// is no deadline that belongs to you and no evidence you can file. Forcing an
|
|
503
|
+
// unrelated event into `payment.disputed` would invent a notification that does not
|
|
504
|
+
// exist; the honest answer is that the first you hear of a chargeback is the payout.
|
|
505
|
+
return eventName;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
#mapWebhookData(eventName, payload) {
|
|
509
|
+
const resource = payload.data;
|
|
510
|
+
const externalReference = this.#readExternalReference(payload.meta?.custom_data);
|
|
511
|
+
const common = {
|
|
512
|
+
...(externalReference !== undefined ? { externalReference } : {}),
|
|
513
|
+
...(payload.meta?.test_mode !== undefined ? { testMode: payload.meta.test_mode } : {}),
|
|
514
|
+
};
|
|
515
|
+
if (resource?.type === 'subscriptions') {
|
|
516
|
+
const subscription = this.#mapSubscription(resource);
|
|
517
|
+
return {
|
|
518
|
+
gatewayId: subscription.gatewayId,
|
|
519
|
+
customerId: subscription.customerId,
|
|
520
|
+
status: subscription.status,
|
|
521
|
+
planId: subscription.planId,
|
|
522
|
+
...(subscription.endsAt !== undefined ? { endsAt: subscription.endsAt } : {}),
|
|
523
|
+
...common,
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
if (resource?.type === 'subscription-invoices') {
|
|
527
|
+
const invoice = resource.attributes;
|
|
528
|
+
return {
|
|
529
|
+
gatewayId: resource.id,
|
|
530
|
+
amount: invoice.total,
|
|
531
|
+
currency: invoice.currency.toLowerCase(),
|
|
532
|
+
...(invoice.customer_id !== undefined ? { customerId: String(invoice.customer_id) } : {}),
|
|
533
|
+
...(invoice.subscription_id !== undefined
|
|
534
|
+
? { subscriptionId: String(invoice.subscription_id) }
|
|
535
|
+
: {}),
|
|
536
|
+
...common,
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
if (resource?.type === 'orders') {
|
|
540
|
+
const payment = this.#mapPayment(resource);
|
|
541
|
+
return {
|
|
542
|
+
gatewayId: payment.gatewayId,
|
|
543
|
+
amount: payment.amount.amount,
|
|
544
|
+
currency: payment.amount.currency,
|
|
545
|
+
...(payment.customerId !== undefined ? { customerId: payment.customerId } : {}),
|
|
546
|
+
...common,
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
return { gatewayId: resource?.id ?? '', eventName, ...common };
|
|
550
|
+
}
|
|
551
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────────────
|
|
552
|
+
/**
|
|
553
|
+
* Lemon Squeezy webhooks carry no event id, so one is derived. `updated_at` is part of
|
|
554
|
+
* it because two `subscription_updated` events for the same subscription are distinct
|
|
555
|
+
* events, and an id without it would make the second look like a replay to the billing
|
|
556
|
+
* layer's idempotency ledger.
|
|
557
|
+
*/
|
|
558
|
+
#webhookEventId(eventName, payload) {
|
|
559
|
+
const updatedAt = payload.data?.attributes?.updated_at;
|
|
560
|
+
const parts = [eventName, payload.data?.type ?? 'unknown', payload.data?.id ?? 'unknown'];
|
|
561
|
+
if (typeof updatedAt === 'string')
|
|
562
|
+
parts.push(updatedAt);
|
|
563
|
+
return parts.join(':');
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* The routing key is written to `checkout_data.custom.external_reference`, which Lemon
|
|
567
|
+
* Squeezy echoes on every webhook for the resulting order and subscription as
|
|
568
|
+
* `meta.custom_data`. `metadata.externalReference` is still honoured as a fallback for
|
|
569
|
+
* callers written before `CheckoutInput.externalReference` existed.
|
|
570
|
+
*/
|
|
571
|
+
#customData(input) {
|
|
572
|
+
const { email: _email, name: _name, taxNumber: _taxNumber, city: _city, region: _region, country: _country, externalReference: fromMetadata, ...rest } = input.metadata ?? {};
|
|
573
|
+
const reference = input.externalReference ?? (typeof fromMetadata === 'string' ? fromMetadata : undefined);
|
|
574
|
+
const custom = {
|
|
575
|
+
...rest,
|
|
576
|
+
...(reference !== undefined ? { external_reference: reference } : {}),
|
|
577
|
+
};
|
|
578
|
+
return Object.keys(custom).length > 0 ? custom : undefined;
|
|
579
|
+
}
|
|
580
|
+
#readExternalReference(customData) {
|
|
581
|
+
const value = customData?.external_reference;
|
|
582
|
+
// Lemon Squeezy returns custom values as strings, but a number written at checkout
|
|
583
|
+
// has been seen coming back as one — normalize rather than silently dropping the key.
|
|
584
|
+
if (typeof value === 'string')
|
|
585
|
+
return value;
|
|
586
|
+
if (typeof value === 'number')
|
|
587
|
+
return String(value);
|
|
588
|
+
return undefined;
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* Lemon Squeezy has **no request deduplication** — no `Idempotency-Key` header, no
|
|
592
|
+
* request-id field on any endpoint. So a key handed to this driver is refused rather
|
|
593
|
+
* than accepted and dropped: silently dropping it turns a caller's retry guarantee into
|
|
594
|
+
* a second refund or a second checkout.
|
|
595
|
+
*/
|
|
596
|
+
#refuseIdempotencyKey(key, operation) {
|
|
597
|
+
if (key === undefined)
|
|
598
|
+
return;
|
|
599
|
+
throw new Error(`[payments] Lemon Squeezy has no idempotency mechanism, so \`idempotencyKey\` cannot be honoured on ${operation}(). Its API deduplicates nothing, and a retried request performs the operation a second time — deduplicate on your side (persist the key and check it) before calling.`);
|
|
600
|
+
}
|
|
601
|
+
#refuseTaxId(taxId) {
|
|
602
|
+
if (taxId === undefined)
|
|
603
|
+
return;
|
|
604
|
+
throw new Error('[payments] Lemon Squeezy has no tax id on a customer — the buyer enters a tax ' +
|
|
605
|
+
'number at checkout. Pass it as `metadata.taxNumber` on `createCheckout()`.');
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* JSON:API needs `Accept` and `Content-Type` of `application/vnd.api+json`, and the
|
|
609
|
+
* shared `httpRequest` helper hardcodes `application/json` with no way to add headers —
|
|
610
|
+
* so this driver does its own `fetch`, throwing the same `Error` with a `status`
|
|
611
|
+
* property so `isNotFound` keeps working.
|
|
612
|
+
*/
|
|
613
|
+
async #request(path, options = {}) {
|
|
614
|
+
const requestInit = {
|
|
615
|
+
method: options.method ?? 'GET',
|
|
616
|
+
headers: {
|
|
617
|
+
Accept: 'application/vnd.api+json',
|
|
618
|
+
'Content-Type': 'application/vnd.api+json',
|
|
619
|
+
Authorization: `Bearer ${this.#apiKey}`,
|
|
620
|
+
},
|
|
621
|
+
};
|
|
622
|
+
if (options.body !== undefined)
|
|
623
|
+
requestInit.body = JSON.stringify(options.body);
|
|
624
|
+
const response = await fetch(`${this.#baseUrl}${path}`, requestInit);
|
|
625
|
+
if (!response.ok) {
|
|
626
|
+
const text = await response.text();
|
|
627
|
+
throw Object.assign(new Error(`[payments] HTTP request failed (${response.status}): ${text}`), { status: response.status });
|
|
628
|
+
}
|
|
629
|
+
return (await response.json());
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
//# sourceMappingURL=lemonsqueezy.js.map
|