@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,124 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+ import type { SubscriptionPricing } from "../../data/discount";
6
+ import type { PaymentsPurchase } from "../../data/purchase";
7
+ import type { PaymentsLemonSqueezyCredentials } from "../../secret/registry";
8
+ import { type LemonSqueezyHttpFetch, lemonSqueezyHttpFetch, lemonSqueezyJson } from "./api";
9
+
10
+ /**
11
+ * What a Lemon Squeezy subscription pays now, what it becomes, and when.
12
+ *
13
+ * Read from the store rather than computed: the subscription object carries the discounted total for the
14
+ * next renewal, the list total, and the discount in force. Pithy multiplies nothing — a second calculation
15
+ * here would be a second answer to the one question a customer checks against their statement.
16
+ *
17
+ * Only a `subscription:` row can be asked. A money row records one closed period and has no "next", and an
18
+ * order has no renewal at all, so both answer `undefined` — the same "nothing to say about this purchase"
19
+ * the refresh path uses.
20
+ */
21
+
22
+ /** What reading pricing needs. */
23
+ export interface LemonSqueezyPricingOptions {
24
+ /** The rail's credentials. */
25
+ credentials: PaymentsLemonSqueezyCredentials;
26
+ /** The HTTP seam. Defaults to the runtime's `fetch`. */
27
+ transport?: LemonSqueezyHttpFetch;
28
+ }
29
+
30
+ /** A subscription, narrowed to what pricing needs. `.loose()` — the store adds fields. */
31
+ const PricedSubscription = z
32
+ .object({
33
+ data: z
34
+ .object({
35
+ attributes: z
36
+ .object({
37
+ first_subscription_item: z.object({ price_id: z.number().optional() }).loose().nullish(),
38
+ })
39
+ .loose(),
40
+ })
41
+ .loose(),
42
+ })
43
+ .loose();
44
+
45
+ /**
46
+ * The subscription's invoices — a **collection**, which is what `/v1/subscription-invoices` returns.
47
+ *
48
+ * `data` is an array. Parsing it as a single resource is the defect this replaced: `safeParse` failed
49
+ * silently, every amount fell back to null, and `GET /payments/pricing` reported nothing for every Lemon
50
+ * Squeezy subscriber while looking like it had worked.
51
+ */
52
+ const PricedInvoices = z
53
+ .object({
54
+ data: z.array(
55
+ z
56
+ .object({
57
+ attributes: z
58
+ .object({
59
+ currency: z.string().nullish(),
60
+ subtotal: z.number().nullish(),
61
+ total: z.number().nullish(),
62
+ discount_total: z.number().nullish(),
63
+ created_at: z.string().nullish(),
64
+ })
65
+ .loose(),
66
+ })
67
+ .loose(),
68
+ ),
69
+ })
70
+ .loose();
71
+
72
+ /** Read what this subscription pays, or `undefined` when the store has nothing to say. */
73
+ export async function readLemonSqueezyPricing(
74
+ purchase: PaymentsPurchase,
75
+ options: LemonSqueezyPricingOptions,
76
+ ): Promise<SubscriptionPricing | undefined> {
77
+ const key = purchase.providerTransactionId;
78
+ if (!key.startsWith("subscription:")) return undefined;
79
+ const id = key.slice("subscription:".length);
80
+
81
+ const transport = options.transport ?? lemonSqueezyHttpFetch;
82
+ const body = await lemonSqueezyJson(transport, `/subscriptions/${encodeURIComponent(id)}`, {
83
+ what: `subscription ${id} pricing`,
84
+ apiKey: options.credentials.apiKey,
85
+ absentOn404: true,
86
+ });
87
+ if (body === undefined) return undefined;
88
+
89
+ const subscription = PricedSubscription.safeParse(body);
90
+ if (!subscription.success) return undefined;
91
+
92
+ // The store's own figures, from its most recent invoice for this subscription. A filtered collection on
93
+ // `/v1/subscription-invoices` — there is no `/subscriptions/{id}/invoices` sub-resource, and asking for
94
+ // one 404s into a silently empty answer.
95
+ const invoices = await lemonSqueezyJson(transport, "/subscription-invoices", {
96
+ what: `subscription ${id} invoices`,
97
+ apiKey: options.credentials.apiKey,
98
+ query: { "filter[subscription_id]": id, "page[size]": "1", sort: "-created_at" },
99
+ absentOn404: true,
100
+ });
101
+ const priced = invoices === undefined ? undefined : PricedInvoices.safeParse(invoices);
102
+ const amounts = priced?.success === true ? priced.data.data[0]?.attributes : undefined;
103
+
104
+ return {
105
+ currency: amounts?.currency ?? null,
106
+ currentAmountMinor: amounts?.total ?? null,
107
+ // The list price is the subtotal before the discount came off. Where the store reports no discount the
108
+ // two coincide, which is exactly what `SubscriptionPricing` says an undiscounted subscription looks like.
109
+ listAmountMinor: amounts?.subtotal ?? amounts?.total ?? null,
110
+ discountCode: null,
111
+ // **Null, always, on this rail — and that is the honest answer rather than a gap.**
112
+ //
113
+ // `SubscriptionPricing.discountEndsAt` means "the date this rate stops". Lemon Squeezy expresses a
114
+ // repeating discount's remaining life in billing *periods* on the discount object, and does not publish
115
+ // the resulting date on the subscription. The previous code returned `renews_at` whenever the last
116
+ // invoice carried any discount at all, which reported the **next renewal** as the end date for a
117
+ // discount running for another eleven periods — telling a customer their bill changes next month when
118
+ // it does not, which is the same class of surprise this field exists to prevent, pointed the other way.
119
+ //
120
+ // Null is documented as "no discount, or one that runs forever". A screen reads it beside
121
+ // `discountCode`; both null on this rail means "we cannot say", which is true.
122
+ discountEndsAt: null,
123
+ };
124
+ }
@@ -0,0 +1,137 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { CreatedDiscount, DiscountTerms, SubscriptionPricing } from "../../data/discount";
5
+ import type { PaymentsPurchase } from "../../data/purchase";
6
+ import type { PaymentsLemonSqueezyCredentials } from "../../secret/registry";
7
+ import type {
8
+ CheckoutHandoff,
9
+ CheckoutRail,
10
+ CheckoutSessionInput,
11
+ DiscountRail,
12
+ ListedDiscount,
13
+ PaymentsRailProvider,
14
+ PortalHandoff,
15
+ PortalSessionInput,
16
+ PricingRail,
17
+ RailRequestContext,
18
+ UnboundProviderEvent,
19
+ VerifiedNotification,
20
+ VerifiedPurchase,
21
+ WebhookDelivery,
22
+ } from "../contract";
23
+ import type { LemonSqueezyHttpFetch } from "./api";
24
+ import { createLemonSqueezyCheckoutSession } from "./checkout";
25
+ import { createLemonSqueezyDiscount, listLemonSqueezyDiscounts } from "./discounts";
26
+ import { createLemonSqueezyPortalSession } from "./portal";
27
+ import { readLemonSqueezyPricing } from "./pricing";
28
+ import { refreshLemonSqueezyPurchase } from "./refresh";
29
+ import { verifyLemonSqueezyOrder } from "./verify";
30
+ import { parseLemonSqueezyNotification } from "./webhook";
31
+
32
+ /**
33
+ * The Lemon Squeezy rail as one provider object — the second to implement both halves of the contract.
34
+ *
35
+ * Like Stripe, this rail *initiates* purchases: `/checkout` creates a hosted checkout and sends the browser
36
+ * to it, so it implements {@link CheckoutRail} as well as {@link PaymentsRailProvider}. Unlike Stripe, it
37
+ * refuses `verify` outright — there is no client-submittable receipt here that could be trusted, and
38
+ * `verify.ts` explains at length why the obvious candidates are not one.
39
+ *
40
+ * Built per request from credentials the caller resolved through the secrets store, rather than reading
41
+ * them itself. That keeps the rail a pure function of its inputs and keeps the secret read at the point of
42
+ * need.
43
+ *
44
+ * **Hosted only, and merchant of record.** Lemon Squeezy owns the payment page, the sales tax, the VAT
45
+ * registration, the invoice, the dunning and the chargebacks. That is the entire reason an adopter reaches
46
+ * for this rail over Stripe, and nothing in this module is a small edit away from taking any of it back.
47
+ */
48
+
49
+ /** How the Lemon Squeezy rail's transport is varied. One seam, for the tests and for the reconciliation pass. */
50
+ export interface LemonSqueezyRailOptions {
51
+ /** The HTTP seam Lemon Squeezy's API is reached through. Defaults to the runtime's `fetch`. */
52
+ transport?: LemonSqueezyHttpFetch;
53
+ /**
54
+ * Whether a variant is sold as a subscription, answered from the adopter's catalog.
55
+ *
56
+ * Supplied by `resolveRailProvider`, which has the config. It decides whether an `order_created` is a
57
+ * one-off sale or the container a subscription arrived in — the second of which is not its own payment.
58
+ */
59
+ sellsSubscription?: (variantId: string) => boolean;
60
+ /**
61
+ * The currency this store prices in, when the project declared one.
62
+ *
63
+ * Only used to refuse a fixed discount in another currency before it reaches the store — see
64
+ * `discounts.ts` for why that refusal has to happen at creation rather than at redemption.
65
+ */
66
+ storeCurrency?: string;
67
+ }
68
+
69
+ /** The Lemon Squeezy rail. Parses webhooks, re-reads purchases, and creates hosted checkouts and portal links. */
70
+ export function lemonSqueezyRail(
71
+ credentials: PaymentsLemonSqueezyCredentials,
72
+ options: LemonSqueezyRailOptions = {},
73
+ ): PaymentsRailProvider & CheckoutRail & DiscountRail & PricingRail {
74
+ return {
75
+ rail: "lemonSqueezy",
76
+
77
+ async verify(receipt: string): Promise<VerifiedPurchase> {
78
+ // `return await`, not `return`. Returning a promise from an async function makes this frame *adopt*
79
+ // the rejection rather than raising it, and workerd then reports the adopted promise as an unhandled
80
+ // rejection even though Hono's `onError` answers the request correctly.
81
+ return await verifyLemonSqueezyOrder(receipt);
82
+ },
83
+
84
+ async parseNotification(delivery: WebhookDelivery, context: RailRequestContext): Promise<VerifiedNotification> {
85
+ return await parseLemonSqueezyNotification(delivery, {
86
+ credentials,
87
+ deployment: context.deployment,
88
+ transport: options.transport,
89
+ sellsSubscription: options.sellsSubscription,
90
+ });
91
+ },
92
+
93
+ async refresh(purchase: PaymentsPurchase, context: RailRequestContext): Promise<UnboundProviderEvent | undefined> {
94
+ // `return await`, not `return` — see `verify` above for why the frame must raise rather than adopt.
95
+ return await refreshLemonSqueezyPurchase(purchase, {
96
+ credentials,
97
+ now: context.now,
98
+ transport: options.transport,
99
+ });
100
+ },
101
+
102
+ async createCheckoutSession(input: CheckoutSessionInput, context: RailRequestContext): Promise<CheckoutHandoff> {
103
+ return await createLemonSqueezyCheckoutSession(input, {
104
+ credentials,
105
+ deployment: context.deployment,
106
+ transport: options.transport,
107
+ });
108
+ },
109
+
110
+ async createPortalSession(input: PortalSessionInput): Promise<PortalHandoff> {
111
+ return await createLemonSqueezyPortalSession(input, { credentials, transport: options.transport });
112
+ },
113
+
114
+ async readPricing(
115
+ purchase: PaymentsPurchase,
116
+ _context: RailRequestContext,
117
+ ): Promise<SubscriptionPricing | undefined> {
118
+ return await readLemonSqueezyPricing(purchase, { credentials, transport: options.transport });
119
+ },
120
+
121
+ async listDiscounts(): Promise<readonly ListedDiscount[]> {
122
+ return await listLemonSqueezyDiscounts({
123
+ credentials,
124
+ storeCurrency: options.storeCurrency,
125
+ transport: options.transport,
126
+ });
127
+ },
128
+
129
+ async createDiscount(terms: DiscountTerms): Promise<CreatedDiscount> {
130
+ return await createLemonSqueezyDiscount(terms, {
131
+ credentials,
132
+ storeCurrency: options.storeCurrency,
133
+ transport: options.transport,
134
+ });
135
+ },
136
+ };
137
+ }
@@ -0,0 +1,72 @@
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 LemonSqueezyHttpFetch, lemonSqueezyJson } from "./api";
8
+ import { LemonSqueezyOrder, LemonSqueezySubscription } from "./objects";
9
+
10
+ /**
11
+ * The reads this rail makes of Lemon Squeezy: a subscription, and an order.
12
+ *
13
+ * Both exist for two callers with different needs, which is why they return `undefined` rather than throwing
14
+ * on an absent object. The webhook parser reads a subscription to learn which variant an invoice bills; the
15
+ * reconciliation pass reads one to find out what a purchase looks like now. For the first, an absent
16
+ * subscription is a delivery that cannot be projected; for the second it is the contract's documented
17
+ * "the store no longer knows this purchase". Neither is a failure of the rail.
18
+ *
19
+ * A store that cannot be *reached* throws `payments/provider_unavailable`, which is what tells the
20
+ * reconciliation Workflow to fail the step and retry, and what makes the webhook guard answer non-2xx so
21
+ * Lemon Squeezy redelivers.
22
+ */
23
+
24
+ /** What a read needs: the credentials, and the transport to make it through. */
25
+ export interface LemonSqueezyReadOptions {
26
+ /** The rail's credentials. The API key is read at the point of need and never cached. */
27
+ credentials: PaymentsLemonSqueezyCredentials;
28
+ /** The HTTP seam. */
29
+ transport: LemonSqueezyHttpFetch;
30
+ }
31
+
32
+ /** A JSON:API single-resource answer, narrowed to the attributes the caller will parse. */
33
+ const Envelope = z.object({
34
+ data: z.object({ id: z.string(), attributes: z.record(z.string(), z.unknown()) }).loose(),
35
+ });
36
+
37
+ /** Read one object, or `undefined` when the store has no such thing. */
38
+ async function readObject(
39
+ path: string,
40
+ what: string,
41
+ options: LemonSqueezyReadOptions,
42
+ ): Promise<{ id: string; attributes: Record<string, unknown> } | undefined> {
43
+ const body = await lemonSqueezyJson(options.transport, path, {
44
+ what,
45
+ apiKey: options.credentials.apiKey,
46
+ absentOn404: true,
47
+ });
48
+ if (body === undefined) return undefined;
49
+
50
+ const envelope = Envelope.safeParse(body);
51
+ if (!envelope.success) {
52
+ throw new PaymentsProviderUnavailableError({
53
+ detail: `Lemon Squeezy answered for ${what} with a body that is not a JSON:API resource.`,
54
+ });
55
+ }
56
+ return { id: envelope.data.data.id, attributes: envelope.data.data.attributes };
57
+ }
58
+
59
+ /** The subscription with this id, or `undefined` when Lemon Squeezy has none. */
60
+ export async function readSubscription(
61
+ id: string,
62
+ options: LemonSqueezyReadOptions,
63
+ ): Promise<LemonSqueezySubscription | undefined> {
64
+ const object = await readObject(`/subscriptions/${encodeURIComponent(id)}`, `subscription ${id}`, options);
65
+ return object === undefined ? undefined : LemonSqueezySubscription.parse(object.attributes);
66
+ }
67
+
68
+ /** The order with this id, or `undefined` when Lemon Squeezy has none. */
69
+ export async function readOrder(id: string, options: LemonSqueezyReadOptions): Promise<LemonSqueezyOrder | undefined> {
70
+ const object = await readObject(`/orders/${encodeURIComponent(id)}`, `order ${id}`, options);
71
+ return object === undefined ? undefined : LemonSqueezyOrder.parse(object.attributes);
72
+ }
@@ -0,0 +1,86 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { PaymentsPurchase } from "../../data/purchase";
5
+ import type { PaymentsLemonSqueezyCredentials } from "../../secret/registry";
6
+ import type { UnboundProviderEvent } from "../contract";
7
+ import type { LemonSqueezyHttpFetch } from "./api";
8
+ import { lemonSqueezyHttpFetch } from "./api";
9
+ import { orderEvent, subscriptionEvent } from "./objects";
10
+ import { readOrder, readSubscription } from "./read";
11
+
12
+ /**
13
+ * Re-read a Lemon Squeezy purchase — the reconciliation path.
14
+ *
15
+ * Webhooks are dropped, endpoints are misconfigured, and a subscription can lapse with no notification
16
+ * arriving at all. This is the question the reconciliation Workflow exists to ask, and this rail answers it
17
+ * for real.
18
+ *
19
+ * ## Which rows it can answer for
20
+ *
21
+ * **State rows.** `providerTransactionId` is `subscription:<id>`, which is exactly the object Lemon Squeezy
22
+ * can be asked about, and the answer is the subscription's standing now — the freshest fact anyone holds.
23
+ *
24
+ * **Order rows.** `order:<id>`, read back the same way.
25
+ *
26
+ * **Money rows are deliberately not refreshed.** A `subscription_invoice:` row records one closed billing
27
+ * period, and it is born `expired` — which is in the Workflow's terminal set, so the pass never selects one.
28
+ * Re-reading a closed invoice could only restate what it already says. Should one somehow reach here it
29
+ * answers `undefined`, which the contract defines as "the store has nothing to say about this purchase" and
30
+ * which leaves the row exactly as it stands.
31
+ *
32
+ * ## The clock is ours
33
+ *
34
+ * `providerEventAt` is `context.now`, not the object's `updated_at`, as the contract requires. A refresh is
35
+ * a read of the state *now*, so it is the freshest fact there is — dating it earlier would let the monotonic
36
+ * write rule discard the very repair this pass exists to make.
37
+ */
38
+
39
+ /** What a refresh needs: the credentials, the clock, and the transport. */
40
+ export interface RefreshLemonSqueezyOptions {
41
+ /** The rail's credentials. */
42
+ credentials: PaymentsLemonSqueezyCredentials;
43
+ /** The clock. Stamped onto the returned event, per the contract. */
44
+ now: Date;
45
+ /** The HTTP seam. Defaults to the runtime's `fetch`. */
46
+ transport?: LemonSqueezyHttpFetch;
47
+ }
48
+
49
+ /** Split a namespaced id back into its type and its Lemon Squeezy id. */
50
+ function split(providerTransactionId: string): { type: string; id: string } | undefined {
51
+ const at = providerTransactionId.indexOf(":");
52
+ if (at <= 0) return undefined;
53
+ return { type: providerTransactionId.slice(0, at), id: providerTransactionId.slice(at + 1) };
54
+ }
55
+
56
+ /** Re-read one purchase, or `undefined` when Lemon Squeezy has nothing to say about it. */
57
+ export async function refreshLemonSqueezyPurchase(
58
+ purchase: PaymentsPurchase,
59
+ options: RefreshLemonSqueezyOptions,
60
+ ): Promise<UnboundProviderEvent | undefined> {
61
+ // **The row's own key, never the family's.** Other rails fall back to `originalTransactionId` because
62
+ // their store can only be asked about the family — Apple's `originalTransactionId`, Play's purchase token.
63
+ // Here the row's own key already names an addressable object, and falling back would be actively wrong: a
64
+ // money row's family key is `subscription:<id>`, so it would re-read the *subscription* and answer with a
65
+ // state event keyed to a different row. Reconciliation would then read that as the row having been
66
+ // superseded, mark the invoice expired, and project nothing in its place.
67
+ const key = split(purchase.providerTransactionId);
68
+ if (key === undefined) return undefined;
69
+
70
+ const read = { credentials: options.credentials, transport: options.transport ?? lemonSqueezyHttpFetch };
71
+
72
+ if (key.type === "subscription") {
73
+ const subscription = await readSubscription(key.id, read);
74
+ if (subscription === undefined) return undefined;
75
+ return { ...subscriptionEvent(key.id, subscription), providerEventAt: options.now };
76
+ }
77
+
78
+ if (key.type === "order") {
79
+ const order = await readOrder(key.id, read);
80
+ if (order === undefined) return undefined;
81
+ return { ...orderEvent(key.id, order), providerEventAt: options.now };
82
+ }
83
+
84
+ // A `subscription_invoice:` row, or a prefix a later build wrote. Nothing to re-read — see the module doc.
85
+ return undefined;
86
+ }
@@ -0,0 +1,119 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { PaymentsVerificationFailedError } from "../../error/errors";
5
+
6
+ /**
7
+ * Lemon Squeezy's authenticity proof: a bare HMAC over the exact received bytes, in one header.
8
+ *
9
+ * `X-Signature: 5257a8…` — one hex HMAC-SHA256 of the body, keyed with the webhook's signing secret. No
10
+ * timestamp, no scheme prefix, no list.
11
+ *
12
+ * ## Why this does not go through core's `signed-webhook` primitive
13
+ *
14
+ * That primitive implements Stripe's format — `t=…,v1=…` — and every security property it enforces is a
15
+ * property *of that format*: the freshness window in both directions, the timestamp being inside the signed
16
+ * payload, a cap on how many signatures a header may list. Lemon Squeezy's scheme has none of them to
17
+ * enforce. Generalizing the primitive to carry a scheme would mean giving it a variant whose `now` and
18
+ * `toleranceSeconds` are accepted and ignored, and a parameter a caller can set that changes nothing is
19
+ * worse than no parameter: somebody eventually reads the call site and believes the window is there.
20
+ *
21
+ * So the absence is expressed in the type. {@link verifyLemonSqueezySignature} takes no options bag and no
22
+ * clock — there is nowhere to pass a tolerance, so no call site can claim a window this rail does not
23
+ * honor, and the refusal union below has two members where Stripe's has three. There is no `stale`.
24
+ *
25
+ * ## Where replay protection actually lives
26
+ *
27
+ * Entirely in the webhook guard's `UNIQUE (rail, providerEventId)` insert. A captured delivery replayed a
28
+ * year later carries a signature that is still valid — that is a true fact about this scheme, not a defect
29
+ * in this module — and is refused because its event id is already in `pithy_payments_webhook_events`. The
30
+ * projection's monotonic `providerEventAt` rule is the second line: a replayed state claim about a
31
+ * subscription that has since moved on is staler than the row and is ignored.
32
+ *
33
+ * Worth stating plainly because it is the one property an operator might assume from the other rails and
34
+ * not have here.
35
+ *
36
+ * ## What a refusal says
37
+ *
38
+ * `payments/verification_failed`, with the reason in `detail` and nothing the delivery carried — not the
39
+ * body, not the candidate signature, never the secret. `detail` reaches an operator's log, and the webhook
40
+ * guard maps this to `payments/webhook_unverified` (401) before anything reaches the sender, so a forger
41
+ * learns only that it failed. That code is this rail's contract with its guard.
42
+ */
43
+
44
+ /** The header Lemon Squeezy puts its proof in. Lower case, because that is how Hono presents a header name. */
45
+ export const LEMON_SQUEEZY_SIGNATURE_HEADER = "x-signature";
46
+
47
+ /** How many bytes an HMAC-SHA256 is. A candidate of any other length cannot be one, whatever it decodes to. */
48
+ const HMAC_SHA256_BYTES = 32;
49
+
50
+ /** Why a delivery was refused. Two members, not Stripe's three — this scheme carries no timestamp to be stale. */
51
+ type LemonSqueezyRefusal = "unreadable" | "unmatched";
52
+
53
+ /** Decode lower- or upper-case hex, or `undefined` when it is not hex at all. */
54
+ function hexBytes(value: string): Uint8Array | undefined {
55
+ if (value.length === 0 || value.length % 2 !== 0 || !/^[0-9a-fA-F]+$/.test(value)) return undefined;
56
+ const bytes = new Uint8Array(value.length / 2);
57
+ for (let index = 0; index < bytes.length; index += 1) {
58
+ bytes[index] = Number.parseInt(value.slice(index * 2, index * 2 + 2), 16);
59
+ }
60
+ return bytes;
61
+ }
62
+
63
+ /** Import a signing secret for HMAC-SHA256. */
64
+ async function signingKey(secret: string, usage: "sign" | "verify"): Promise<CryptoKey> {
65
+ return await crypto.subtle.importKey(
66
+ "raw",
67
+ new TextEncoder().encode(secret),
68
+ { name: "HMAC", hash: "SHA-256" },
69
+ false,
70
+ [usage],
71
+ );
72
+ }
73
+
74
+ /**
75
+ * The signature Lemon Squeezy would send for these bytes — lower-case hex.
76
+ *
77
+ * Exported for the tests and the fixtures, which mint their own so verification is exercised for real
78
+ * rather than stubbed. Nothing in the request path signs anything: this rail only ever verifies.
79
+ */
80
+ export async function signLemonSqueezyBody(body: string, secret: string): Promise<string> {
81
+ const mac = await crypto.subtle.sign("HMAC", await signingKey(secret, "sign"), new TextEncoder().encode(body));
82
+ return [...new Uint8Array(mac)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
83
+ }
84
+
85
+ /**
86
+ * Verify one delivery's `X-Signature`. Resolves when Lemon Squeezy signed these exact bytes, and throws
87
+ * `payments/verification_failed` otherwise.
88
+ *
89
+ * The comparison is `crypto.subtle.verify`, never `===` on the hex and never a hand-written loop: the
90
+ * runtime's own compare is constant-time, and a string equality on a MAC leaks how many leading bytes an
91
+ * attacker got right.
92
+ */
93
+ export async function verifyLemonSqueezySignature(body: string, header: string | null, secret: string): Promise<void> {
94
+ const candidate = header === null ? undefined : hexBytes(header.trim());
95
+ if (candidate === undefined || candidate.length !== HMAC_SHA256_BYTES) {
96
+ throw new PaymentsVerificationFailedError({ detail: refusal("unreadable") });
97
+ }
98
+
99
+ const matched = await crypto.subtle.verify(
100
+ "HMAC",
101
+ await signingKey(secret, "verify"),
102
+ candidate,
103
+ new TextEncoder().encode(body),
104
+ );
105
+ if (!matched) throw new PaymentsVerificationFailedError({ detail: refusal("unmatched") });
106
+ }
107
+
108
+ /**
109
+ * This rail's wording for its two refusals.
110
+ *
111
+ * Prefixed `Lemon Squeezy:` because a payments Worker composes several rails and `detail` is what an
112
+ * operator reads to find out which one refused. The strings say what failed and what to check, and nothing
113
+ * the delivery carried.
114
+ */
115
+ function refusal(reason: LemonSqueezyRefusal): string {
116
+ return reason === "unreadable"
117
+ ? "Lemon Squeezy: the delivery carries no X-Signature header holding 32 bytes of hex."
118
+ : "Lemon Squeezy: the X-Signature does not match these bytes under the configured signing secret. Check that the secret belongs to this webhook and this store.";
119
+ }
@@ -0,0 +1,45 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { PaymentsInvalidReceiptError } from "../../error/errors";
5
+ import type { VerifiedPurchase } from "../contract";
6
+
7
+ /**
8
+ * This rail has no client-submittable receipt, and says so.
9
+ *
10
+ * ## Why refusing is the secure answer
11
+ *
12
+ * `verify` exists so a buyer sees their entitlement without waiting for the webhook. Stripe can do that
13
+ * because a returning buyer carries a Checkout Session id that Stripe substituted into the success URL: an
14
+ * unguessable token this deployment's own server can trade for a session carrying `client_reference_id`.
15
+ *
16
+ * Lemon Squeezy has no equivalent. Its order ids are **sequential integers**, so a `verify` that trusted a
17
+ * submitted order id would let any authenticated caller claim any order in the store by counting — including
18
+ * one belonging to somebody who has not signed in yet, whose purchase would then be bound to the attacker
19
+ * forever, because `linkProviderAccount` never rebinds and the first pairing wins.
20
+ *
21
+ * The order's UUID `identifier` was the obvious repair and is not one. It is unguessable, but it is not a
22
+ * *credential*: it appears in the buyer's own receipt email and in the storefront's return URL, so it proves
23
+ * possession of a value that was never secret, and Lemon Squeezy does not return `custom_data` on the order
24
+ * object in a form this server could check against the caller. Binding on an unguessable-but-unauthenticated
25
+ * identifier is exactly the class of mistake the {@link VerifiedPurchase.accountReference} doc warns about:
26
+ * it lets a client's choice of value decide who a purchase belongs to.
27
+ *
28
+ * ## What it costs, stated plainly
29
+ *
30
+ * A Lemon Squeezy buyer's entitlement appears when the webhook lands rather than the moment they return —
31
+ * seconds, usually. The `successUrl` this rail's config asks for should therefore show a pending state and
32
+ * poll, not post a receipt. `docs/lemon-squeezy.md` says so, and the scaffolded return page does it.
33
+ *
34
+ * The webhook is authoritative on every rail anyway. Here it is simply the only path.
35
+ */
36
+
37
+ /** Refuse a submitted Lemon Squeezy receipt. There is no shape of one that could be trusted. */
38
+ export async function verifyLemonSqueezyOrder(_receipt: string): Promise<VerifiedPurchase> {
39
+ throw new PaymentsInvalidReceiptError({
40
+ message: "Lemon Squeezy purchases confirm themselves.",
41
+ action: "Nothing to submit. Your purchase appears as soon as Lemon Squeezy tells the server about it.",
42
+ detail:
43
+ "Lemon Squeezy has no client-submittable receipt: an order id is a sequential integer, and the order object carries no reference this server set, so accepting one would let any authenticated caller claim an unprojected order by counting. Purchases on this rail land through the webhook alone.",
44
+ });
45
+ }