@pithy-sh/payments 0.1.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/LICENSE +21 -0
- package/README.md +157 -0
- package/dist/paddle-prices.iife.js +1 -0
- package/dist/pithy.manifest.json +68 -0
- package/docs/lemon-squeezy.md +167 -0
- package/docs/paddle.md +486 -0
- package/package.json +73 -0
- package/pithy.manifest.json +68 -0
- package/src/admin/coverage.ts +107 -0
- package/src/admin/read.ts +360 -0
- package/src/audit/actions.ts +214 -0
- package/src/capability.ts +377 -0
- package/src/client/api.ts +734 -0
- package/src/client/checkout.ts +252 -0
- package/src/client/fixtures/price-preview-de.json +54 -0
- package/src/client/fixtures/price-preview-gb.json +54 -0
- package/src/client/fixtures/price-preview-jp-yen.json +54 -0
- package/src/client/fixtures/price-preview-us-country-only.json +54 -0
- package/src/client/fixtures/price-preview-us-new-york.json +54 -0
- package/src/client/fixtures/pricePreview.ts +67 -0
- package/src/client/hooks.ts +590 -0
- package/src/client/paddle.ts +733 -0
- package/src/client/paddleCache.ts +265 -0
- package/src/client/paddlePrices.iife.ts +43 -0
- package/src/client/paddlePrices.ts +143 -0
- package/src/client/paddlePricesTag.ts +332 -0
- package/src/client/projection.ts +133 -0
- package/src/client/test-utils/cacheStore.ts +41 -0
- package/src/client/wholeUnits.ts +147 -0
- package/src/cloudflare-test.d.ts +16 -0
- package/src/config/config.ts +563 -0
- package/src/data/discount.ts +238 -0
- package/src/data/entitlement.ts +67 -0
- package/src/data/money.ts +75 -0
- package/src/data/pause.ts +148 -0
- package/src/data/providerAccount.ts +47 -0
- package/src/data/purchase.ts +131 -0
- package/src/data/rail.ts +74 -0
- package/src/data/reconcileRun.ts +208 -0
- package/src/data/renderMoney.ts +180 -0
- package/src/data/status.ts +67 -0
- package/src/data/subject.ts +148 -0
- package/src/data/subscription.ts +675 -0
- package/src/data/syncCursor.ts +44 -0
- package/src/data/tables.ts +61 -0
- package/src/data/webhookEvent.ts +196 -0
- package/src/entitlement/manual.ts +209 -0
- package/src/entitlement/resolver.ts +125 -0
- package/src/entitlement/subjectSeam.ts +171 -0
- package/src/error/errors.ts +421 -0
- package/src/grants/apply.ts +207 -0
- package/src/grants/clawback.ts +128 -0
- package/src/grants/ledgerSeam.ts +141 -0
- package/src/http/dispatch.ts +48 -0
- package/src/http/guards.ts +58 -0
- package/src/http/manifestConfig.ts +56 -0
- package/src/http/responses.ts +1147 -0
- package/src/http/routes.ts +2911 -0
- package/src/http/schemas.ts +577 -0
- package/src/http/scopes.ts +260 -0
- package/src/http/view.ts +188 -0
- package/src/http/webhookGuard.ts +341 -0
- package/src/index.ts +231 -0
- package/src/migrations/0001_purchases.ts +312 -0
- package/src/pricing/location.ts +175 -0
- package/src/pricing/visitor.ts +82 -0
- package/src/projection/event.ts +101 -0
- package/src/projection/orphans.ts +226 -0
- package/src/projection/owner.ts +247 -0
- package/src/projection/resolve.ts +57 -0
- package/src/projection/writer.ts +554 -0
- package/src/provision/resolvePaymentsConfig.ts +91 -0
- package/src/rails/apple/certs.ts +66 -0
- package/src/rails/apple/der.ts +249 -0
- package/src/rails/apple/fixtures/chain.ts +412 -0
- package/src/rails/apple/fixtures/did-fail-to-renew-grace.json +43 -0
- package/src/rails/apple/fixtures/did-renew.json +48 -0
- package/src/rails/apple/fixtures/one-time-charge.json +32 -0
- package/src/rails/apple/fixtures/refund.json +33 -0
- package/src/rails/apple/fixtures/subscribed-initial-buy-sandbox.json +38 -0
- package/src/rails/apple/fixtures/test.json +14 -0
- package/src/rails/apple/http.ts +128 -0
- package/src/rails/apple/jws.ts +123 -0
- package/src/rails/apple/notification.ts +357 -0
- package/src/rails/apple/rail.ts +114 -0
- package/src/rails/apple/serverApi.ts +412 -0
- package/src/rails/apple/verify.ts +93 -0
- package/src/rails/apple/x509.ts +424 -0
- package/src/rails/contract.ts +939 -0
- package/src/rails/google/fixtures/play-product-purchased.json +14 -0
- package/src/rails/google/fixtures/play-subscription-active.json +23 -0
- package/src/rails/google/fixtures/push.ts +152 -0
- package/src/rails/google/fixtures/rtdn-one-time-purchased.json +11 -0
- package/src/rails/google/fixtures/rtdn-subscription-renewed.json +11 -0
- package/src/rails/google/fixtures/rtdn-subscription-revoked.json +11 -0
- package/src/rails/google/fixtures/rtdn-test.json +8 -0
- package/src/rails/google/fixtures/rtdn-voided-purchase.json +11 -0
- package/src/rails/google/http.ts +124 -0
- package/src/rails/google/jwt.ts +127 -0
- package/src/rails/google/oidc.ts +308 -0
- package/src/rails/google/playApi.ts +598 -0
- package/src/rails/google/rail.ts +174 -0
- package/src/rails/google/rtdn.ts +331 -0
- package/src/rails/google/verify.ts +127 -0
- package/src/rails/lemonSqueezy/api.ts +192 -0
- package/src/rails/lemonSqueezy/checkout.ts +186 -0
- package/src/rails/lemonSqueezy/discounts.ts +166 -0
- package/src/rails/lemonSqueezy/fixtures/events.ts +180 -0
- package/src/rails/lemonSqueezy/objects.ts +511 -0
- package/src/rails/lemonSqueezy/portal.ts +82 -0
- package/src/rails/lemonSqueezy/pricing.ts +124 -0
- package/src/rails/lemonSqueezy/rail.ts +137 -0
- package/src/rails/lemonSqueezy/read.ts +72 -0
- package/src/rails/lemonSqueezy/refresh.ts +86 -0
- package/src/rails/lemonSqueezy/signature.ts +119 -0
- package/src/rails/lemonSqueezy/verify.ts +45 -0
- package/src/rails/lemonSqueezy/webhook.ts +326 -0
- package/src/rails/paddle/adjustments.ts +94 -0
- package/src/rails/paddle/api.ts +266 -0
- package/src/rails/paddle/checkout.ts +240 -0
- package/src/rails/paddle/discounts.ts +176 -0
- package/src/rails/paddle/events.ts +285 -0
- package/src/rails/paddle/fixtures/browserForged.ts +54 -0
- package/src/rails/paddle/fixtures/pausedSubscription.ts +62 -0
- package/src/rails/paddle/objects.ts +883 -0
- package/src/rails/paddle/portal.ts +130 -0
- package/src/rails/paddle/rail.ts +219 -0
- package/src/rails/paddle/read.ts +108 -0
- package/src/rails/paddle/recorded.ts +83 -0
- package/src/rails/paddle/refresh.ts +153 -0
- package/src/rails/paddle/refund.ts +297 -0
- package/src/rails/paddle/signature.ts +225 -0
- package/src/rails/paddle/subscription.ts +793 -0
- package/src/rails/paddle/verify.ts +120 -0
- package/src/rails/paddle/webhook.ts +405 -0
- package/src/rails/providers.ts +168 -0
- package/src/rails/stripe/api.ts +253 -0
- package/src/rails/stripe/checkout.ts +175 -0
- package/src/rails/stripe/discounts.ts +169 -0
- package/src/rails/stripe/fixtures/event-charge-refunded.json +23 -0
- package/src/rails/stripe/fixtures/event-invoice-paid.json +19 -0
- package/src/rails/stripe/fixtures/event-session-completed-payment.json +27 -0
- package/src/rails/stripe/fixtures/event-session-completed-subscription.json +27 -0
- package/src/rails/stripe/fixtures/event-subscription-canceled.json +43 -0
- package/src/rails/stripe/fixtures/event-subscription-created.json +42 -0
- package/src/rails/stripe/fixtures/event-subscription-deleted.json +44 -0
- package/src/rails/stripe/fixtures/events.ts +111 -0
- package/src/rails/stripe/objects.ts +642 -0
- package/src/rails/stripe/portal.ts +45 -0
- package/src/rails/stripe/pricing.ts +123 -0
- package/src/rails/stripe/rail.ts +139 -0
- package/src/rails/stripe/refresh.ts +79 -0
- package/src/rails/stripe/signature.ts +127 -0
- package/src/rails/stripe/verify.ts +105 -0
- package/src/rails/stripe/webhook.ts +79 -0
- package/src/secret/registry.ts +275 -0
- package/src/seeds/example.ts +181 -0
- package/src/version.generated.ts +16 -0
- package/src/workflows/paddleSweep.ts +717 -0
- package/src/workflows/railAccess.ts +87 -0
- package/src/workflows/reconcile.ts +648 -0
- package/src/workflows/report.ts +64 -0
- package/src/workflows/retryPolicy.ts +40 -0
- package/src/workflows/specs.ts +143 -0
- package/src/workflows/worker.ts +166 -0
- package/src/workflows/workerConfig.ts +73 -0
- package/src/workflows/wrangler.jsonc +55 -0
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { pauseResumesAt } from "../../data/pause";
|
|
6
|
+
import type { PurchaseEnvironment } from "../../data/purchase";
|
|
7
|
+
import type { PurchaseStatus } from "../../data/status";
|
|
8
|
+
import { decodeSubjectReference } from "../../data/subject";
|
|
9
|
+
import { PaymentsVerificationFailedError } from "../../error/errors";
|
|
10
|
+
import type { UnboundProviderEvent } from "../contract";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Lemon Squeezy's objects, and the mapping from what its webhooks say to what this package stores.
|
|
14
|
+
*
|
|
15
|
+
* ## Identifiers are namespaced, and that is not a style choice
|
|
16
|
+
*
|
|
17
|
+
* Lemon Squeezy numbers **each object type from one**, and renders the number as a JSON:API string. So order
|
|
18
|
+
* `8801` and subscription-invoice `8801` are different objects wearing the same `data.id`. A purchase row's
|
|
19
|
+
* identity is `UNIQUE (rail, providerTransactionId)`, so storing the bare id would silently fuse an order and
|
|
20
|
+
* an invoice into one row — one buyer's refund landing on another buyer's subscription.
|
|
21
|
+
*
|
|
22
|
+
* Stripe never faced this because `pi_`, `in_` and `sub_` are globally distinct, so the Stripe rail stores its
|
|
23
|
+
* ids verbatim. Here the type has to be part of the key, and {@link namespacedId} is the only way an id is
|
|
24
|
+
* allowed to reach the writer. The prefix is stable forever: it is inside the row's identity.
|
|
25
|
+
*
|
|
26
|
+
* ## Two rows per subscription, and why
|
|
27
|
+
*
|
|
28
|
+
* Lemon Squeezy splits money from state at the source. `subscription_payment_*` delivers a **subscription
|
|
29
|
+
* invoice**: an amount, a currency, and nothing about whether the subscription is still live.
|
|
30
|
+
* `subscription_*` delivers a **subscription**: a status and a renewal date, and no charge. Neither names the
|
|
31
|
+
* other's key — an LS subscription has no latest-invoice pointer — so there is no honest way to collapse them
|
|
32
|
+
* onto one row.
|
|
33
|
+
*
|
|
34
|
+
* Collapsing them anyway would mean stamping two different clocks into one monotonic `providerEventAt`: the
|
|
35
|
+
* invoice's `updated_at` lives in the invoice domain and moves when a refund is issued months later, while the
|
|
36
|
+
* subscription's moves on every renewal. That is the defect the CMS learned expensively — an invoice timestamp
|
|
37
|
+
* written into a subscription's watermark drops the `subscription_updated` that follows a renewal.
|
|
38
|
+
*
|
|
39
|
+
* So: a **state row** keyed on the subscription, and a **money row** per invoice, distinguished by
|
|
40
|
+
* `PurchaseRole`. Each row is monotonic on exactly one provider clock, and an invoice event physically cannot
|
|
41
|
+
* address the state row because the keys differ. That is a property of the schema rather than of careful code.
|
|
42
|
+
* Only the money rows fulfill a `grants` clause, which is what makes N renewals credit exactly N times.
|
|
43
|
+
*
|
|
44
|
+
* ## The environment fence
|
|
45
|
+
*
|
|
46
|
+
* A Lemon Squeezy store is **one namespace across every environment** — test mode is a flag on an object, not
|
|
47
|
+
* a separate store — so a `dev` deployment and a `staging` deployment pointed at one store both hear
|
|
48
|
+
* everything the other's buyers do. `createCheckoutSession` stamps this deployment's `ENVIRONMENT` into
|
|
49
|
+
* `checkout_data.custom`, the store echoes it back as `meta.custom_data`, and an event stamped for somebody
|
|
50
|
+
* else projects nothing: no row, no entitlement, no audit warning, and a 200.
|
|
51
|
+
*
|
|
52
|
+
* An event carrying **no** stamp is not fenced. An order placed in the Lemon Squeezy storefront rather than
|
|
53
|
+
* through our checkout carries no `custom_data` at all, and fencing on absence would silently drop real sales.
|
|
54
|
+
*
|
|
55
|
+
* `test_mode` is a separate axis and drives `environment`, which the projection writer refuses across.
|
|
56
|
+
*
|
|
57
|
+
* ## Custom data is snake_case, in both directions
|
|
58
|
+
*
|
|
59
|
+
* Lemon Squeezy normalizes custom keys to snake_case before echoing them back. What checkout sends and what
|
|
60
|
+
* this module reads must therefore both be snake_case, even though the rest of this codebase is camelCase.
|
|
61
|
+
* Sending `pithyEnv` and reading `pithy_env` silently never matches, and the binding never happens — so the
|
|
62
|
+
* two constants below are the single source for both sides and the round-trip is pinned by a test.
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The `checkout_data.custom` key carrying the subject reference. snake_case, because LS normalizes to it.
|
|
67
|
+
*
|
|
68
|
+
* **The name is frozen even though its meaning moved.** What travels here is now
|
|
69
|
+
* `encodeSubjectReference`'s output — `user:ada`, `organization:acme` — because a purchase can be held by an
|
|
70
|
+
* organization. This string is a wire contract with Lemon Squeezy rather than an identifier of ours: a
|
|
71
|
+
* checkout stamped today is a hosted page open in somebody's browser and a subscription that renews for
|
|
72
|
+
* months, and the store echoes whatever it was given back on every delivery that purchase ever produces. A
|
|
73
|
+
* renamed key reads nothing on any of them — every in-flight purchase would come back naming nobody,
|
|
74
|
+
* permanently, with a `linkProviderAccount` that never rebinds behind it. Rename it and the loss is silent.
|
|
75
|
+
*/
|
|
76
|
+
export const LEMON_SQUEEZY_CUSTOM_ACCOUNT = "pithy_account_reference";
|
|
77
|
+
|
|
78
|
+
/** The `checkout_data.custom` key carrying this deployment's environment. snake_case, for the same reason. */
|
|
79
|
+
export const LEMON_SQUEEZY_CUSTOM_ENV = "pithy_env";
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The `checkout_data.custom` key carrying the proof that **this deployment's server** wrote the two above.
|
|
83
|
+
*
|
|
84
|
+
* Without it the other two prove nothing. Lemon Squeezy's public storefront buy links accept
|
|
85
|
+
* `checkout[custom][...]` parameters and the webhook echoes them exactly as an API-created checkout's would,
|
|
86
|
+
* so anyone can set `pithy_account_reference`. The environment stamp does not save it: these key names are
|
|
87
|
+
* exported constants in an open-source package and the value is one of three (`dev`, `staging`, `prod`), so
|
|
88
|
+
* requiring it asks an attacker to guess nothing at all.
|
|
89
|
+
*
|
|
90
|
+
* A MAC is the only thing that distinguishes the two, because it is the only thing a stranger cannot
|
|
91
|
+
* produce. See {@link accountReferenceProof}.
|
|
92
|
+
*/
|
|
93
|
+
export const LEMON_SQUEEZY_CUSTOM_PROOF = "pithy_ref_proof";
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* One identifier, prefixed by the object type it came from.
|
|
97
|
+
*
|
|
98
|
+
* The prefix is part of the row's identity forever, so it is never derived from anything that could change
|
|
99
|
+
* spelling. Three values, and they are the only three.
|
|
100
|
+
*/
|
|
101
|
+
export function namespacedId(type: "subscription" | "subscription_invoice" | "order", id: string): string {
|
|
102
|
+
return `${type}:${id}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** A JSON:API envelope, narrowed to what any of this rail's objects need. `.loose()` — LS adds fields. */
|
|
106
|
+
const LemonSqueezyObject = z
|
|
107
|
+
.object({
|
|
108
|
+
id: z.string().describe("The object's own id — an integer as a string, unique only within its type."),
|
|
109
|
+
attributes: z.record(z.string(), z.unknown()).describe("The object's fields, whose shape depends on its type."),
|
|
110
|
+
})
|
|
111
|
+
.loose();
|
|
112
|
+
|
|
113
|
+
/** The webhook envelope every delivery arrives in. */
|
|
114
|
+
export const LemonSqueezyWebhook = z
|
|
115
|
+
.object({
|
|
116
|
+
meta: z
|
|
117
|
+
.object({
|
|
118
|
+
event_name: z.string().describe("Which event this is — `order_created`, `subscription_updated`, and so on."),
|
|
119
|
+
test_mode: z
|
|
120
|
+
.boolean()
|
|
121
|
+
.optional()
|
|
122
|
+
.describe("Whether the object belongs to the store's test mode. Drives `environment`, never the fence."),
|
|
123
|
+
custom_data: z
|
|
124
|
+
.record(z.string(), z.unknown())
|
|
125
|
+
.optional()
|
|
126
|
+
.describe(
|
|
127
|
+
"What this deployment's own server attached when it created the checkout, echoed back with keys normalized to snake_case. Absent for a storefront purchase.",
|
|
128
|
+
),
|
|
129
|
+
})
|
|
130
|
+
.loose()
|
|
131
|
+
.describe("The delivery's metadata — what happened, and what we asked to have echoed back."),
|
|
132
|
+
data: LemonSqueezyObject.describe("The object the event is about: an order, a subscription, or an invoice."),
|
|
133
|
+
})
|
|
134
|
+
.loose()
|
|
135
|
+
.describe("One Lemon Squeezy webhook delivery, as received.");
|
|
136
|
+
export type LemonSqueezyWebhook = z.infer<typeof LemonSqueezyWebhook>;
|
|
137
|
+
|
|
138
|
+
/** A subscription's fields, as a `subscription_*` webhook and the API both present them. */
|
|
139
|
+
export const LemonSqueezySubscription = z
|
|
140
|
+
.object({
|
|
141
|
+
store_id: z.number().optional().describe("The store the subscription belongs to."),
|
|
142
|
+
customer_id: z.number().optional().describe("The Lemon Squeezy customer — this rail's `providerAccountId`."),
|
|
143
|
+
variant_id: z.number().optional().describe("The variant sold, which is this rail's SKU."),
|
|
144
|
+
status: z.string().describe("Lemon Squeezy's own status vocabulary, normalized by `subscriptionStatus`."),
|
|
145
|
+
renews_at: z.string().nullish().describe("When the current period renews, if it is going to."),
|
|
146
|
+
ends_at: z.string().nullish().describe("When access ends for a canceled subscription, if it is ending."),
|
|
147
|
+
pause: z
|
|
148
|
+
.object({
|
|
149
|
+
mode: z.string().nullish().describe("`void` or `free` — what the subscriber keeps while paused. Recorded."),
|
|
150
|
+
resumes_at: z
|
|
151
|
+
.string()
|
|
152
|
+
.nullish()
|
|
153
|
+
.describe("When the pause ends, as Lemon Squeezy states it. Null for an open-ended pause."),
|
|
154
|
+
})
|
|
155
|
+
.loose()
|
|
156
|
+
.nullish()
|
|
157
|
+
.describe("The pause in force, when one is. Null while the subscription is not paused."),
|
|
158
|
+
created_at: z.string().describe("When the subscription began."),
|
|
159
|
+
updated_at: z.string().describe("The subscription's own clock — the state row's `providerEventAt`."),
|
|
160
|
+
test_mode: z.boolean().optional().describe("Whether this is a test-mode subscription."),
|
|
161
|
+
})
|
|
162
|
+
.loose()
|
|
163
|
+
.describe("A Lemon Squeezy subscription — the standing of a recurring purchase, carrying no charge.");
|
|
164
|
+
export type LemonSqueezySubscription = z.infer<typeof LemonSqueezySubscription>;
|
|
165
|
+
|
|
166
|
+
/** A subscription invoice's fields — one billing period's money. */
|
|
167
|
+
export const LemonSqueezySubscriptionInvoice = z
|
|
168
|
+
.object({
|
|
169
|
+
store_id: z.number().optional().describe("The store the invoice belongs to."),
|
|
170
|
+
subscription_id: z.number().describe("The subscription this invoice bills — the family key."),
|
|
171
|
+
customer_id: z.number().optional().describe("The Lemon Squeezy customer charged."),
|
|
172
|
+
status: z.string().describe("The invoice's own status — `paid`, `pending`, `void`, `refunded`."),
|
|
173
|
+
refunded: z.boolean().optional().describe("Whether this invoice was refunded, in whole or in part."),
|
|
174
|
+
refunded_at: z.string().nullish().describe("When it was refunded, if it was."),
|
|
175
|
+
currency: z.string().optional().describe("The ISO currency the total is in."),
|
|
176
|
+
total: z.number().optional().describe("The amount charged, in the currency's minor unit."),
|
|
177
|
+
created_at: z.string().describe("When the invoice was raised — the money row's `purchasedAt`."),
|
|
178
|
+
updated_at: z.string().describe("The invoice's own clock — the money row's `providerEventAt`."),
|
|
179
|
+
test_mode: z.boolean().optional().describe("Whether this is a test-mode invoice."),
|
|
180
|
+
})
|
|
181
|
+
.loose()
|
|
182
|
+
.describe("A Lemon Squeezy subscription invoice — one billing period's charge, carrying no subscription state.");
|
|
183
|
+
export type LemonSqueezySubscriptionInvoice = z.infer<typeof LemonSqueezySubscriptionInvoice>;
|
|
184
|
+
|
|
185
|
+
/** An order's fields — a one-off purchase. */
|
|
186
|
+
export const LemonSqueezyOrder = z
|
|
187
|
+
.object({
|
|
188
|
+
store_id: z.number().optional().describe("The store the order belongs to."),
|
|
189
|
+
customer_id: z.number().optional().describe("The Lemon Squeezy customer who bought."),
|
|
190
|
+
status: z.string().describe("The order's status — `paid`, `refunded`, `pending`, `failed`."),
|
|
191
|
+
refunded: z.boolean().optional().describe("Whether the order was refunded."),
|
|
192
|
+
refunded_at: z.string().nullish().describe("When it was refunded, if it was."),
|
|
193
|
+
currency: z.string().optional().describe("The ISO currency the total is in."),
|
|
194
|
+
total: z.number().optional().describe("The amount charged, in the currency's minor unit."),
|
|
195
|
+
first_order_item: z
|
|
196
|
+
.object({ variant_id: z.number().optional().describe("The variant bought — this rail's SKU.") })
|
|
197
|
+
.loose()
|
|
198
|
+
.optional()
|
|
199
|
+
.describe("The order's first line item, which is where a single-product order carries its variant."),
|
|
200
|
+
created_at: z.string().describe("When the order was placed."),
|
|
201
|
+
updated_at: z.string().describe("The order's own clock."),
|
|
202
|
+
test_mode: z.boolean().optional().describe("Whether this is a test-mode order."),
|
|
203
|
+
})
|
|
204
|
+
.loose()
|
|
205
|
+
.describe("A Lemon Squeezy order — a one-off purchase, where money and state are the same object.");
|
|
206
|
+
export type LemonSqueezyOrder = z.infer<typeof LemonSqueezyOrder>;
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Which store environment an object belongs to.
|
|
210
|
+
*
|
|
211
|
+
* `=== false ? "production"` and never `=== true ? "sandbox"`, so an absent flag lands on sandbox. The
|
|
212
|
+
* failure directions are not symmetric: treating production as sandbox loses a purchase that reconciliation
|
|
213
|
+
* repairs, while treating sandbox as production hands out real entitlements for test transactions.
|
|
214
|
+
*/
|
|
215
|
+
function environment(testMode: boolean | undefined): PurchaseEnvironment {
|
|
216
|
+
return testMode === false ? "production" : "sandbox";
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** A Lemon Squeezy timestamp, or a refusal. Its API emits ISO-8601 throughout. */
|
|
220
|
+
function at(value: string, what: string): Date {
|
|
221
|
+
const parsed = new Date(value);
|
|
222
|
+
if (Number.isNaN(parsed.getTime())) {
|
|
223
|
+
throw new PaymentsVerificationFailedError({
|
|
224
|
+
detail: `Lemon Squeezy: ${what} is not a readable timestamp.`,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
return parsed;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* A subscription's status, normalized.
|
|
232
|
+
*
|
|
233
|
+
* `on_trial` grants: a trial is access the store is currently giving, and refusing it would lock a user out
|
|
234
|
+
* of the thing they signed up for. `unpaid` is `on_hold` rather than `expired` — Lemon Squeezy has exhausted
|
|
235
|
+
* its dunning and the money never arrived, so nothing was paid and nothing may be credited. An unknown
|
|
236
|
+
* status refuses rather than guessing, which is the Stripe rail's rule too: a status this package has never
|
|
237
|
+
* seen is a shape change worth failing loudly on.
|
|
238
|
+
*/
|
|
239
|
+
export function subscriptionStatus(status: string): PurchaseStatus {
|
|
240
|
+
switch (status) {
|
|
241
|
+
case "on_trial":
|
|
242
|
+
case "active":
|
|
243
|
+
return "active";
|
|
244
|
+
case "paused":
|
|
245
|
+
return "paused";
|
|
246
|
+
case "past_due":
|
|
247
|
+
return "in_grace";
|
|
248
|
+
case "unpaid":
|
|
249
|
+
return "on_hold";
|
|
250
|
+
case "cancelled":
|
|
251
|
+
return "canceled";
|
|
252
|
+
case "expired":
|
|
253
|
+
return "expired";
|
|
254
|
+
default:
|
|
255
|
+
throw new PaymentsVerificationFailedError({
|
|
256
|
+
detail: `Lemon Squeezy: subscription status "${status}" is not one this build maps.`,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* A subscription invoice's status, normalized — the money's own story, not the subscription's.
|
|
263
|
+
*
|
|
264
|
+
* A paid invoice is `expired` rather than `active`, and that is deliberate: it records a billing period that
|
|
265
|
+
* was paid for and has closed, and `expired` is the status that credits a `grants` clause without granting
|
|
266
|
+
* access. Access comes from the state row. A `void` or `failed` invoice is `never_paid`, which credits
|
|
267
|
+
* nothing and is never clawed back, because no money ever moved.
|
|
268
|
+
*/
|
|
269
|
+
export function invoiceStatus(invoice: LemonSqueezySubscriptionInvoice): PurchaseStatus {
|
|
270
|
+
if (invoice.refunded === true || invoice.status === "refunded") return "refunded";
|
|
271
|
+
switch (invoice.status) {
|
|
272
|
+
case "paid":
|
|
273
|
+
return "expired";
|
|
274
|
+
case "pending":
|
|
275
|
+
// An invoice raised and not yet settled. Neither status grants on a money row, but `on_hold` is what
|
|
276
|
+
// "the payment is outstanding" means, and the two must not drift apart across the pair.
|
|
277
|
+
return "on_hold";
|
|
278
|
+
case "void":
|
|
279
|
+
case "failed":
|
|
280
|
+
return "never_paid";
|
|
281
|
+
default:
|
|
282
|
+
throw new PaymentsVerificationFailedError({
|
|
283
|
+
detail: `Lemon Squeezy: subscription invoice status "${invoice.status}" is not one this build maps.`,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** An order's status, normalized. An order is money and state at once, so a paid one grants. */
|
|
289
|
+
export function orderStatus(order: LemonSqueezyOrder): PurchaseStatus {
|
|
290
|
+
if (order.refunded === true || order.status === "refunded") return "refunded";
|
|
291
|
+
switch (order.status) {
|
|
292
|
+
case "paid":
|
|
293
|
+
return "active";
|
|
294
|
+
case "pending":
|
|
295
|
+
// `on_hold`, not `in_grace`. Grace means a *paid* subscriber whose renewal is retrying, and it grants
|
|
296
|
+
// by policy — on an order, which carries no expiry, that would be permanent free access for money
|
|
297
|
+
// that never arrived. `on_hold` is the status for a payment outstanding, and it never grants.
|
|
298
|
+
return "on_hold";
|
|
299
|
+
case "failed":
|
|
300
|
+
return "never_paid";
|
|
301
|
+
default:
|
|
302
|
+
throw new PaymentsVerificationFailedError({
|
|
303
|
+
detail: `Lemon Squeezy: order status "${order.status}" is not one this build maps.`,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** The state row a subscription object implies. Carries no amount — a subscription is not a charge. */
|
|
309
|
+
export function subscriptionEvent(id: string, subscription: LemonSqueezySubscription): UnboundProviderEvent {
|
|
310
|
+
const status = subscriptionStatus(subscription.status);
|
|
311
|
+
const ends = subscription.ends_at ?? subscription.renews_at ?? null;
|
|
312
|
+
return {
|
|
313
|
+
rail: "lemonSqueezy",
|
|
314
|
+
providerTransactionId: namespacedId("subscription", id),
|
|
315
|
+
// Uniform with the money rows, which name this same subscription as their family. A state row is its own
|
|
316
|
+
// family's head, so both columns carry the same value and the owner lookup matches on either.
|
|
317
|
+
originalTransactionId: namespacedId("subscription", id),
|
|
318
|
+
providerProductId: String(subscription.variant_id ?? ""),
|
|
319
|
+
role: "state",
|
|
320
|
+
status,
|
|
321
|
+
environment: environment(subscription.test_mode),
|
|
322
|
+
purchasedAt: at(subscription.created_at, "a subscription's created_at"),
|
|
323
|
+
expiresAt: ends === null ? null : at(ends, "a subscription's ends_at"),
|
|
324
|
+
revokedAt: null,
|
|
325
|
+
// Lemon Squeezy's own answer to "when does this come back". Null on the pause object is an open-ended
|
|
326
|
+
// pause, which `pauseResumesAt` keeps distinct from a subscription that is not paused at all.
|
|
327
|
+
resumesAt: pauseResumesAt({ rail: "lemonSqueezy", status, reported: subscription.pause?.resumes_at }),
|
|
328
|
+
amountMinor: null,
|
|
329
|
+
currency: null,
|
|
330
|
+
providerEventAt: at(subscription.updated_at, "a subscription's updated_at"),
|
|
331
|
+
payload: { ...subscription },
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** The money row a subscription invoice implies, given the variant its subscription is for. */
|
|
336
|
+
export function invoiceEvent(
|
|
337
|
+
id: string,
|
|
338
|
+
invoice: LemonSqueezySubscriptionInvoice,
|
|
339
|
+
variantId: string,
|
|
340
|
+
): UnboundProviderEvent {
|
|
341
|
+
const status = invoiceStatus(invoice);
|
|
342
|
+
const raised = at(invoice.created_at, "an invoice's created_at");
|
|
343
|
+
return {
|
|
344
|
+
rail: "lemonSqueezy",
|
|
345
|
+
providerTransactionId: namespacedId("subscription_invoice", id),
|
|
346
|
+
originalTransactionId: namespacedId("subscription", String(invoice.subscription_id)),
|
|
347
|
+
providerProductId: variantId,
|
|
348
|
+
role: "charge",
|
|
349
|
+
status,
|
|
350
|
+
environment: environment(invoice.test_mode),
|
|
351
|
+
purchasedAt: raised,
|
|
352
|
+
// An already-closed window rather than null. A money row must never be the reason access is granted —
|
|
353
|
+
// that is the state row's job — and `expired` with a past expiry says exactly what this row is.
|
|
354
|
+
expiresAt: raised,
|
|
355
|
+
revokedAt: status === "refunded" ? at(invoice.refunded_at ?? invoice.updated_at, "an invoice's refunded_at") : null,
|
|
356
|
+
amountMinor: invoice.total ?? null,
|
|
357
|
+
currency: invoice.currency ?? null,
|
|
358
|
+
providerEventAt: at(invoice.updated_at, "an invoice's updated_at"),
|
|
359
|
+
payload: { ...invoice },
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** The row a one-off order implies. Money and state at once, so one row does both jobs. */
|
|
364
|
+
export function orderEvent(id: string, order: LemonSqueezyOrder): UnboundProviderEvent {
|
|
365
|
+
const status = orderStatus(order);
|
|
366
|
+
return {
|
|
367
|
+
rail: "lemonSqueezy",
|
|
368
|
+
providerTransactionId: namespacedId("order", id),
|
|
369
|
+
originalTransactionId: null,
|
|
370
|
+
providerProductId: String(order.first_order_item?.variant_id ?? ""),
|
|
371
|
+
role: "charge",
|
|
372
|
+
status,
|
|
373
|
+
environment: environment(order.test_mode),
|
|
374
|
+
purchasedAt: at(order.created_at, "an order's created_at"),
|
|
375
|
+
expiresAt: null,
|
|
376
|
+
revokedAt: status === "refunded" ? at(order.refunded_at ?? order.updated_at, "an order's refunded_at") : null,
|
|
377
|
+
amountMinor: order.total ?? null,
|
|
378
|
+
currency: order.currency ?? null,
|
|
379
|
+
providerEventAt: at(order.updated_at, "an order's updated_at"),
|
|
380
|
+
payload: { ...order },
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* The account reference this deployment stamped into the checkout, or null.
|
|
386
|
+
*
|
|
387
|
+
* **Honored only when this deployment's own environment stamp is beside it**, and that condition is the
|
|
388
|
+
* whole security of the field. `accountReference`'s contract says it is "a value this deployment's own
|
|
389
|
+
* server wrote and the store returned unchanged" — the route writes the provider-account link from it, and
|
|
390
|
+
* `linkProviderAccount` never rebinds, so the first pairing is permanent.
|
|
391
|
+
*
|
|
392
|
+
* Lemon Squeezy's public storefront buy links accept `checkout[custom][...]` query parameters, and the
|
|
393
|
+
* resulting webhook echoes them in `meta.custom_data` exactly as an API-created checkout's would. So
|
|
394
|
+
* `custom_data` on its own is **not** evidence our server wrote anything: an unauthenticated stranger can
|
|
395
|
+
* put any string in it and permanently bind their store customer to an account they chose. The env stamp is
|
|
396
|
+
* the discriminator, because it is written only by {@link createLemonSqueezyCheckoutSession}, and a checkout
|
|
397
|
+
* a stranger built through the storefront carries either no stamp or another deployment's.
|
|
398
|
+
*
|
|
399
|
+
* A deployment that does not know its own `ENVIRONMENT` trusts no reference at all. That is the safe
|
|
400
|
+
* direction: the cost is a purchase that lands unbound and is repairable from the trail, where the other way
|
|
401
|
+
* round is an unauthenticated write into the account map that nothing ever undoes.
|
|
402
|
+
*
|
|
403
|
+
* **And the value must be the subject encoding, which the MAC alone does not make it.** A stamp can be
|
|
404
|
+
* authentic and still name nobody: a bare id is what this rail wrote before subjects existed, and reading
|
|
405
|
+
* one as a user would hand a purchase to whoever else holds that id. So the reference is put through
|
|
406
|
+
* `decodeSubjectReference` — the one decoder, never a split written here — and anything that is not exactly
|
|
407
|
+
* the encoding is refused in the same direction as everything else above. The string is returned rather than
|
|
408
|
+
* the pair because a rail reports `accountReference` as a string; the route decodes it again through the same
|
|
409
|
+
* function, and gets the same answer.
|
|
410
|
+
*/
|
|
411
|
+
export async function accountReferenceOf(
|
|
412
|
+
webhook: LemonSqueezyWebhook,
|
|
413
|
+
deployment: string | undefined,
|
|
414
|
+
secret: string,
|
|
415
|
+
): Promise<string | null> {
|
|
416
|
+
if (deployment === undefined) return null;
|
|
417
|
+
|
|
418
|
+
const custom = webhook.meta.custom_data;
|
|
419
|
+
const reference = custom?.[LEMON_SQUEEZY_CUSTOM_ACCOUNT];
|
|
420
|
+
const stampedEnv = custom?.[LEMON_SQUEEZY_CUSTOM_ENV];
|
|
421
|
+
const proof = custom?.[LEMON_SQUEEZY_CUSTOM_PROOF];
|
|
422
|
+
if (typeof reference !== "string" || reference === "") return null;
|
|
423
|
+
if (typeof stampedEnv !== "string" || typeof proof !== "string") return null;
|
|
424
|
+
|
|
425
|
+
// Fail closed on the shape before spending an HMAC on it. A reference that does not decode names nobody
|
|
426
|
+
// however well it is proven, so there is nothing a MAC could add.
|
|
427
|
+
if (decodeSubjectReference(reference) === undefined) return null;
|
|
428
|
+
|
|
429
|
+
// The environment is checked as part of the MAC's message rather than beside it, so a proof minted for
|
|
430
|
+
// staging cannot be replayed against production by editing one field.
|
|
431
|
+
if (stampedEnv !== deployment) return null;
|
|
432
|
+
return (await accountReferenceProofMatches(reference, deployment, secret, proof)) ? reference : null;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* The proof this deployment stamps beside an account reference, and the only reason the reference is worth
|
|
437
|
+
* anything.
|
|
438
|
+
*
|
|
439
|
+
* An HMAC over the environment and the subject reference, keyed with a secret a stranger does not have. The
|
|
440
|
+
* message is domain-separated by a fixed prefix so this MAC can never be confused with the webhook-body
|
|
441
|
+
* signature that shares its key: the two answer different questions, and a value that satisfied both would be
|
|
442
|
+
* a cross-protocol attack waiting to be found.
|
|
443
|
+
*
|
|
444
|
+
* The key is the webhook signing secret because it is already per-environment, already rotated with the
|
|
445
|
+
* rail, and already known only to this deployment and to Lemon Squeezy — and Lemon Squeezy is not the
|
|
446
|
+
* attacker here. The attacker is anyone who can open a storefront buy link, which is everyone.
|
|
447
|
+
*/
|
|
448
|
+
export async function accountReferenceProof(reference: string, deployment: string, secret: string): Promise<string> {
|
|
449
|
+
const key = await crypto.subtle.importKey(
|
|
450
|
+
"raw",
|
|
451
|
+
new TextEncoder().encode(secret),
|
|
452
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
453
|
+
false,
|
|
454
|
+
["sign"],
|
|
455
|
+
);
|
|
456
|
+
const message = `${ACCOUNT_REFERENCE_DOMAIN}${deployment}:${reference}`;
|
|
457
|
+
const mac = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(message));
|
|
458
|
+
return [...new Uint8Array(mac)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** Domain separation, so this MAC and the webhook body signature can never be mistaken for one another. */
|
|
462
|
+
const ACCOUNT_REFERENCE_DOMAIN = "pithy:lemonSqueezy:account-reference:v1:";
|
|
463
|
+
|
|
464
|
+
/** Whether a stamped proof matches, compared in constant time by the runtime rather than by `===`. */
|
|
465
|
+
async function accountReferenceProofMatches(
|
|
466
|
+
reference: string,
|
|
467
|
+
deployment: string,
|
|
468
|
+
secret: string,
|
|
469
|
+
candidate: string,
|
|
470
|
+
): Promise<boolean> {
|
|
471
|
+
const expected = await accountReferenceProof(reference, deployment, secret);
|
|
472
|
+
if (candidate.length !== expected.length) return false;
|
|
473
|
+
const key = await crypto.subtle.importKey(
|
|
474
|
+
"raw",
|
|
475
|
+
new TextEncoder().encode(secret),
|
|
476
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
477
|
+
false,
|
|
478
|
+
["verify"],
|
|
479
|
+
);
|
|
480
|
+
const bytes = hexToBytes(candidate);
|
|
481
|
+
if (bytes === undefined) return false;
|
|
482
|
+
return await crypto.subtle.verify(
|
|
483
|
+
"HMAC",
|
|
484
|
+
key,
|
|
485
|
+
bytes,
|
|
486
|
+
new TextEncoder().encode(`${ACCOUNT_REFERENCE_DOMAIN}${deployment}:${reference}`),
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** Decode hex, or undefined when it is not hex. */
|
|
491
|
+
function hexToBytes(value: string): Uint8Array | undefined {
|
|
492
|
+
if (value.length === 0 || value.length % 2 !== 0 || !/^[0-9a-fA-F]+$/.test(value)) return undefined;
|
|
493
|
+
const bytes = new Uint8Array(value.length / 2);
|
|
494
|
+
for (let index = 0; index < bytes.length; index += 1) {
|
|
495
|
+
bytes[index] = Number.parseInt(value.slice(index * 2, index * 2 + 2), 16);
|
|
496
|
+
}
|
|
497
|
+
return bytes;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Whether this delivery belongs to another deployment sharing the same store.
|
|
502
|
+
*
|
|
503
|
+
* True only when both sides named an environment and they differ. An unstamped delivery is ours to consider
|
|
504
|
+
* — a storefront order has no `custom_data` — and a deployment that does not know its own environment
|
|
505
|
+
* fences nothing, which is precisely how the other three rails behave.
|
|
506
|
+
*/
|
|
507
|
+
export function fencedOut(webhook: LemonSqueezyWebhook, deployment: string | undefined): boolean {
|
|
508
|
+
const stamped = webhook.meta.custom_data?.[LEMON_SQUEEZY_CUSTOM_ENV];
|
|
509
|
+
if (typeof stamped !== "string" || stamped === "" || deployment === undefined) return false;
|
|
510
|
+
return stamped !== deployment;
|
|
511
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { PaymentsProviderUnavailableError } from "../../error/errors";
|
|
6
|
+
import type { PaymentsLemonSqueezyCredentials } from "../../secret/registry";
|
|
7
|
+
import type { PortalHandoff, PortalSessionInput } from "../contract";
|
|
8
|
+
import { type LemonSqueezyHttpFetch, lemonSqueezyHttpFetch, lemonSqueezyJson } from "./api";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The customer portal — where a Lemon Squeezy subscriber changes a card or cancels, under Lemon Squeezy's
|
|
12
|
+
* own rules.
|
|
13
|
+
*
|
|
14
|
+
* ## Why this is a read and not a create
|
|
15
|
+
*
|
|
16
|
+
* Stripe mints a Billing Portal *session* per caller. Lemon Squeezy does not: the portal link is an
|
|
17
|
+
* attribute of the customer object, already signed and already expiring, and reading the customer is how you
|
|
18
|
+
* get it. So this asks for the customer the provider-account map resolved and returns the link it carries.
|
|
19
|
+
*
|
|
20
|
+
* That is why there is no `returnUrl` here. Lemon Squeezy's portal takes no return parameter — the
|
|
21
|
+
* subscriber closes the tab — and {@link PortalSessionInput.returnUrl} is optional precisely so this rail
|
|
22
|
+
* can decline it in the type rather than accept one and silently drop it. A project running this rail alone
|
|
23
|
+
* is never asked to configure a URL that nothing would use.
|
|
24
|
+
*
|
|
25
|
+
* ## The caller never names the customer
|
|
26
|
+
*
|
|
27
|
+
* `providerAccountId` comes from the provider-account map, keyed on the authenticated caller — never from a
|
|
28
|
+
* request body. The `/portal` route takes no body at all, which is the request contract that makes this
|
|
29
|
+
* safe: a `customer` field there would let anyone read anyone's billing portal, and the link is a bearer
|
|
30
|
+
* credential for that account's subscription.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** What creating a portal link needs: the credentials, and the transport. */
|
|
34
|
+
export interface LemonSqueezyPortalOptions {
|
|
35
|
+
/** The rail's credentials. Only `apiKey` is used here. */
|
|
36
|
+
credentials: PaymentsLemonSqueezyCredentials;
|
|
37
|
+
/** The HTTP seam. Defaults to the runtime's `fetch`. */
|
|
38
|
+
transport?: LemonSqueezyHttpFetch;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A customer, narrowed to the portal link it carries. */
|
|
42
|
+
const LemonSqueezyCustomer = z.object({
|
|
43
|
+
data: z
|
|
44
|
+
.object({
|
|
45
|
+
attributes: z
|
|
46
|
+
.object({
|
|
47
|
+
urls: z
|
|
48
|
+
.object({
|
|
49
|
+
customer_portal: z
|
|
50
|
+
.string()
|
|
51
|
+
.min(1)
|
|
52
|
+
.describe("The signed, expiring portal link. A bearer credential for this account's billing."),
|
|
53
|
+
})
|
|
54
|
+
.loose()
|
|
55
|
+
.describe("The customer's links."),
|
|
56
|
+
})
|
|
57
|
+
.loose(),
|
|
58
|
+
})
|
|
59
|
+
.loose(),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
/** The portal link for one customer. */
|
|
63
|
+
export async function createLemonSqueezyPortalSession(
|
|
64
|
+
input: PortalSessionInput,
|
|
65
|
+
options: LemonSqueezyPortalOptions,
|
|
66
|
+
): Promise<PortalHandoff> {
|
|
67
|
+
const customer = await lemonSqueezyJson(
|
|
68
|
+
options.transport ?? lemonSqueezyHttpFetch,
|
|
69
|
+
`/customers/${encodeURIComponent(input.providerAccountId)}`,
|
|
70
|
+
{ what: "a customer portal link", apiKey: options.credentials.apiKey },
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const parsed = LemonSqueezyCustomer.safeParse(customer);
|
|
74
|
+
if (!parsed.success) {
|
|
75
|
+
// A customer with no portal link is a customer Lemon Squeezy will not let us manage. Nothing here
|
|
76
|
+
// recovers from that, and sending a browser nowhere is worse than a refusal.
|
|
77
|
+
throw new PaymentsProviderUnavailableError({
|
|
78
|
+
detail: `Lemon Squeezy returned customer ${input.providerAccountId} with no customer_portal link.`,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
return { url: parsed.data.data.attributes.urls.customer_portal };
|
|
82
|
+
}
|