@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,192 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { PaymentsProviderUnavailableError, PaymentsRailNotConfiguredError } from "../../error/errors";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The one door out to Lemon Squeezy, and the mapping from how it answered to what that means.
|
|
9
|
+
*
|
|
10
|
+
* Reached with `fetch` and JSON:API rather than through the `@lemonsqueezy/lemonsqueezy.js` package, for the
|
|
11
|
+
* reason `stripe/api.ts` gives: four calls are needed — create a checkout, read an order, read a
|
|
12
|
+
* subscription, read a customer — and each is one request. Every other third-party API in this repo is
|
|
13
|
+
* spoken to the same way: `fetch` out, Zod in.
|
|
14
|
+
*
|
|
15
|
+
* ## JSON:API, and the one thing it costs
|
|
16
|
+
*
|
|
17
|
+
* Lemon Squeezy speaks JSON:API, so every answer is `{ data: { id, type, attributes: {…} } }` and every
|
|
18
|
+
* request body is the same shape. The `id` is a **string containing an integer**, and the integers are
|
|
19
|
+
* **per object type**: order `8801` and subscription-invoice `8801` are different objects. Nothing in this
|
|
20
|
+
* module cares, but {@link namespacedId} exists because the projection very much does — see `objects.ts`.
|
|
21
|
+
*
|
|
22
|
+
* The `Accept` and `Content-Type` are `application/vnd.api+json`, which the API enforces. A request sent as
|
|
23
|
+
* `application/json` is refused with a 415, and that refusal reads as a configuration failure rather than
|
|
24
|
+
* as anything a buyer did — which is exactly how the mapping below treats it.
|
|
25
|
+
*
|
|
26
|
+
* ## How an answer is read
|
|
27
|
+
*
|
|
28
|
+
* **A 5xx or a 429 is `payments/provider_unavailable` (503).** Lemon Squeezy is up but not answering. The
|
|
29
|
+
* caller retries, and on the webhook path that code passes through the guard unchanged so the store
|
|
30
|
+
* redelivers rather than being told its signature was bad.
|
|
31
|
+
*
|
|
32
|
+
* **Every other 4xx is `payments/rail_not_configured` (404).** A 4xx here is never the buyer's fault: these
|
|
33
|
+
* requests are built entirely from config, from the credential bundle, and from rows we wrote. A rejected
|
|
34
|
+
* API key, a variant that does not belong to this store, a store id that is not ours — all one statement,
|
|
35
|
+
* that this project's Lemon Squeezy rail is not set up to do what it was asked.
|
|
36
|
+
*
|
|
37
|
+
* **A 404 is an absent resource only for a caller that said it was probing.** `absentOn404` is how a
|
|
38
|
+
* refresh distinguishes "Lemon Squeezy no longer knows this subscription" — a normal answer, which the
|
|
39
|
+
* contract says is `undefined` — from "our API key is wrong". Only a caller that knows the difference may
|
|
40
|
+
* claim it.
|
|
41
|
+
*
|
|
42
|
+
* **Nothing in a refusal carries the API key.** `detail` is written to an operator's logs, so anything
|
|
43
|
+
* key-shaped in a message is redacted before it gets there.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/** Lemon Squeezy's REST base. Public and stable, so it is pinned rather than configured. */
|
|
47
|
+
export const LEMON_SQUEEZY_API_BASE = "https://api.lemonsqueezy.com/v1";
|
|
48
|
+
|
|
49
|
+
/** The media type JSON:API mandates, and which this API enforces on both directions. */
|
|
50
|
+
const JSON_API_MEDIA_TYPE = "application/vnd.api+json";
|
|
51
|
+
|
|
52
|
+
/** One outbound request, narrowed to what this rail's endpoints need. */
|
|
53
|
+
export interface LemonSqueezyHttpRequest {
|
|
54
|
+
/** The HTTP method. */
|
|
55
|
+
method?: string;
|
|
56
|
+
/** Request headers — the bearer key and the JSON:API media type. */
|
|
57
|
+
headers?: Record<string, string>;
|
|
58
|
+
/** The JSON:API body, on a POST. */
|
|
59
|
+
body?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** What this rail needs of a response. Structural, so a test can answer without building a `Response`. */
|
|
63
|
+
export interface LemonSqueezyHttpResponse {
|
|
64
|
+
/** Whether the status is 2xx. */
|
|
65
|
+
ok: boolean;
|
|
66
|
+
/** The HTTP status. */
|
|
67
|
+
status: number;
|
|
68
|
+
/** The body as text — read once, then parsed here, so a non-JSON body is reportable rather than a throw. */
|
|
69
|
+
text(): Promise<string>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The HTTP seam. One explicit parameter defaulting to the runtime's `fetch`, as every rail has. */
|
|
73
|
+
export type LemonSqueezyHttpFetch = (url: string, init?: LemonSqueezyHttpRequest) => Promise<LemonSqueezyHttpResponse>;
|
|
74
|
+
|
|
75
|
+
/** The runtime's own `fetch`, adapted to the seam. */
|
|
76
|
+
export const lemonSqueezyHttpFetch: LemonSqueezyHttpFetch = (url, init) =>
|
|
77
|
+
fetch(url, init as RequestInit) as unknown as Promise<LemonSqueezyHttpResponse>;
|
|
78
|
+
|
|
79
|
+
/** Lemon Squeezy's error envelope — JSON:API's `errors` array, of which only the first is worth reporting. */
|
|
80
|
+
const LemonSqueezyApiError = z.object({
|
|
81
|
+
errors: z.array(
|
|
82
|
+
z.object({ status: z.string().optional(), title: z.string().optional(), detail: z.string().optional() }),
|
|
83
|
+
),
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
/** What one call needs: what it is for, the key, and optionally a body or a probe flag. */
|
|
87
|
+
export interface LemonSqueezyJsonOptions {
|
|
88
|
+
/** What was being asked for, in an operator's words. Lands in every refusal's `detail`. */
|
|
89
|
+
what: string;
|
|
90
|
+
/** The Lemon Squeezy API key. */
|
|
91
|
+
apiKey: string;
|
|
92
|
+
/** The JSON:API request body, on a POST. */
|
|
93
|
+
body?: unknown;
|
|
94
|
+
/** The query string, already composed. */
|
|
95
|
+
query?: Record<string, string>;
|
|
96
|
+
/** Whether a 404 means "no such object" rather than "this rail is misconfigured". */
|
|
97
|
+
absentOn404?: boolean;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* One call to Lemon Squeezy. Returns the parsed body, or `undefined` for a probing caller's 404.
|
|
102
|
+
*
|
|
103
|
+
* `unknown` rather than a typed answer: the caller narrows with its own Zod object, because what a
|
|
104
|
+
* subscription looks like is `objects.ts`'s business and not this module's.
|
|
105
|
+
*/
|
|
106
|
+
export async function lemonSqueezyJson(
|
|
107
|
+
transport: LemonSqueezyHttpFetch,
|
|
108
|
+
path: string,
|
|
109
|
+
options: LemonSqueezyJsonOptions,
|
|
110
|
+
): Promise<unknown | undefined> {
|
|
111
|
+
const query =
|
|
112
|
+
options.query === undefined || Object.keys(options.query).length === 0
|
|
113
|
+
? ""
|
|
114
|
+
: `?${new URLSearchParams(options.query).toString()}`;
|
|
115
|
+
const headers: Record<string, string> = {
|
|
116
|
+
authorization: `Bearer ${options.apiKey}`,
|
|
117
|
+
accept: JSON_API_MEDIA_TYPE,
|
|
118
|
+
};
|
|
119
|
+
if (options.body !== undefined) headers["content-type"] = JSON_API_MEDIA_TYPE;
|
|
120
|
+
|
|
121
|
+
let response: LemonSqueezyHttpResponse;
|
|
122
|
+
try {
|
|
123
|
+
response = await transport(`${LEMON_SQUEEZY_API_BASE}${path}${query}`, {
|
|
124
|
+
method: options.body === undefined ? "GET" : "POST",
|
|
125
|
+
headers,
|
|
126
|
+
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
|
127
|
+
});
|
|
128
|
+
} catch (cause) {
|
|
129
|
+
throw new PaymentsProviderUnavailableError(
|
|
130
|
+
{ detail: `Lemon Squeezy did not answer when asked for ${options.what}.` },
|
|
131
|
+
{ cause },
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const text = await response.text();
|
|
136
|
+
|
|
137
|
+
if (!response.ok) {
|
|
138
|
+
if (response.status === 404 && options.absentOn404) return undefined;
|
|
139
|
+
throw refusal(response.status, text, options);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
return JSON.parse(text) as unknown;
|
|
144
|
+
} catch (cause) {
|
|
145
|
+
throw new PaymentsProviderUnavailableError(
|
|
146
|
+
{ detail: `Lemon Squeezy answered with a non-JSON body when asked for ${options.what}.` },
|
|
147
|
+
{ cause },
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The refusal for a non-2xx answer: retryable if the store is struggling, a configuration failure otherwise. */
|
|
153
|
+
function refusal(status: number, body: string, options: LemonSqueezyJsonOptions): PaymentsProviderUnavailableError {
|
|
154
|
+
if (status === 429 || status >= 500) {
|
|
155
|
+
return new PaymentsProviderUnavailableError({
|
|
156
|
+
detail: `Lemon Squeezy answered ${status} when asked for ${options.what}. ${lemonSqueezySaid(body)}`,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const key = status === 401 || status === 403 ? " Check the API key stored for this environment." : "";
|
|
161
|
+
return new PaymentsRailNotConfiguredError({
|
|
162
|
+
detail: `Lemon Squeezy refused the request for ${options.what} with ${status}.${key} ${lemonSqueezySaid(body)}`,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Lemon Squeezy's own account of the failure, redacted. Empty when the body was not its error envelope. */
|
|
167
|
+
function lemonSqueezySaid(body: string): string {
|
|
168
|
+
let parsed: unknown;
|
|
169
|
+
try {
|
|
170
|
+
parsed = JSON.parse(body) as unknown;
|
|
171
|
+
} catch {
|
|
172
|
+
return "";
|
|
173
|
+
}
|
|
174
|
+
const envelope = LemonSqueezyApiError.safeParse(parsed);
|
|
175
|
+
if (!envelope.success) return "";
|
|
176
|
+
const first = envelope.data.errors[0];
|
|
177
|
+
if (first === undefined) return "";
|
|
178
|
+
const parts = [first.title, first.detail].filter((part): part is string => part !== undefined);
|
|
179
|
+
return parts.length === 0 ? "" : `Lemon Squeezy said: ${redactLemonSqueezySecrets(parts.join(" / "))}.`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Blank out anything key-shaped in text bound for a log.
|
|
184
|
+
*
|
|
185
|
+
* Belt and braces, exactly as the Stripe rail's equivalent: `detail` is written to an operator's logs, and
|
|
186
|
+
* a credential that reaches one has to be rotated. Lemon Squeezy API keys are long opaque strings with no
|
|
187
|
+
* fixed prefix, so what is matched is the shape a JWT-ish or base64url key takes at 40 characters or more —
|
|
188
|
+
* long enough that a variant id, a store id, or an ordinary sentence cannot trip it.
|
|
189
|
+
*/
|
|
190
|
+
export function redactLemonSqueezySecrets(text: string): string {
|
|
191
|
+
return text.replace(/\b[A-Za-z0-9_-]{40,}\b/g, "…");
|
|
192
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { PithyError } from "@pithy-sh/core/src/error/pithyError";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { encodeSubjectReference } from "../../data/subject";
|
|
7
|
+
import { PaymentsDiscountInvalidError, PaymentsProviderUnavailableError } from "../../error/errors";
|
|
8
|
+
import type { PaymentsLemonSqueezyCredentials } from "../../secret/registry";
|
|
9
|
+
import type { CheckoutHandoff, CheckoutSessionInput } from "../contract";
|
|
10
|
+
import { type LemonSqueezyHttpFetch, lemonSqueezyHttpFetch, lemonSqueezyJson } from "./api";
|
|
11
|
+
import {
|
|
12
|
+
accountReferenceProof,
|
|
13
|
+
LEMON_SQUEEZY_CUSTOM_ACCOUNT,
|
|
14
|
+
LEMON_SQUEEZY_CUSTOM_ENV,
|
|
15
|
+
LEMON_SQUEEZY_CUSTOM_PROOF,
|
|
16
|
+
} from "./objects";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Hosted checkout, and nothing else.
|
|
20
|
+
*
|
|
21
|
+
* Lemon Squeezy presents the payment page, takes the money as **merchant of record**, and owns the sales
|
|
22
|
+
* tax, the VAT registration, the invoice and the dunning. Pithy sends a browser there and hears the outcome
|
|
23
|
+
* on a webhook. That division is the whole reason this rail exists, and there is no card field anywhere in
|
|
24
|
+
* this package.
|
|
25
|
+
*
|
|
26
|
+
* ## What this call stamps, and why each of them
|
|
27
|
+
*
|
|
28
|
+
* **The resolved subject, in `checkout_data.custom`.** A Lemon Squeezy purchase is only ever heard about
|
|
29
|
+
* through a webhook, and that webhook carries a `customer_id` and no Pithy holder. This is the pairing, and it
|
|
30
|
+
* is why the `/checkout` route resolves the subject through the configured seam and never from a request body:
|
|
31
|
+
* a client that could name it could attach its purchase to somebody else's account, or somebody else's
|
|
32
|
+
* purchase to its own.
|
|
33
|
+
*
|
|
34
|
+
* The value is `encodeSubjectReference`'s output — `user:ada`, `organization:acme` — and **both halves travel
|
|
35
|
+
* or neither does**. Nothing keeps an organization id from equalling some user's, so a bare id read back at
|
|
36
|
+
* the far end would eventually hand one holder's purchase to the other. A bare id is also exactly what this
|
|
37
|
+
* rail stamped before subjects existed, and `decodeSubjectReference` refuses one on purpose: the purchase
|
|
38
|
+
* orphans, replayable, rather than being attributed to a stranger. The key name is unchanged through all of
|
|
39
|
+
* that — see `objects.ts`.
|
|
40
|
+
*
|
|
41
|
+
* **This deployment's environment, in the same place.** A Lemon Squeezy store is one namespace across every
|
|
42
|
+
* environment — test mode is a flag on an object, not a separate store — so `dev` and `staging` pointed at
|
|
43
|
+
* one store hear each other's webhooks. The stamp is what lets each ignore the other's.
|
|
44
|
+
*
|
|
45
|
+
* **Both keys are snake_case.** Lemon Squeezy normalizes custom keys before echoing them back, so a
|
|
46
|
+
* camelCase key sent is a snake_case key returned, and a reader looking for what it sent finds nothing. The
|
|
47
|
+
* binding silently never happens. Both sides read the same two constants, and a round-trip test pins it.
|
|
48
|
+
*
|
|
49
|
+
* ## What it deliberately does not do
|
|
50
|
+
*
|
|
51
|
+
* No quantity, no discount code, no plan-change or proration logic, and no price. The variant *is* the
|
|
52
|
+
* price — that is Lemon Squeezy's model — and a checkout that could name an amount would be a checkout a
|
|
53
|
+
* client could name an amount on.
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
/** What creating a checkout needs beyond the input: the credentials, the deployment, and the transport. */
|
|
57
|
+
export interface LemonSqueezyCheckoutOptions {
|
|
58
|
+
/** The rail's credentials. `apiKey` creates the checkout; `storeId` says which store it belongs to. */
|
|
59
|
+
credentials: PaymentsLemonSqueezyCredentials;
|
|
60
|
+
/** This deployment's `ENVIRONMENT`, stamped so its own webhooks are recognizable. */
|
|
61
|
+
deployment?: string;
|
|
62
|
+
/** The HTTP seam. Defaults to the runtime's `fetch`. */
|
|
63
|
+
transport?: LemonSqueezyHttpFetch;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A created checkout, narrowed to the one field a browser needs. */
|
|
67
|
+
const LemonSqueezyCheckout = z
|
|
68
|
+
.object({
|
|
69
|
+
data: z
|
|
70
|
+
.object({
|
|
71
|
+
id: z.string().describe("The checkout's id."),
|
|
72
|
+
attributes: z
|
|
73
|
+
.object({ url: z.string().min(1).describe("The hosted page to send the browser to.") })
|
|
74
|
+
.loose()
|
|
75
|
+
.describe("The checkout's fields."),
|
|
76
|
+
})
|
|
77
|
+
.loose(),
|
|
78
|
+
})
|
|
79
|
+
.describe("A created Lemon Squeezy checkout, as much of it as a redirect needs.");
|
|
80
|
+
|
|
81
|
+
/** Create a hosted checkout for one product, and return where to send the browser. */
|
|
82
|
+
export async function createLemonSqueezyCheckoutSession(
|
|
83
|
+
input: CheckoutSessionInput,
|
|
84
|
+
options: LemonSqueezyCheckoutOptions,
|
|
85
|
+
): Promise<CheckoutHandoff> {
|
|
86
|
+
// One encoding, from the one function. The webhook reads it back through `decodeSubjectReference`, and a
|
|
87
|
+
// second spelling anywhere is a purchase stamped by one code path and read by another.
|
|
88
|
+
const accountReference = encodeSubjectReference(input.subject);
|
|
89
|
+
|
|
90
|
+
const custom: Record<string, string> = { [LEMON_SQUEEZY_CUSTOM_ACCOUNT]: accountReference };
|
|
91
|
+
if (options.deployment !== undefined) {
|
|
92
|
+
custom[LEMON_SQUEEZY_CUSTOM_ENV] = options.deployment;
|
|
93
|
+
// The proof, without which the two values above are worth nothing: a stranger can set them from a
|
|
94
|
+
// public storefront buy link, and both key names are exported constants. This is the part they cannot
|
|
95
|
+
// produce. See `accountReferenceProof`.
|
|
96
|
+
custom[LEMON_SQUEEZY_CUSTOM_PROOF] = await accountReferenceProof(
|
|
97
|
+
accountReference,
|
|
98
|
+
options.deployment,
|
|
99
|
+
options.credentials.webhookSecret,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const created = await withDiscountRefusal(input.discountCode, () =>
|
|
104
|
+
lemonSqueezyJson(options.transport ?? lemonSqueezyHttpFetch, "/checkouts", {
|
|
105
|
+
what: "a checkout",
|
|
106
|
+
apiKey: options.credentials.apiKey,
|
|
107
|
+
body: {
|
|
108
|
+
data: {
|
|
109
|
+
type: "checkouts",
|
|
110
|
+
attributes: {
|
|
111
|
+
checkout_data: {
|
|
112
|
+
// The resolved subject, echoed back on every webhook this purchase produces.
|
|
113
|
+
custom,
|
|
114
|
+
// The code, handed over exactly as the caller typed it. Lemon Squeezy resolves it and computes
|
|
115
|
+
// the price; nothing here validates it or multiplies anything. Unlike Stripe there is no id to
|
|
116
|
+
// look up — the store takes the customer-facing string — so an unusable code is learned from
|
|
117
|
+
// the store's own refusal, which `lemonSqueezyJson` surfaces below.
|
|
118
|
+
...(input.discountCode === undefined ? {} : { discount_code: input.discountCode }),
|
|
119
|
+
},
|
|
120
|
+
product_options: {
|
|
121
|
+
redirect_url: input.successUrl,
|
|
122
|
+
},
|
|
123
|
+
checkout_options: {
|
|
124
|
+
// Lemon Squeezy's own page, unembedded. Pithy owns no payment UI.
|
|
125
|
+
embed: false,
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
relationships: {
|
|
129
|
+
store: { data: { type: "stores", id: options.credentials.storeId } },
|
|
130
|
+
variant: { data: { type: "variants", id: input.providerProductId } },
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
}),
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
const parsed = LemonSqueezyCheckout.safeParse(created);
|
|
138
|
+
if (!parsed.success) {
|
|
139
|
+
// Lemon Squeezy created something and did not say where to send the browser. A redirect to an absent
|
|
140
|
+
// URL is worse than a refusal the caller can retry.
|
|
141
|
+
throw new PaymentsProviderUnavailableError({
|
|
142
|
+
detail: "Lemon Squeezy created a checkout with no URL to redirect to.",
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
return { kind: "redirect", url: parsed.data.data.attributes.url };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Re-read a refused checkout as a refused *code*, when a code was sent.
|
|
150
|
+
*
|
|
151
|
+
* Lemon Squeezy takes the customer-facing string rather than an id, so there is no lookup to learn a bad
|
|
152
|
+
* code from before the fact — the store's refusal of the whole checkout is the first news of it. Left alone
|
|
153
|
+
* that surfaces as `payments/rail_not_configured`, which tells a customer this payment method is
|
|
154
|
+
* unavailable when what actually happened is that their code was not accepted.
|
|
155
|
+
*
|
|
156
|
+
* The reclassification is narrow and its reasoning is the same one `api.ts` uses to call a 4xx a
|
|
157
|
+
* configuration failure: **every other input to this request comes from config, the credential bundle, or a
|
|
158
|
+
* row we wrote.** The discount code is the only caller-influenced value in it, so when one was sent and the
|
|
159
|
+
* store refuses on the caller's side of the line, the code is what it refused. A 5xx or a 429 is untouched —
|
|
160
|
+
* that is the store struggling, not judging — and so is a refusal when no code was sent at all.
|
|
161
|
+
*
|
|
162
|
+
* The store's own sentence rides in `detail` for the operator; the customer gets the code back and an action
|
|
163
|
+
* they can take.
|
|
164
|
+
*/
|
|
165
|
+
async function withDiscountRefusal<T>(code: string | undefined, call: () => Promise<T>): Promise<T> {
|
|
166
|
+
if (code === undefined) return await call();
|
|
167
|
+
try {
|
|
168
|
+
return await call();
|
|
169
|
+
} catch (cause) {
|
|
170
|
+
// A 401 or a 403 is our credentials, not their code, and `api.ts` folds both into
|
|
171
|
+
// `rail_not_configured` — so reclassifying every one of those would tell a customer their perfectly
|
|
172
|
+
// good code was rejected while the real fault is an API key nobody rotated. `detail` still carries the
|
|
173
|
+
// status, which is what distinguishes them.
|
|
174
|
+
const credentials = /with 40[13]\./.test(cause instanceof PithyError ? (cause.payload.detail ?? "") : "");
|
|
175
|
+
if (cause instanceof PithyError && cause.payload.code === "payments/rail_not_configured" && !credentials) {
|
|
176
|
+
throw new PaymentsDiscountInvalidError(
|
|
177
|
+
{
|
|
178
|
+
message: `"${code}" is not a discount code we can accept.`,
|
|
179
|
+
detail: `Lemon Squeezy refused a checkout carrying discount code "${code}". ${cause.payload.detail ?? ""}`,
|
|
180
|
+
},
|
|
181
|
+
{ cause },
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
throw cause;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import type { CreatedDiscount, DiscountTerms } from "../../data/discount";
|
|
6
|
+
import { PaymentsDiscountInvalidError } from "../../error/errors";
|
|
7
|
+
import type { PaymentsLemonSqueezyCredentials } from "../../secret/registry";
|
|
8
|
+
import type { ListedDiscount } from "../contract";
|
|
9
|
+
import { type LemonSqueezyHttpFetch, lemonSqueezyHttpFetch, lemonSqueezyJson } from "./api";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Minting a discount at Lemon Squeezy, from the normalized terms.
|
|
13
|
+
*
|
|
14
|
+
* One object rather than Stripe's two: a Discount carries the money, the duration, the redemption limit and
|
|
15
|
+
* the expiry together, and the code is a field on it rather than a separate object.
|
|
16
|
+
*
|
|
17
|
+
* ## The two translations that decide whether a customer is charged correctly
|
|
18
|
+
*
|
|
19
|
+
* **Duration.** Lemon Squeezy counts in **billing periods** — `duration_in_months` is its field name, and
|
|
20
|
+
* the name is a trap, because the store applies the number to renewals rather than to months. That happens
|
|
21
|
+
* to be the unit `DiscountDuration` already uses, so the number passes through unchanged. **This is the rail
|
|
22
|
+
* where the value is *not* converted, and Stripe is the one where it is** — which is precisely why neither
|
|
23
|
+
* rail's field name was allowed to become the normalized one. `discounts.test.ts` pins an annual plan on
|
|
24
|
+
* both rails and asserts the two produce the same customer-visible term from different numbers.
|
|
25
|
+
*
|
|
26
|
+
* **Expiry.** `redeemableUntil` means the code can no longer be *claimed*. Lemon Squeezy's `expires_at`
|
|
27
|
+
* stops redemption and leaves an existing subscriber's discount running, which is the same meaning — so it
|
|
28
|
+
* maps directly, and a test pins that a code already redeemed keeps its rate past the date.
|
|
29
|
+
*
|
|
30
|
+
* ## Amount, and the currency that arrives at the customer
|
|
31
|
+
*
|
|
32
|
+
* A fixed amount carries a currency; Lemon Squeezy applies it against a store whose subscriptions have their
|
|
33
|
+
* own. A mismatch is not refused at creation by the store — it fails at *redemption*, in front of whoever
|
|
34
|
+
* typed the code. `createDiscount` on this rail therefore refuses a fixed amount in a currency the store
|
|
35
|
+
* does not use before sending anything, naming both currencies, which is the only moment the adopter is
|
|
36
|
+
* still the one reading the error.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/** What creating a discount needs: the credentials, the store's currency, and the transport. */
|
|
40
|
+
export interface LemonSqueezyDiscountOptions {
|
|
41
|
+
/** The rail's credentials. `apiKey` creates; `storeId` says which store it belongs to. */
|
|
42
|
+
credentials: PaymentsLemonSqueezyCredentials;
|
|
43
|
+
/**
|
|
44
|
+
* The currency this store prices in, when it is known.
|
|
45
|
+
*
|
|
46
|
+
* Supplied so a fixed amount in another currency is refused here rather than at redemption. Absent means
|
|
47
|
+
* the check cannot be made and the store's own judgment stands — better than inventing a currency.
|
|
48
|
+
*/
|
|
49
|
+
storeCurrency?: string;
|
|
50
|
+
/** The HTTP seam. Defaults to the runtime's `fetch`. */
|
|
51
|
+
transport?: LemonSqueezyHttpFetch;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A created discount, narrowed to what an adopter needs back. */
|
|
55
|
+
const LemonSqueezyDiscount = z
|
|
56
|
+
.object({
|
|
57
|
+
data: z
|
|
58
|
+
.object({
|
|
59
|
+
id: z.string().min(1),
|
|
60
|
+
attributes: z.object({ code: z.string().min(1) }).loose(),
|
|
61
|
+
})
|
|
62
|
+
.loose(),
|
|
63
|
+
})
|
|
64
|
+
.loose();
|
|
65
|
+
|
|
66
|
+
/** Create a discount and return the code a customer will type. */
|
|
67
|
+
export async function createLemonSqueezyDiscount(
|
|
68
|
+
terms: DiscountTerms,
|
|
69
|
+
options: LemonSqueezyDiscountOptions,
|
|
70
|
+
): Promise<CreatedDiscount> {
|
|
71
|
+
if (
|
|
72
|
+
terms.amount.kind === "fixed" &&
|
|
73
|
+
options.storeCurrency !== undefined &&
|
|
74
|
+
options.storeCurrency.toLowerCase() !== terms.amount.currency.toLowerCase()
|
|
75
|
+
) {
|
|
76
|
+
// Refused before anything is sent. At the store this would be accepted and then fail when somebody
|
|
77
|
+
// redeems it — so the adopter who can fix it would never see the error, and the customer who cannot
|
|
78
|
+
// would.
|
|
79
|
+
throw new PaymentsDiscountInvalidError({
|
|
80
|
+
message: "That discount is in a currency this store does not sell in.",
|
|
81
|
+
action: `Create it in ${options.storeCurrency.toUpperCase()}, or use a percentage instead.`,
|
|
82
|
+
detail: `A fixed discount of ${terms.amount.amountMinor} ${terms.amount.currency.toUpperCase()} cannot apply to a store selling in ${options.storeCurrency.toUpperCase()}. Lemon Squeezy accepts the object and refuses it at redemption, in front of the customer.`,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const attributes: Record<string, unknown> = {
|
|
87
|
+
name: terms.code ?? "Discount",
|
|
88
|
+
amount_type: terms.amount.kind === "percent" ? "percent" : "fixed",
|
|
89
|
+
amount: terms.amount.kind === "percent" ? terms.amount.percent : terms.amount.amountMinor,
|
|
90
|
+
// `once` | `forever` | `repeating`, the same three words. Only the number beneath differs.
|
|
91
|
+
duration: terms.duration.kind,
|
|
92
|
+
is_limited_redemptions: terms.maxRedemptions !== undefined,
|
|
93
|
+
};
|
|
94
|
+
if (terms.code !== undefined) attributes.code = terms.code;
|
|
95
|
+
if (terms.duration.kind === "repeating") {
|
|
96
|
+
// **Not converted.** The field is called `duration_in_months` and counts billing periods; see the module
|
|
97
|
+
// doc. Converting here is the annual-plan defect, committed on the other rail.
|
|
98
|
+
attributes.duration_in_months = terms.duration.billingPeriods;
|
|
99
|
+
}
|
|
100
|
+
if (terms.maxRedemptions !== undefined) attributes.max_redemptions = terms.maxRedemptions;
|
|
101
|
+
if (terms.redeemableUntil !== undefined) attributes.expires_at = terms.redeemableUntil.toISOString();
|
|
102
|
+
|
|
103
|
+
const created = await lemonSqueezyJson(options.transport ?? lemonSqueezyHttpFetch, "/discounts", {
|
|
104
|
+
what: "a discount",
|
|
105
|
+
apiKey: options.credentials.apiKey,
|
|
106
|
+
body: {
|
|
107
|
+
data: {
|
|
108
|
+
type: "discounts",
|
|
109
|
+
attributes,
|
|
110
|
+
relationships: { store: { data: { type: "stores", id: options.credentials.storeId } } },
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const parsed = LemonSqueezyDiscount.safeParse(created);
|
|
116
|
+
if (!parsed.success) {
|
|
117
|
+
throw new PaymentsDiscountInvalidError({
|
|
118
|
+
detail: "Lemon Squeezy created a discount and returned no code for it.",
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
return { code: parsed.data.data.attributes.code, providerDiscountId: parsed.data.data.id, terms };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Lemon Squeezy's discount list, narrowed to what a management pane shows. */
|
|
125
|
+
const LemonSqueezyDiscountList = z
|
|
126
|
+
.object({
|
|
127
|
+
data: z.array(
|
|
128
|
+
z
|
|
129
|
+
.object({
|
|
130
|
+
id: z.string().min(1),
|
|
131
|
+
attributes: z
|
|
132
|
+
.object({
|
|
133
|
+
code: z.string().min(1),
|
|
134
|
+
amount: z.number().nullish(),
|
|
135
|
+
amount_type: z.string().nullish(),
|
|
136
|
+
duration: z.string().nullish(),
|
|
137
|
+
})
|
|
138
|
+
.loose(),
|
|
139
|
+
})
|
|
140
|
+
.loose(),
|
|
141
|
+
),
|
|
142
|
+
})
|
|
143
|
+
.loose();
|
|
144
|
+
|
|
145
|
+
/** The discount codes this store holds. */
|
|
146
|
+
export async function listLemonSqueezyDiscounts(
|
|
147
|
+
options: LemonSqueezyDiscountOptions,
|
|
148
|
+
): Promise<readonly ListedDiscount[]> {
|
|
149
|
+
const found = await lemonSqueezyJson(options.transport ?? lemonSqueezyHttpFetch, "/discounts", {
|
|
150
|
+
what: "the discount codes",
|
|
151
|
+
apiKey: options.credentials.apiKey,
|
|
152
|
+
query: { "filter[store_id]": options.credentials.storeId },
|
|
153
|
+
});
|
|
154
|
+
const parsed = LemonSqueezyDiscountList.safeParse(found);
|
|
155
|
+
if (!parsed.success) return [];
|
|
156
|
+
return parsed.data.data.map((discount) => ({
|
|
157
|
+
code: discount.attributes.code,
|
|
158
|
+
providerDiscountId: discount.id,
|
|
159
|
+
amount:
|
|
160
|
+
discount.attributes.amount_type === "percent"
|
|
161
|
+
? `${discount.attributes.amount ?? 0}%`
|
|
162
|
+
: `${discount.attributes.amount ?? 0} ${options.storeCurrency ?? ""}`.trim(),
|
|
163
|
+
// Lemon Squeezy does not report a redemption count on the discount object.
|
|
164
|
+
redemptions: null,
|
|
165
|
+
}));
|
|
166
|
+
}
|