@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.
Files changed (167) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +157 -0
  3. package/dist/paddle-prices.iife.js +1 -0
  4. package/dist/pithy.manifest.json +68 -0
  5. package/docs/lemon-squeezy.md +167 -0
  6. package/docs/paddle.md +486 -0
  7. package/package.json +73 -0
  8. package/pithy.manifest.json +68 -0
  9. package/src/admin/coverage.ts +107 -0
  10. package/src/admin/read.ts +360 -0
  11. package/src/audit/actions.ts +214 -0
  12. package/src/capability.ts +377 -0
  13. package/src/client/api.ts +734 -0
  14. package/src/client/checkout.ts +252 -0
  15. package/src/client/fixtures/price-preview-de.json +54 -0
  16. package/src/client/fixtures/price-preview-gb.json +54 -0
  17. package/src/client/fixtures/price-preview-jp-yen.json +54 -0
  18. package/src/client/fixtures/price-preview-us-country-only.json +54 -0
  19. package/src/client/fixtures/price-preview-us-new-york.json +54 -0
  20. package/src/client/fixtures/pricePreview.ts +67 -0
  21. package/src/client/hooks.ts +590 -0
  22. package/src/client/paddle.ts +733 -0
  23. package/src/client/paddleCache.ts +265 -0
  24. package/src/client/paddlePrices.iife.ts +43 -0
  25. package/src/client/paddlePrices.ts +143 -0
  26. package/src/client/paddlePricesTag.ts +332 -0
  27. package/src/client/projection.ts +133 -0
  28. package/src/client/test-utils/cacheStore.ts +41 -0
  29. package/src/client/wholeUnits.ts +147 -0
  30. package/src/cloudflare-test.d.ts +16 -0
  31. package/src/config/config.ts +563 -0
  32. package/src/data/discount.ts +238 -0
  33. package/src/data/entitlement.ts +67 -0
  34. package/src/data/money.ts +75 -0
  35. package/src/data/pause.ts +148 -0
  36. package/src/data/providerAccount.ts +47 -0
  37. package/src/data/purchase.ts +131 -0
  38. package/src/data/rail.ts +74 -0
  39. package/src/data/reconcileRun.ts +208 -0
  40. package/src/data/renderMoney.ts +180 -0
  41. package/src/data/status.ts +67 -0
  42. package/src/data/subject.ts +148 -0
  43. package/src/data/subscription.ts +675 -0
  44. package/src/data/syncCursor.ts +44 -0
  45. package/src/data/tables.ts +61 -0
  46. package/src/data/webhookEvent.ts +196 -0
  47. package/src/entitlement/manual.ts +209 -0
  48. package/src/entitlement/resolver.ts +125 -0
  49. package/src/entitlement/subjectSeam.ts +171 -0
  50. package/src/error/errors.ts +421 -0
  51. package/src/grants/apply.ts +207 -0
  52. package/src/grants/clawback.ts +128 -0
  53. package/src/grants/ledgerSeam.ts +141 -0
  54. package/src/http/dispatch.ts +48 -0
  55. package/src/http/guards.ts +58 -0
  56. package/src/http/manifestConfig.ts +56 -0
  57. package/src/http/responses.ts +1147 -0
  58. package/src/http/routes.ts +2911 -0
  59. package/src/http/schemas.ts +577 -0
  60. package/src/http/scopes.ts +260 -0
  61. package/src/http/view.ts +188 -0
  62. package/src/http/webhookGuard.ts +341 -0
  63. package/src/index.ts +231 -0
  64. package/src/migrations/0001_purchases.ts +312 -0
  65. package/src/pricing/location.ts +175 -0
  66. package/src/pricing/visitor.ts +82 -0
  67. package/src/projection/event.ts +101 -0
  68. package/src/projection/orphans.ts +226 -0
  69. package/src/projection/owner.ts +247 -0
  70. package/src/projection/resolve.ts +57 -0
  71. package/src/projection/writer.ts +554 -0
  72. package/src/provision/resolvePaymentsConfig.ts +91 -0
  73. package/src/rails/apple/certs.ts +66 -0
  74. package/src/rails/apple/der.ts +249 -0
  75. package/src/rails/apple/fixtures/chain.ts +412 -0
  76. package/src/rails/apple/fixtures/did-fail-to-renew-grace.json +43 -0
  77. package/src/rails/apple/fixtures/did-renew.json +48 -0
  78. package/src/rails/apple/fixtures/one-time-charge.json +32 -0
  79. package/src/rails/apple/fixtures/refund.json +33 -0
  80. package/src/rails/apple/fixtures/subscribed-initial-buy-sandbox.json +38 -0
  81. package/src/rails/apple/fixtures/test.json +14 -0
  82. package/src/rails/apple/http.ts +128 -0
  83. package/src/rails/apple/jws.ts +123 -0
  84. package/src/rails/apple/notification.ts +357 -0
  85. package/src/rails/apple/rail.ts +114 -0
  86. package/src/rails/apple/serverApi.ts +412 -0
  87. package/src/rails/apple/verify.ts +93 -0
  88. package/src/rails/apple/x509.ts +424 -0
  89. package/src/rails/contract.ts +939 -0
  90. package/src/rails/google/fixtures/play-product-purchased.json +14 -0
  91. package/src/rails/google/fixtures/play-subscription-active.json +23 -0
  92. package/src/rails/google/fixtures/push.ts +152 -0
  93. package/src/rails/google/fixtures/rtdn-one-time-purchased.json +11 -0
  94. package/src/rails/google/fixtures/rtdn-subscription-renewed.json +11 -0
  95. package/src/rails/google/fixtures/rtdn-subscription-revoked.json +11 -0
  96. package/src/rails/google/fixtures/rtdn-test.json +8 -0
  97. package/src/rails/google/fixtures/rtdn-voided-purchase.json +11 -0
  98. package/src/rails/google/http.ts +124 -0
  99. package/src/rails/google/jwt.ts +127 -0
  100. package/src/rails/google/oidc.ts +308 -0
  101. package/src/rails/google/playApi.ts +598 -0
  102. package/src/rails/google/rail.ts +174 -0
  103. package/src/rails/google/rtdn.ts +331 -0
  104. package/src/rails/google/verify.ts +127 -0
  105. package/src/rails/lemonSqueezy/api.ts +192 -0
  106. package/src/rails/lemonSqueezy/checkout.ts +186 -0
  107. package/src/rails/lemonSqueezy/discounts.ts +166 -0
  108. package/src/rails/lemonSqueezy/fixtures/events.ts +180 -0
  109. package/src/rails/lemonSqueezy/objects.ts +511 -0
  110. package/src/rails/lemonSqueezy/portal.ts +82 -0
  111. package/src/rails/lemonSqueezy/pricing.ts +124 -0
  112. package/src/rails/lemonSqueezy/rail.ts +137 -0
  113. package/src/rails/lemonSqueezy/read.ts +72 -0
  114. package/src/rails/lemonSqueezy/refresh.ts +86 -0
  115. package/src/rails/lemonSqueezy/signature.ts +119 -0
  116. package/src/rails/lemonSqueezy/verify.ts +45 -0
  117. package/src/rails/lemonSqueezy/webhook.ts +326 -0
  118. package/src/rails/paddle/adjustments.ts +94 -0
  119. package/src/rails/paddle/api.ts +266 -0
  120. package/src/rails/paddle/checkout.ts +240 -0
  121. package/src/rails/paddle/discounts.ts +176 -0
  122. package/src/rails/paddle/events.ts +285 -0
  123. package/src/rails/paddle/fixtures/browserForged.ts +54 -0
  124. package/src/rails/paddle/fixtures/pausedSubscription.ts +62 -0
  125. package/src/rails/paddle/objects.ts +883 -0
  126. package/src/rails/paddle/portal.ts +130 -0
  127. package/src/rails/paddle/rail.ts +219 -0
  128. package/src/rails/paddle/read.ts +108 -0
  129. package/src/rails/paddle/recorded.ts +83 -0
  130. package/src/rails/paddle/refresh.ts +153 -0
  131. package/src/rails/paddle/refund.ts +297 -0
  132. package/src/rails/paddle/signature.ts +225 -0
  133. package/src/rails/paddle/subscription.ts +793 -0
  134. package/src/rails/paddle/verify.ts +120 -0
  135. package/src/rails/paddle/webhook.ts +405 -0
  136. package/src/rails/providers.ts +168 -0
  137. package/src/rails/stripe/api.ts +253 -0
  138. package/src/rails/stripe/checkout.ts +175 -0
  139. package/src/rails/stripe/discounts.ts +169 -0
  140. package/src/rails/stripe/fixtures/event-charge-refunded.json +23 -0
  141. package/src/rails/stripe/fixtures/event-invoice-paid.json +19 -0
  142. package/src/rails/stripe/fixtures/event-session-completed-payment.json +27 -0
  143. package/src/rails/stripe/fixtures/event-session-completed-subscription.json +27 -0
  144. package/src/rails/stripe/fixtures/event-subscription-canceled.json +43 -0
  145. package/src/rails/stripe/fixtures/event-subscription-created.json +42 -0
  146. package/src/rails/stripe/fixtures/event-subscription-deleted.json +44 -0
  147. package/src/rails/stripe/fixtures/events.ts +111 -0
  148. package/src/rails/stripe/objects.ts +642 -0
  149. package/src/rails/stripe/portal.ts +45 -0
  150. package/src/rails/stripe/pricing.ts +123 -0
  151. package/src/rails/stripe/rail.ts +139 -0
  152. package/src/rails/stripe/refresh.ts +79 -0
  153. package/src/rails/stripe/signature.ts +127 -0
  154. package/src/rails/stripe/verify.ts +105 -0
  155. package/src/rails/stripe/webhook.ts +79 -0
  156. package/src/secret/registry.ts +275 -0
  157. package/src/seeds/example.ts +181 -0
  158. package/src/version.generated.ts +16 -0
  159. package/src/workflows/paddleSweep.ts +717 -0
  160. package/src/workflows/railAccess.ts +87 -0
  161. package/src/workflows/reconcile.ts +648 -0
  162. package/src/workflows/report.ts +64 -0
  163. package/src/workflows/retryPolicy.ts +40 -0
  164. package/src/workflows/specs.ts +143 -0
  165. package/src/workflows/worker.ts +166 -0
  166. package/src/workflows/workerConfig.ts +73 -0
  167. 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
+ }