@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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pithy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,157 @@
1
+ # @pithy-sh/payments
2
+
3
+ Three payment rails — Apple, Google, Stripe — resolving to one cross-rail entitlement, in your own Worker and your own D1.
4
+
5
+ Buy Pro on iOS, be entitled on the web. That resolution is the whole product, and it is the same one **[RevenueCat](https://www.revenuecat.com)** built a very good business on — their SDKs, their store-quirk coverage and their dashboards are genuinely ahead of this package. The honest difference is not the feature list. It is **where the purchase history lives**: RevenueCat is a hosted data plane holding your customers' transactions, and this is five tables in a D1 you own, written by a Worker you deploy.
6
+
7
+ ```sh
8
+ pithy add payments
9
+ ```
10
+
11
+ **Documentation: [pithy.sh/docs/capabilities/payments](https://pithy.sh/docs/capabilities/payments).** Overview, adding it, using it, and the reference: product versus entitlement, entitlement resolution, webhooks and replay. Selling on each rail is a guide apiece under [pithy.sh/docs/build/money](https://pithy.sh/docs/build/money/sell-on-the-app-store).
12
+
13
+ _Everything else is on the site. `pithy.sh/docs` is canonical — new prose goes there, not here._
14
+
15
+ ## Routes
16
+
17
+ | Route | Purpose | Verification |
18
+ | --- | --- | --- |
19
+ | `POST /payments/purchases` | Submit a receipt or signed transaction for verification | bearer · session |
20
+ | `GET /payments/entitlements` | The caller's own resolved entitlements | bearer · session |
21
+ | `POST /payments/restore` | Restore Purchases — rebind store history to the caller | bearer · session |
22
+ | `GET /payments/pricing` | What the caller's own subscription pays, and when that changes | bearer · session |
23
+ | `POST /payments/checkout` | Create a checkout, on Stripe, Lemon Squeezy or Paddle | bearer · session |
24
+ | `POST /payments/portal` | Create a billing-portal session for the caller's own account | bearer · session |
25
+ | `GET /payments/subscription` | Where the caller's own subscription stands, read live from the store | bearer · session |
26
+ | `POST /payments/subscription/preview` | What moving to one catalog product would cost, before anything is committed | bearer · session |
27
+ | `POST /payments/subscription/change` | Move the caller's own subscription onto one catalog product | bearer · session |
28
+ | `POST /payments/subscription/cancel` | Stop it renewing — today, or at the end of the paid period | bearer · session |
29
+ | `POST /payments/subscription/keep` | Withdraw a scheduled cancellation | bearer · session |
30
+ | `POST /payments/subscription/refund` | Ask the store to refund the payments made on it | bearer · session |
31
+ | `POST /payments/webhooks/apple` | App Store Server Notifications V2 | signed-webhook |
32
+ | `POST /payments/webhooks/google` | Play Real-time Developer Notifications, via Pub/Sub push | signed-webhook |
33
+ | `POST /payments/webhooks/stripe` | Stripe events | signed-webhook |
34
+ | `POST /payments/webhooks/lemon-squeezy` | Lemon Squeezy events | signed-webhook |
35
+ | `POST /payments/webhooks/paddle` | Paddle events | signed-webhook |
36
+ | `POST /payments/admin/discounts` | Mint a discount code at one store | control-plane: `payments:discounts:create` |
37
+ | `GET /payments/admin/discounts` | The discount codes this project has issued | control-plane: `payments:discounts:read` |
38
+ | `POST /payments/entitlements/grant` | Comp or repair an entitlement | control-plane: `payments:entitlements:grant` |
39
+ | `POST /payments/entitlements/revoke` | Take one back | control-plane: `payments:entitlements:revoke` |
40
+ | `GET /payments/admin/catalog` | What this project sells, and the keys it comps by hand | control-plane: `payments:catalog:read` |
41
+ | `GET /payments/admin/purchases` | The purchase log, paged | control-plane: `payments:purchases:read` |
42
+ | `GET /payments/admin/subscriptions` | The purchases that renew | control-plane: `payments:subscriptions:read` |
43
+ | `GET /payments/admin/entitlements` | The entitlement model, paged | control-plane: `payments:entitlements:read` |
44
+ | `GET /payments/admin/entitlements/:subjectType/:subjectId` | One subject's entitlements | control-plane: `payments:entitlements:read` |
45
+ | `GET /payments/admin/reconcile-runs` | The reconciliation passes this deployment has run | control-plane: `payments:reconcile:read` |
46
+ | `POST /payments/admin/reconcile-runs` | Start a pass now, rather than waiting for the nightly one | control-plane: `payments:reconcile:run` |
47
+
48
+ **Every route this capability registers is in that table, and a test holds it there.** The management reads shipped without rows for long enough that the next person to add one withheld theirs too — a table missing four peers reads as complete, so one more row would have read as a lie. `routeContract.test.ts` now parses this table and compares it against the real registrations in both directions, which makes the omission a failing build rather than a judgment call.
49
+
50
+ That gate checked the method and the path and nothing else, so a response could change shape under a row that still read as correct — which is how `quotedFrom` shipped undescribed. It now also holds `GET /payments/pricing`'s response envelope against the section below, field by field.
51
+
52
+ ### What `GET /payments/pricing` answers
53
+
54
+ Two independent facts about one caller, siblings rather than one nested in the other, and each is null on its own terms.
55
+
56
+ ```json
57
+ {
58
+ "pricing": {
59
+ "currency": "USD",
60
+ "currentAmountMinor": 500,
61
+ "listAmountMinor": 1000,
62
+ "discountCode": "LAUNCH50",
63
+ "discountEndsAt": "2026-09-01T00:00:00.000Z"
64
+ },
65
+ "quotedFrom": { "rail": "paddle", "providerAccountId": "ctm_01hv8wptq8987qeep44cyrewp9" }
66
+ }
67
+ ```
68
+
69
+ `pricing` is null when no rail can price a subscription this caller holds — including when they hold none. When it is there it carries five fields, all nullable, and none of them is computed here: every amount is the store's own figure, because nothing in this package multiplies a price by a percentage.
70
+
71
+ | Field | What it is |
72
+ | --- | --- |
73
+ | `currency` | The currency both amounts are in |
74
+ | `currentAmountMinor` | What the next invoice comes to under any discount |
75
+ | `listAmountMinor` | What it comes to once the discount ends |
76
+ | `discountCode` | The code in force, or null at list price |
77
+ | `discountEndsAt` | When the rate changes, ISO-8601, or null — which is either no discount or one that runs forever. Read it beside `discountCode` to tell which |
78
+
79
+ **`quotedFrom` names the store customer a quote and a charge must both resolve from.** It is the caller's own row in the provider-account map, which is the same row `POST /payments/checkout` hands the rail as `customer_id` — so the figure quoted and the figure charged cannot resolve location differently. `ctm_…` is an identifier, not a credential: it names a Paddle customer and authorizes nothing, and Paddle's `PricePreview` reads a price with it and the publishable client token, which is the pair Paddle publishes for exactly this. The route is `requireAuth()` and answers only about its own caller, so nobody learns anybody else's.
80
+
81
+ A browser reads three states off it, and they are not the same answer:
82
+
83
+ | What arrives | What it means | What to quote from |
84
+ | --- | --- | --- |
85
+ | the field is absent | the Worker is older than the bundle asking it | the IP, labeled an estimate |
86
+ | `null` | no store holds a customer for this caller yet — the ordinary state of somebody who has not bought anything | a billing address you hold, else the IP |
87
+ | `{ "rail": "paddle", "providerAccountId": "ctm_…" }` | the store customer this caller is charged as | that customer |
88
+
89
+ `rail` is `paddle` today and is on the wire rather than assumed, because Paddle is the rail that quotes in a browser and a reader that skipped the check would price a Stripe customer id as a Paddle one. `providerAccountId` is that store's own customer id, and it is the same value this caller's checkout is charged against. `readPaddleCustomer` in `src/pricing/visitor.ts` refuses all three of the other shapes, and refuses a hostile one the same way.
90
+
91
+ **An address supersedes an IP, and which one you got is a fact the screen has to state.** A customer is charged from their billing address — Paddle settles tax on the transaction's address, not on where the browser happened to connect from. `resolvePriceLocation` in `src/pricing/location.ts` is the one resolver, and its precedence is authority rather than convenience:
92
+
93
+ | `source` | Where it came from | Is it the charge? |
94
+ | --- | --- | --- |
95
+ | `customer` | `quotedFrom.providerAccountId`. Paddle prices from the address it holds, which is the address the checkout charges | Yes. The only one that is not a guess |
96
+ | `address` | a billing address you hold and Paddle does not | Closer than the network, still not proof — the buyer may enter another at the card form |
97
+ | `ip` | nobody said. Paddle resolves the country from the browser's own connection | No. Right for a marketing page a stranger is reading, and an estimate every time |
98
+
99
+ `location.provisional` is true exactly when the source is `ip`, and `quoteIsEstimated(location, taxUnresolved)` is what a screen labels from — two independent reasons, either one enough. So a signed-in visitor's price refines: the first render quotes from the IP and says `Estimated.`, and the second supersedes it from the customer `quotedFrom` named. That is the recalculation every checkout on the web performs, and the only thing that would make it a broken promise is a first figure that did not admit what it was. In the United States the gap between the two reaches 15%.
100
+
101
+ **`currentAmountMinor` and `listAmountMinor` are not divisible by 100.** They are the store's own integers in the currency's smallest unit, and how many decimal places that unit has is a property of the currency rather than a constant: `500` is $5.00 in USD and ¥500 in JPY, and Korean won and Chilean pesos are the same story. **Read them beside `currency` or not at all.** Where the store hands you a rendered total — `PricePreview`'s `formattedTotals` and `formattedUnitTotals` — pass it through byte for byte; it already carries the currency's decimals, symbol and separators. Where you must work from the integers, take the scale from the currency (`Intl.NumberFormat(locale, { style: "currency", currency }).resolvedOptions().minimumFractionDigits`) and never from a literal. A `/ 100` in a consumer is wrong in every zero-decimal market it reaches, and wrong silently.
102
+
103
+ **This section stays here.** `src/http/routeContract.test.ts` parses that table and compares it against the real registrations in both directions, holds every control-plane scope the guards demand to it, and holds `GET /payments/pricing`'s response envelope to the field table above — field by field. It is specification, not a summary, and a copy of it on the site would be the second list that drifts.
104
+
105
+ ### What `GET /payments/subscription` answers
106
+
107
+ The read that ships before the four verbs beside it. A capability that can cancel a subscription and cannot report the cancellation has shipped the half that creates the support ticket, which is #247 in miniature.
108
+
109
+ ```json
110
+ {
111
+ "subscription": {
112
+ "productId": "team_monthly",
113
+ "status": "active",
114
+ "currency": "usd",
115
+ "currentPeriodEndsAt": "2026-09-15T11:42:21.789Z",
116
+ "nextBilledAt": null,
117
+ "scheduledChange": { "action": "cancel", "effectiveAt": "2026-09-15T11:42:21.789Z", "resumesAt": null },
118
+ "nextEvent": { "kind": "ends", "at": "2026-09-15T11:42:21.789Z" }
119
+ }
120
+ }
121
+ ```
122
+
123
+ `subscription` is null when the caller holds none, and when the store has nothing to say about the row it holds. That is a fact rather than a failure, and it is deliberately not a 404: a 404 would make this route an existence oracle and would read, to a screen, exactly like a Worker that could not be reached.
124
+
125
+ **A holder with more than one live subscription gets `payments/subscription_change_refused` (409), on all five routes.** Null would say they hold none and picking one would render a plan beside a button that ends a different one, so the server says there are two and stops. Send them to the billing portal.
126
+
127
+ | Field | What it is |
128
+ | --- | --- |
129
+ | `productId` | The catalog product this subscription is for — the key in `products`. Look the display name up from it; a name copied onto every response goes stale the day it is changed |
130
+ | `status` | The normalized status, never a store's own. **It does not say whether the subscription is ending** |
131
+ | `currency` | What it bills in, lowercase, or null when the store did not state one. Here to format the price `GET /payments/pricing` carries, not to carry a price |
132
+ | `currentPeriodEndsAt` | When the period already paid for runs out, ISO-8601. Null while trialing or paused, which are the states with no billing period |
133
+ | `nextBilledAt` | When the next charge falls due, or null when none is going to. **Null is neither canceled nor broken** |
134
+ | `scheduledChange` | The change waiting to land, or null. `action` is `cancel`, `pause` or `resume`; `effectiveAt` is when it happens; `resumesAt` is when a paused subscription comes back, or null |
135
+ | `nextEvent` | What happens next and when, already resolved — `{ "kind": "renews" \| "ends" \| "pauses" \| "resumes", "at": "…" }`, or `{ "kind": "unknown", "at": null }` |
136
+
137
+ **Render `nextEvent`, not `status`.** The subscription above is one somebody canceled. Paddle reports it as `active`, with no cancellation date and a blank next billing date (recorded against the sandbox, 2026-08-28): two of the three say the subscription is fine and the third says nothing at all. The day it ends exists only on `scheduledChange.effectiveAt`. A screen that reads the status tells a customer who canceled that they will be billed again, and `nextEvent` is that precedence resolved on the server so no client has to rediscover it.
138
+
139
+ **The three writes answer `{ "subscription": … }` with the same object, never null** — each resolved a subscription before it ran — and `POST /payments/subscription/preview` answers `{ "quote": … }` instead: what settles today, what settles on the next invoice, and what the subscription pays afterwards. Three separate facts, because a deferred downgrade has three: the recorded one settles *nothing* today and still owes the customer 65.58, on an invoice a month out. A quote is rendered, confirmed and discarded — never stored.
140
+
141
+ ### What `POST /payments/subscription/refund` does, and does not
142
+
143
+ It **asks**. It does not refund. Paddle holds most live refunds at `pending_approval` until a person there reviews them, so the route answers `{ "refund": { "outcomes": [ … ] } }` — one entry per payment, each `requested`, `already_requested` or `failed`, with a `status` on the first two that is `awaiting_review`, `approved`, `rejected`, `reversed` or `unknown`. **None of those means the money has arrived**, `approved` included: that is a decision, not a settlement, and the settlement reaches you as a webhook.
144
+
145
+ Nothing is revoked here. Not the entitlement, not the purchase row, not a projection — the subscriber keeps what they paid for until the store approves the refund and the webhook says so, because a refund the store then rejects would otherwise have left them with neither the money nor the product.
146
+
147
+ **Refunds attach to a payment, not to a plan**, so this acts on a set. A customer who joined on one plan, upgraded mid-period and canceled has paid twice, and a policy that owes them their money owes both. The report is total over that set: one entry per payment, always, so a caller counting entries and a caller counting their own payments get the same number. Everything knowable in advance refuses the whole request and raises nothing; once one refund exists, every remaining failure is an entry rather than an error, because an error over a state where money is already moving is a partial told backwards.
148
+
149
+ **The 14-day window is not here, and no window is.** How long a customer has to ask is your commercial policy, with your company behind it. The kit makes the refund possible and hard-codes no number; your screen decides which button exists and when.
150
+
151
+ The body is empty and there is no amount anywhere: every refund raised is for a payment's whole total. A partial refund needs the store's own line-item ids, which this capability does not hold, and an amount on a bearer route is a self-service withdrawal.
152
+
153
+ **None of the six accepts a subscription, a price, or a rail.** `change` and `preview` take one field, `productId`, and the route resolves the store's own price from the catalog; `cancel` takes `timing`, which is `now` or `at_period_end` in the customer's own terms rather than the store's; `keep` and `refund` take no body at all. A body naming a price would move a customer onto a plan the project does not sell, and a body naming a subscription would move somebody else's — so neither is refused by a check, and both are unreachable because there is nowhere to write them.
154
+
155
+ ## License
156
+
157
+ MIT — adopter-side app value. The root `LICENSE` covers it.
@@ -0,0 +1 @@
1
+ (function(){function e(e){if(typeof e!=`object`||!e)return!1;let t=e;return typeof t.getItem==`function`&&typeof t.setItem==`function`&&typeof t.removeItem==`function`}function t(t){let n=typeof t.key==`string`?t.key.trim():``,r=e(t.store)?t.store:null,i=typeof t.ttlMs==`number`&&Number.isFinite(t.ttlMs)&&t.ttlMs>0?t.ttlMs:null;if(n!==``&&r!==null&&i!==null)return{key:n,store:r,ttlMs:i};if((t.key??t.store??t.ttlMs)==null)return null;let a=[n===``?`key`:null,r===null?`store`:null,i===null?`ttlMs`:null].filter(e=>e!==null).join(`, `);return console.warn(`Paddle price cache ignored. Missing: ${a}. A cache needs key, store and ttlMs together, so that where a customer's price rests and how long it stands are both stated. Pass all three, or none.`),null}function n(e){return`pithy.paddle.price.${e.key}.`}function r(e,t){return`${n(e)}${t}`}function i(e){if(e===null)return null;let t;try{t=JSON.parse(e)}catch{return null}if(typeof t!=`object`||!t||Array.isArray(t))return null;let n=t;return typeof n.at!=`number`||!(`answer`in n)?null:{at:n.at,answer:n.answer}}function a(e,t){let n=Date.now()-e;return n<0||n>t}function o(e,t){try{e.store.removeItem(r(e,t))}catch{}}function s(e,t){let n;try{n=e.store.getItem(r(e,t))}catch{return null}if(n===null)return null;let s=i(n);return s===null||a(s.at,e.ttlMs)?(o(e,t),null):s.answer}function c(e){let t=e.store;if(typeof t.key==`function`&&typeof t.length==`number`)try{let r=[],o=n(e);for(let n=0;n<t.length;n+=1){let s=t.key(n);if(s==null||!s.startsWith(o))continue;let c=i(t.getItem(s));(c===null||a(c.at,e.ttlMs))&&r.push(s)}for(let e of r)t.removeItem(e)}catch{}}function l(e,t,n){c(e);let i;try{i=JSON.stringify({at:Date.now(),answer:n})}catch{return}try{e.store.setItem(r(e,t),i)}catch{}}function u(e,t,n,r,i,a,o){try{var s=e[a](o),c=s.value}catch(e){n(e);return}s.done?t(c):Promise.resolve(c).then(r,i)}function d(e){return function(){var t=this,n=arguments;return new Promise(function(r,i){var a=e.apply(t,n);function o(e){u(a,r,i,o,s,`next`,e)}function s(e){u(a,r,i,o,s,`throw`,e)}o(void 0)})}}function f(e,t,n){return(t=y(t))in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}function p(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter(function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable})),n.push.apply(n,r)}return n}function m(e){for(var t=1;t<arguments.length;t++){var n=arguments[t]==null?{}:arguments[t];t%2?p(Object(n),!0).forEach(function(t){f(e,t,n[t])}):Object.getOwnPropertyDescriptors?Object.defineProperties(e,Object.getOwnPropertyDescriptors(n)):p(Object(n)).forEach(function(t){Object.defineProperty(e,t,Object.getOwnPropertyDescriptor(n,t))})}return e}function h(e,t){if(e==null)return{};var n,r,i=g(e,t);if(Object.getOwnPropertySymbols){var a=Object.getOwnPropertySymbols(e);for(r=0;r<a.length;r++)n=a[r],t.includes(n)||{}.propertyIsEnumerable.call(e,n)&&(i[n]=e[n])}return i}function g(e,t){if(e==null)return{};var n={};for(var r in e)if({}.hasOwnProperty.call(e,r)){if(t.includes(r))continue;n[r]=e[r]}return n}function _(){_=function(){return t};var e,t={},n=Object.prototype,r=n.hasOwnProperty,i=Object.defineProperty||function(e,t,n){e[t]=n.value},a=typeof Symbol==`function`?Symbol:{},o=a.iterator||`@@iterator`,s=a.asyncIterator||`@@asyncIterator`,c=a.toStringTag||`@@toStringTag`;function l(e,t,n){return Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}),e[t]}try{l({},``)}catch{l=function(e,t,n){return e[t]=n}}function u(e,t,n,r){var a=t&&t.prototype instanceof v?t:v,o=Object.create(a.prototype);return i(o,`_invoke`,{value:D(e,n,new j(r||[]))}),o}function d(e,t,n){try{return{type:`normal`,arg:e.call(t,n)}}catch(e){return{type:`throw`,arg:e}}}t.wrap=u;var f=`suspendedStart`,p=`suspendedYield`,m=`executing`,h=`completed`,g={};function v(){}function y(){}function b(){}var x={};l(x,o,function(){return this});var S=Object.getPrototypeOf,C=S&&S(S(M([])));C&&C!==n&&r.call(C,o)&&(x=C);var w=b.prototype=v.prototype=Object.create(x);function T(e){[`next`,`throw`,`return`].forEach(function(t){l(e,t,function(e){return this._invoke(t,e)})})}function E(e,t){function n(i,a,o,s){var c=d(e[i],e,a);if(c.type!==`throw`){var l=c.arg,u=l.value;return u&&typeof u==`object`&&r.call(u,`__await`)?t.resolve(u.__await).then(function(e){n(`next`,e,o,s)},function(e){n(`throw`,e,o,s)}):t.resolve(u).then(function(e){l.value=e,o(l)},function(e){return n(`throw`,e,o,s)})}s(c.arg)}var a;i(this,`_invoke`,{value:function(e,r){function i(){return new t(function(t,i){n(e,r,t,i)})}return a=a?a.then(i,i):i()}})}function D(t,n,r){var i=f;return function(a,o){if(i===m)throw Error(`Generator is already running`);if(i===h){if(a===`throw`)throw o;return{value:e,done:!0}}for(r.method=a,r.arg=o;;){var s=r.delegate;if(s){var c=O(s,r);if(c){if(c===g)continue;return c}}if(r.method===`next`)r.sent=r._sent=r.arg;else if(r.method===`throw`){if(i===f)throw i=h,r.arg;r.dispatchException(r.arg)}else r.method===`return`&&r.abrupt(`return`,r.arg);i=m;var l=d(t,n,r);if(l.type===`normal`){if(i=r.done?h:p,l.arg===g)continue;return{value:l.arg,done:r.done}}l.type===`throw`&&(i=h,r.method=`throw`,r.arg=l.arg)}}}function O(t,n){var r=n.method,i=t.iterator[r];if(i===e)return n.delegate=null,r===`throw`&&t.iterator.return&&(n.method=`return`,n.arg=e,O(t,n),n.method===`throw`)||r!==`return`&&(n.method=`throw`,n.arg=TypeError(`The iterator does not provide a '`+r+`' method`)),g;var a=d(i,t.iterator,n.arg);if(a.type===`throw`)return n.method=`throw`,n.arg=a.arg,n.delegate=null,g;var o=a.arg;return o?o.done?(n[t.resultName]=o.value,n.next=t.nextLoc,n.method!==`return`&&(n.method=`next`,n.arg=e),n.delegate=null,g):o:(n.method=`throw`,n.arg=TypeError(`iterator result is not an object`),n.delegate=null,g)}function k(e){var t={tryLoc:e[0]};1 in e&&(t.catchLoc=e[1]),2 in e&&(t.finallyLoc=e[2],t.afterLoc=e[3]),this.tryEntries.push(t)}function A(e){var t=e.completion||{};t.type=`normal`,delete t.arg,e.completion=t}function j(e){this.tryEntries=[{tryLoc:`root`}],e.forEach(k,this),this.reset(!0)}function M(t){if(t||t===``){var n=t[o];if(n)return n.call(t);if(typeof t.next==`function`)return t;if(!isNaN(t.length)){var i=-1,a=function n(){for(;++i<t.length;)if(r.call(t,i))return n.value=t[i],n.done=!1,n;return n.value=e,n.done=!0,n};return a.next=a}}throw TypeError(typeof t+` is not iterable`)}return y.prototype=b,i(w,`constructor`,{value:b,configurable:!0}),i(b,`constructor`,{value:y,configurable:!0}),y.displayName=l(b,c,`GeneratorFunction`),t.isGeneratorFunction=function(e){var t=typeof e==`function`&&e.constructor;return!!t&&(t===y||(t.displayName||t.name)===`GeneratorFunction`)},t.mark=function(e){return Object.setPrototypeOf?Object.setPrototypeOf(e,b):(e.__proto__=b,l(e,c,`GeneratorFunction`)),e.prototype=Object.create(w),e},t.awrap=function(e){return{__await:e}},T(E.prototype),l(E.prototype,s,function(){return this}),t.AsyncIterator=E,t.async=function(e,n,r,i,a){a===void 0&&(a=Promise);var o=new E(u(e,n,r,i),a);return t.isGeneratorFunction(n)?o:o.next().then(function(e){return e.done?e.value:o.next()})},T(w),l(w,c,`Generator`),l(w,o,function(){return this}),l(w,`toString`,function(){return`[object Generator]`}),t.keys=function(e){var t=Object(e),n=[];for(var r in t)n.push(r);return n.reverse(),function e(){for(;n.length;){var r=n.pop();if(r in t)return e.value=r,e.done=!1,e}return e.done=!0,e}},t.values=M,j.prototype={constructor:j,reset:function(t){if(this.prev=0,this.next=0,this.sent=this._sent=e,this.done=!1,this.delegate=null,this.method=`next`,this.arg=e,this.tryEntries.forEach(A),!t)for(var n in this)n.charAt(0)===`t`&&r.call(this,n)&&!isNaN(+n.slice(1))&&(this[n]=e)},stop:function(){this.done=!0;var e=this.tryEntries[0].completion;if(e.type===`throw`)throw e.arg;return this.rval},dispatchException:function(t){if(this.done)throw t;var n=this;function i(r,i){return s.type=`throw`,s.arg=t,n.next=r,i&&(n.method=`next`,n.arg=e),!!i}for(var a=this.tryEntries.length-1;a>=0;--a){var o=this.tryEntries[a],s=o.completion;if(o.tryLoc===`root`)return i(`end`);if(o.tryLoc<=this.prev){var c=r.call(o,`catchLoc`),l=r.call(o,`finallyLoc`);if(c&&l){if(this.prev<o.catchLoc)return i(o.catchLoc,!0);if(this.prev<o.finallyLoc)return i(o.finallyLoc)}else if(c){if(this.prev<o.catchLoc)return i(o.catchLoc,!0)}else{if(!l)throw Error(`try statement without catch or finally`);if(this.prev<o.finallyLoc)return i(o.finallyLoc)}}}},abrupt:function(e,t){for(var n=this.tryEntries.length-1;n>=0;--n){var i=this.tryEntries[n];if(i.tryLoc<=this.prev&&r.call(i,`finallyLoc`)&&this.prev<i.finallyLoc){var a=i;break}}a&&(e===`break`||e===`continue`)&&a.tryLoc<=t&&t<=a.finallyLoc&&(a=null);var o=a?a.completion:{};return o.type=e,o.arg=t,a?(this.method=`next`,this.next=a.finallyLoc,g):this.complete(o)},complete:function(e,t){if(e.type===`throw`)throw e.arg;return e.type===`break`||e.type===`continue`?this.next=e.arg:e.type===`return`?(this.rval=this.arg=e.arg,this.method=`return`,this.next=`end`):e.type===`normal`&&t&&(this.next=t),g},finish:function(e){for(var t=this.tryEntries.length-1;t>=0;--t){var n=this.tryEntries[t];if(n.finallyLoc===e)return this.complete(n.completion,n.afterLoc),A(n),g}},catch:function(e){for(var t=this.tryEntries.length-1;t>=0;--t){var n=this.tryEntries[t];if(n.tryLoc===e){var r=n.completion;if(r.type===`throw`){var i=r.arg;A(n)}return i}}throw Error(`illegal catch attempt`)},delegateYield:function(t,n,r){return this.delegate={iterator:M(t),resultName:n,nextLoc:r},this.method===`next`&&(this.arg=e),g}},t}function v(e,t){if(typeof e!=`object`||!e)return e;var n=e[Symbol.toPrimitive];if(n!==void 0){var r=n.call(e,t||`default`);if(typeof r!=`object`)return r;throw TypeError(`@@toPrimitive must return a primitive value.`)}return(t===`string`?String:Number)(e)}function y(e){var t=v(e,`string`);return typeof t==`symbol`?t:t+``}var b={CLASSIC:`classic`,V1:`v1`},x=b.V1,S=`https://cdn.paddle.com/paddle/paddle.js`,C=`https://cdn.paddle.com/paddle/v2/paddle.js`,w={url:S},T={url:C};function E(e){return document.querySelector(`script[src="${e}"]`)||void 0}function D(e){var t=document.createElement(`script`);t.src=e;var n=document.head||document.body;if(!n)throw Error(`Cannot inject Paddle.js. It needs a <head> or <body> element.`);return n.appendChild(t),t}var O={classic:void 0,v1:void 0},k={classic:`PaddleClassic`,v1:`PaddleBillingV1`};function A(e){var t=j(e)?.url;if(t){var n=O[e],r=k[e];return n===void 0?(O[e]=new Promise(function(n,i){if(typeof window>`u`){n(void 0);return}if(window[r]){n(window[r]);return}try{var a=E(t);a||=D(t),a.addEventListener(`load`,function(){window[r]?n(window[r]):i(Error(`Paddle.js not available`))}),a.addEventListener(`error`,function(){i(Error(`Failed to load Paddle.js - ${e}`))})}catch(e){i(e);return}}),O[e]):n}}function j(e){if(e===b.CLASSIC)return w;if(e===b.V1)return T;console.error(`[Paddle] Unknown Paddle Version`)}var M=[`environment`,`version`],ee=[`environment`,`version`];function N(e,t){var n=e.environment;e.version;var r=h(e,M);try{n&&t.Environment.set(n),t.Initialized?t.Update(m({},r)):t.Initialize(m({},r))}catch(e){console.warn(`[Paddle] Paddle Initialization failed. Please check the inputs`,e)}}function te(e,t){var n=e.environment;e.version;var r=h(e,ee);try{n&&t.Environment.set(n),t.Setup(m({},r))}catch(e){console.warn(`[Paddle] Paddle Initialization failed. Please check the inputs`,e)}}var ne;(function(e){e.CHECKOUT_LOADED=`checkout.loaded`,e.CHECKOUT_CLOSED=`checkout.closed`,e.CHECKOUT_UPDATED=`checkout.updated`,e.CHECKOUT_COMPLETED=`checkout.completed`,e.CHECKOUT_ERROR=`checkout.error`,e.CHECKOUT_FAILED=`checkout.failed`,e.CHECKOUT_ITEMS_UPDATED=`checkout.items.updated`,e.CHECKOUT_ITEMS_REMOVED=`checkout.items.removed`,e.CHECKOUT_CUSTOMER_CREATED=`checkout.customer.created`,e.CHECKOUT_CUSTOMER_UPDATED=`checkout.customer.updated`,e.CHECKOUT_CUSTOMER_REMOVED=`checkout.customer.removed`,e.CHECKOUT_PAYMENT_SELECTED=`checkout.payment.selected`,e.CHECKOUT_PAYMENT_INITIATED=`checkout.payment.initiated`,e.CHECKOUT_PAYMENT_FAILED=`checkout.payment.failed`,e.CHECKOUT_PAYMENT_ERROR=`checkout.payment.error`,e.CHECKOUT_DISCOUNT_APPLIED=`checkout.discount.applied`,e.CHECKOUT_DISCOUNT_REMOVED=`checkout.discount.removed`,e.CHECKOUT_UPSELL_CANCELED=`checkout.upsell.canceled`})(ne||={});var re;(function(e){e.DAY=`day`,e.WEEK=`week`,e.MONTH=`month`,e.YEAR=`year`})(re||={});var ie;(function(e){e.ALIPAY=`alipay`,e.APPLE_PAY=`apple-pay`,e.CARD=`card`,e.GOOGLE_PAY=`google-pay`,e.IDEAL=`ideal`,e.PAYPAL=`paypal`,e.WECHAT_PAY=`wechat-pay`,e.WIRE_TRANSFER=`wire-transfer`,e.NONE=`none`})(ie||={});var ae;(function(e){e.AMERICAN_EXPRESS=`american_express`,e.DINERS_CLUB=`diners_club`,e.DISCOVER=`discover`,e.JCB=`jcb`,e.MADA=`mada`,e.MAESTRO=`maestro`,e.MASTER_CARD=`mastercard`,e.UNION_PAY=`union_pay`,e.VISA=`visa`,e.UNKNOWN=`unknown`})(ae||={});var oe;(function(e){e.DRAFT=`draft`,e.READY=`ready`,e.COMPLETED=`completed`,e.BILLED=`billed`,e.canceled=`canceled`,e.PAST_DUE=`past_due`})(oe||={});function se(e){return P.apply(this,arguments)}function P(){return P=d(_().mark(function e(t){var n,r;return _().wrap(function(e){for(;;)switch(e.prev=e.next){case 0:return n=t?.version||x,e.next=3,A(n);case 3:if(r=e.sent,!r){e.next=9;break}return t&&(n===b.V1?N(t,r):n===b.CLASSIC&&te(t,r)),e.abrupt(`return`,r);case 9:return console.error(`[Paddle] Error Loading Paddle`),e.abrupt(`return`);case 11:case`end`:return e.stop()}},e)})),P.apply(this,arguments)}let ce={code:`client/unreadable`,message:`The store answered with something we couldn't read.`,action:`Try again. If it keeps happening, the app and the backend are out of step.`},le={code:`client/no_browser`,message:`There's no browser to send you to the payment page.`,action:`Start checkout from the page in the browser, not before it has loaded.`},ue=new Set([`BIF`,`CLP`,`DJF`,`GNF`,`ISK`,`JPY`,`KMF`,`KRW`,`PYG`,`RWF`,`UGX`,`UYI`,`VND`,`VUV`,`XAF`,`XOF`,`XPF`]),F=new Set([`BHD`,`IQD`,`JOD`,`KWD`,`LYD`,`OMR`,`TND`]);function I(e){let t=e.toUpperCase();return ue.has(t)?0:F.has(t)?3:2}let L=/\p{Nd}/u,R=/^-?[0-9]+$/;function z(e){return e!==void 0&&L.test(e)}function B(e){if(e===void 0||!z(e))return!1;let t=e.codePointAt(0);return t!==void 0&&!z(String.fromCodePoint(t-1))}function V(e,t){let n=[...e],r=n.length;for(;r>0&&!z(n[r-1]);)--r;let i=r-t;if(i<2)return e;for(let t=i;t<r;t+=1)if(!B(n[t]))return e;return z(n[i-1])||!z(n[i-2])?e:[...n.slice(0,i-1),...n.slice(r)].join(``)}function H(e){let t=I(e.currency);if(t===0||!R.test(e.minorAmount))return e.formatted;let n=Number(e.minorAmount);return!Number.isSafeInteger(n)||n%10**t!=0?e.formatted:V(e.formatted,t)}let U={code:`client/paddle_unavailable`,message:`We couldn't load the payment provider.`,action:`Check your connection or any content blockers, then reload.`},de={code:`client/paddle_not_initialized`,message:`The payment provider wouldn't start.`,action:`Reload. If it keeps happening, this project's payment settings are wrong.`},fe={code:`client/paddle_account_conflict`,message:`The payment provider is already running for a different account.`,action:`One page serves one Paddle account. Reload before switching.`},pe={code:`client/paddle_preview_refused`,message:`We couldn't get a price for you.`,action:`Try again in a moment.`},me={};function W(e){return`${e.environment}:${e.clientToken}`}let he=e=>se(e);function ge(e,t){let n=t?.registry??me,r=W(e),i=n.current;if(i)return i.key===r?i.paddle:Promise.resolve({ok:!1,failure:fe});let a=t?.initialize??he,o=new Promise(t=>{t(a({token:e.clientToken,environment:e.environment}))}).then(e=>e?e.Initialized?{ok:!0,value:e}:{ok:!1,failure:de}:{ok:!1,failure:le},()=>({ok:!1,failure:U})).then(e=>(!e.ok&&n.current?.key===r&&(n.current=void 0),e));return n.current={key:r,paddle:o},o}function G(e){return typeof e==`object`&&!!e&&!Array.isArray(e)}function K(e){if(!G(e))return null;let{subtotal:t,discount:n,tax:r,total:i}=e;return typeof t!=`string`||typeof n!=`string`||typeof r!=`string`||typeof i!=`string`?null:{subtotal:t,discount:n,tax:r,total:i}}function q(e){return typeof e==`string`&&e.length>0?e:null}function _e(e,t){let n=e.unitPriceOverrides;if(t!==null&&Array.isArray(n))for(let e of n){if(!G(e)||!Array.isArray(e.countryCodes)||!e.countryCodes.includes(t))continue;let n=e.unitPrice;return!G(n)||typeof n.amount!=`string`||typeof n.currencyCode!=`string`?null:{amount:n.amount,currency:n.currencyCode}}let r=e.unitPrice;return!G(r)||typeof r.amount!=`string`||typeof r.currencyCode!=`string`?null:{amount:r.amount,currency:r.currencyCode}}function ve(e,t,n,r){if(e.tax===`0`)return`none`;let i=_e(t,r);return i===null||i.currency!==n?`unknown`:e.subtotal===i.amount?`added`:e.total===i.amount?`included`:`unknown`}function ye(e,t,n){if(!G(e))return null;let r=e.price,i=e.product;if(!G(r)||!G(i)||typeof r.id!=`string`||typeof i.name!=`string`||typeof e.quantity!=`number`||typeof e.taxRate!=`string`)return null;let a=K(e.unitTotals),o=K(e.totals),s=K(e.formattedUnitTotals),c=K(e.formattedTotals);if(!a||!o||!s||!c)return null;let l=r.billingCycle,u=G(l)&&typeof l.interval==`string`&&typeof l.frequency==`number`?{interval:l.interval,frequency:l.frequency}:null;return{priceId:r.id,productName:i.name,priceName:q(r.name),quantity:e.quantity,taxRate:e.taxRate,billingCycle:u,unitTotals:a,totals:o,formattedUnitTotals:s,formattedTotals:c,taxTreatment:ve(a,r,t,n)}}function J(e){if(!G(e))return null;let t=G(e.data)?e.data:e;if(typeof t.currencyCode!=`string`)return null;let n=t.details;if(!G(n)||!Array.isArray(n.lineItems))return null;let r=G(t.address)?t.address:null,i=r?q(r.countryCode):null,a=r?q(r.postalCode):null,o=[];for(let e of n.lineItems){let n=ye(e,t.currencyCode,i);if(n===null)return null;o.push(n)}return{currencyCode:t.currencyCode,countryCode:i,postalCode:a,lines:o}}async function be(e,n,r){let i=r?.cache===void 0?null:t(r.cache),a=i===null?``:`${W(e)}|${Se(n)}`;if(i!==null){let e=s(i,a),t=e===null?null:J(e);if(t!==null)return{ok:!0,value:t}}let o=await ge(e,r);if(!o.ok)return o;let c=await new Promise(e=>e(o.value.PricePreview(n))).then(e=>({answered:!0,value:e}),()=>({answered:!1,value:void 0}));if(!c.answered)return{ok:!1,failure:pe};let u=J(c.value);return u===null?{ok:!1,failure:ce}:(i!==null&&l(i,a,c.value),{ok:!0,value:u})}function xe(e,t,n){let r=e.postalCode===null,i=t.formattedUnitTotals,a=r=>n?.wholeUnits===!0?H({formatted:i[r],minorAmount:t.unitTotals[r],currency:e.currencyCode}):i[r];return t.taxTreatment===`added`?{headline:a(`subtotal`),note:`Plus ${i.tax} tax.`,estimated:r}:t.taxTreatment===`included`?{headline:a(`total`),note:`Includes ${i.tax} tax.`,estimated:r}:t.taxTreatment===`none`?{headline:a(`total`),note:r?`Tax is settled at checkout.`:null,estimated:r}:{headline:a(`total`),note:`Includes ${i.tax} tax.`,estimated:r}}function Se(e){return JSON.stringify([e.items.map(e=>[e.priceId,e.quantity]),e.address?[e.address.countryCode,e.address.postalCode??null]:null,e.customerId??null,e.customerIpAddress??null,e.currencyCode??null,e.discountId??null])}async function Ce(e,t,n){let r=Object.entries(t);if(r.length===0)return{ok:!0,value:[]};let i=[...new Set(r.map(([,e])=>e))],a=await be(e,{...n?.query,items:i.map(e=>({priceId:e,quantity:1}))},n);if(!a.ok)return a;let o=[];for(let[e,t]of r){let r=a.value.lines.find(e=>e.priceId===t);if(r===void 0)continue;let i=xe(a.value,r,{wholeUnits:n?.wholeUnits});o.push({plan:e,priceId:t,headline:i.headline,note:i.note,estimated:i.estimated,currency:a.value.currencyCode})}return{ok:!0,value:o}}let we={code:`client/paddle_prices_not_configured`,message:`Prices aren't available right now.`,action:`Set data-paddle-env, data-paddle-token and every data-paddle-price-<name> id on the paddle-prices script tag.`},Y={sandbox:`test_`,production:`live_`},Te=/^pri_/,Ee=/^ctm_/;function X(e){try{return globalThis[e]??null}catch{return null}}function De(e,t){return e===`local`?t?.local===void 0?X(`localStorage`)??e:t.local:e===`session`?t?.session===void 0?X(`sessionStorage`)??e:t.session:e}function Oe(e){if(e===null)return null;let t=Number(e);return Number.isFinite(t)?t*1e3:NaN}function ke(e,n){if(e===null)return null;let r=e.getAttribute(`data-paddle-env`);if(r!==`sandbox`&&r!==`production`)return null;let i=e.getAttribute(`data-paddle-token`);if(i===null||!i.startsWith(Y[r]))return null;let a={};for(let t of e.getAttributeNames()){if(!t.startsWith(`data-paddle-price-`))continue;let n=e.getAttribute(t);if(n===null||!Te.test(n))return null;a[t.slice(18)]=n}if(Object.keys(a).length===0)return null;let o=e.getAttribute(`data-paddle-customer`)?.trim()??``,s=Ee.test(o)?{customerId:o}:{},c=t({key:e.getAttribute(`data-paddle-cache`),store:De(e.getAttribute(`data-paddle-cache-store`),n),ttlMs:Oe(e.getAttribute(`data-paddle-cache-ttl`))});return{setup:{clientToken:i,environment:r},plans:a,paint:e.getAttribute(`data-paddle-paint`)!==`off`,wholeUnits:e.getAttribute(`data-paddle-whole-units`)===`on`,query:s,cache:c}}let Z=`data-price-plan`,Q=`data-price-note`;function $(e,t){return e!==null&&e.toLowerCase()===t.toLowerCase()}function Ae(e,t){for(let n of Array.from(e.querySelectorAll(`[${Z}]`))){let e=t.find(e=>$(n.getAttribute(Z),e.plan));e!==void 0&&(n.textContent=e.headline)}for(let n of Array.from(e.querySelectorAll(`[${Q}]`))){let e=t.find(e=>$(n.getAttribute(Q),e.plan));e!==void 0&&e.note!==null&&(n.textContent=e.note)}}function je(e){return e.readyState===`loading`?new Promise(t=>{e.addEventListener(`DOMContentLoaded`,()=>t(),{once:!0})}):Promise.resolve()}async function Me(e,t,n){let{stores:r,...i}=n??{},a=ke(t,r);if(a===null)return{ok:!1,failure:we};let o=await Ce(a.setup,a.plans,{...i,query:{...a.query,...i.query},cache:i.cache??a.cache??void 0,wholeUnits:i.wholeUnits??a.wholeUnits});return!o.ok||!a.paint?o:(await je(e),Ae(e,o.value),o)}globalThis.pithyPaddlePrices={quotes:Me(document,document.currentScript)}})();
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "payments",
3
+ "package": "@pithy-sh/payments",
4
+ "requiredBindings": [
5
+ { "type": "d1", "name": "DB" },
6
+ {
7
+ "type": "workflow",
8
+ "name": "PAYMENTS_RECONCILE",
9
+ "job": "reconcile",
10
+ "className": "PaymentsReconcileWorkflow",
11
+ "optional": true
12
+ }
13
+ ],
14
+ "peerCapabilities": ["secrets"],
15
+ "optionalCapabilities": ["auth", "controlplane", "ledger"],
16
+ "migrationNamespace": "payments",
17
+ "secrets": [
18
+ {
19
+ "name": "payments-provider-credentials",
20
+ "origin": {
21
+ "kind": "obtained",
22
+ "issuer": "other",
23
+ "documentation": "https://github.com/pithy-sh/pithy/blob/main/docs/commands/payments.md#where-each-rails-credentials-come-from"
24
+ },
25
+ "rotation": {
26
+ "kind": "manual",
27
+ "issuer": "other",
28
+ "documentation": "https://github.com/pithy-sh/pithy/blob/main/docs/commands/payments.md#where-each-rails-credentials-come-from"
29
+ }
30
+ }
31
+ ],
32
+ "whenToEnable": "Four payment rails — Apple, Google, Stripe, Lemon Squeezy — resolving to one cross-rail entitlement, in your own Worker and your own D1. Buy Pro on iOS, be entitled on the web, with no hosted data plane holding your purchase history. A product is not an entitlement: `pro_monthly` and `pro_annual`, across four stores' catalogs, grant one key — `pro` — and gating code names the key, never a SKU. Every write converges on one idempotent projection keyed on (rail, provider transaction id), so a client submission, a provider webhook, and a reconciliation pass produce the identical row: a dropped client call costs nothing and a replayed webhook changes nothing. The projection is monotonic on the provider's own event time, because providers do not guarantee delivery order and a stale `expired` arriving after the `renewed` that superseded it would silently revoke a paying subscriber. Sandbox purchases are tracked as sandbox and never grant a production entitlement. A cron Workflow re-verifies what the webhooks missed, because webhook-only systems rot silently. Lemon Squeezy is the merchant of record — it owns the sales tax, the EU VAT registration, the invoicing and the chargebacks, which is what a solo developer reaches for when the alternative is registering for VAT in twenty-seven countries. Add auth — every route belongs to an authenticated purchaser or a machine proving authenticity, and there are no public routes. Add secrets: the four rails' credentials are read through it, so payments will not compose without it. Ledger fulfillment is opt-in per product; most products never touch a balance.",
33
+ "scaffold": [
34
+ "Add a `payments({ rails: {...}, products: {...} })` block to pithy.config.ts. The catalog lives in config, not in D1 — a product's entitlement mapping is policy, so it belongs in git rather than in a mutable table. A new SKU needs a deploy, which is the correct trade.",
35
+ "Give every product a `type` (`consumable`, `non_consumable`, or `subscription`), a display `name`, the `entitlements` it grants, and one block per rail it ships on. Omit a rail to ship without it — `remove_ads` on mobile only is expressed by omission, not a flag.",
36
+ "Bind a D1 database named DB in wrangler.jsonc — the same app database your other capabilities use.",
37
+ "Run `pithy migrate` to create pithy_payments_purchases, pithy_payments_entitlements, pithy_payments_provider_accounts, pithy_payments_webhook_events, and pithy_payments_reconcile_runs.",
38
+ "Add `@pithy-sh/secrets` if it is not already installed, then supply each rail's credentials through it: Apple's App Store Connect key with its key and issuer ids, Google's service account, Stripe's secret key and webhook signing secret. Apple's root certificates are public and ship with the package.",
39
+ "Add `@pithy-sh/auth` if it is not already installed. Purchases belong to an authenticated user; with no auth capability composed every route denies.",
40
+ "Comping and revoking entitlements by hand is a `control-plane` surface, not an admin user, so /payments/entitlements/grant and /payments/entitlements/revoke are reached with a signed management credential rather than a session. A grant must name an entitlement key this project defines — one a product grants, or one declared in `manualEntitlements` — so a console cannot comp `pr` for `pro` and leave a customer locked out; `payments:catalog:read` is the separately-granted read that lets a console offer a list instead of a text box, and it discloses only each product's id, kind, name and keys. Add `controlplane()` to this Worker and run `pithy dashboard connect`, then grant the connection `payments:catalog:read`, `payments:entitlements:grant`, `payments:entitlements:revoke`, or only the one its tooling needs — a refund tool that cannot comp cannot be turned into one, and a comp tool cannot take paid access away from a live customer. With the seam not composed both routes answer `controlplane/not_connected`, and no app session opens either of them whatever scopes it carries.",
41
+ "Selling through Stripe also needs a `stripe` block naming where its hosted pages return to — `successUrl`, `cancelUrl`, `portalReturnUrl`. Required whenever the rail is on, and config rather than request input: a client that could name a return URL could send a paying customer to a page it controls. Put `{CHECKOUT_SESSION_ID}` in the success URL's query and the thank-you page can post it to /payments/purchases for an entitlement at once.",
42
+ "Set the account link on every purchase your app starts — Apple's appAccountToken and Google's obfuscatedAccountId, in the app's own purchase flow. A webhook arrives carrying the provider's identifier and names no holder of ours, so a purchase made without one arrives orphaned. Stripe needs nothing from you here: /payments/checkout sets `client_reference_id` from the subject the caller acts for, which is the one hook a client must never be allowed to name.",
43
+ "Run `pithy payments provision` to deploy the reconciliation Workflow and write its binding. Every pass it runs leaves a row in pithy_payments_reconcile_runs — started, finished, rail, environment and tally, kept ninety days — and `GET /payments/admin/reconcile-runs` reads them behind `payments:reconcile:read`, so an adopter can tell a healthy integration from one whose cron stopped firing without granting anybody the purchase log. `pithy add` touches no Cloudflare account; provisioning is the separate, explicit step. Skipping it costs nothing up front — the binding is optional and every route works without it — but webhook-only systems rot silently, so the nightly pass is what turns a dropped delivery into a repaired row rather than a support ticket.",
44
+ "Gate a paid route with `requireEntitlement(\"pro\")` from @pithy-sh/core. The gate lives in core, so a Worker with no payments capability composed denies rather than opens."
45
+ ],
46
+ "configOptions": [
47
+ {
48
+ "key": "basePath",
49
+ "default": "/payments",
50
+ "describe": "Where the payments routes mount, webhooks included. Edit the `rails` and `products` objects by hand — the catalog maps each store's SKU to one logical product and the entitlements it grants, and it is the whole point of the capability."
51
+ },
52
+ {
53
+ "key": "billingSubject",
54
+ "choices": ["user", "organization"],
55
+ "describe": "Who holds a subscription in this project. user — one person buys, one person is entitled. organization — a company buys, and everybody in it is entitled. It keys the entitlement column and its UNIQUE index, so changing it once anybody has subscribed is a migration, not an edit."
56
+ },
57
+ {
58
+ "key": "manualEntitlements",
59
+ "default": [],
60
+ "describe": "Entitlement keys the control plane may grant that no product sells — a beta flag, an internal tier, a key that exists only to be comped. Declared, because the alternative to declaring is not checking: with this empty, a manual grant of any key outside the catalog is refused. Only grants are constrained; a revoke of a key the catalog has since dropped stays legal."
61
+ },
62
+ {
63
+ "key": "graceGrantsAccess",
64
+ "default": true,
65
+ "describe": "Whether a subscription in its billing-retry grace period still grants its entitlements. True, because that is the point of grace — a failed card should not lock a paying subscriber out mid-period. Once grace is exhausted the purchase moves to on_hold, which never grants."
66
+ }
67
+ ]
68
+ }
@@ -0,0 +1,167 @@
1
+ # Lemon Squeezy
2
+
3
+ _The reader's version of this page is [pithy.sh/docs/build/money/sell-with-lemon-squeezy](https://pithy.sh/docs/build/money/sell-with-lemon-squeezy). This copy ships in the package because `packages/payments/src/rails/lemonSqueezy/verify.ts` sends an adopter to it by name._
4
+
5
+ Wiring Lemon Squeezy into `@pithy-sh/payments`. Step by step.
6
+
7
+ Lemon Squeezy is the **merchant of record**, and that is the entire reason to choose it over Stripe. It owns the tax registration, the EU VAT, the invoice, the dunning and the chargebacks — it sells to your buyer and pays you, rather than processing a payment on your behalf. Like Stripe here, it is **hosted only**: Lemon Squeezy presents the payment page and takes the card, Pithy sends a browser there and hears the outcome on a webhook. No card fields in your app, no plan-change or proration logic, and no tax settings of ours to get wrong.
8
+
9
+ ## Why this part is manual
10
+
11
+ Everything below is created in the [Lemon Squeezy dashboard](https://app.lemonsqueezy.com) by a human with access to your store. Pithy cannot provision it for you: prices are commercial decisions, and a webhook signing secret is chosen once and shared with exactly one deployment. One-time setup per environment. Everything after it is config.
12
+
13
+ ## 1. Create your store, products and variants
14
+
15
+ You need a store before anything else — Lemon Squeezy sells on your behalf, so the store is the legal party doing the selling and it is what your credentials name.
16
+
17
+ Under **Products**, create a product and give it a **variant**. A variant is Lemon Squeezy's price-equivalent: it carries the amount, the interval, and the trial. So the **variant id** — `123456`, not the product id — is what goes in `pithy.config.ts`, exactly where Stripe's `priceId` sits.
18
+
19
+ ```ts
20
+ payments({
21
+ rails: { lemonSqueezy: true },
22
+ lemonSqueezy: {
23
+ successUrl: `${PUBLIC_ORIGIN}/thanks`,
24
+ },
25
+ products: {
26
+ pro_monthly: {
27
+ type: "subscription",
28
+ name: "Pro",
29
+ entitlements: ["pro"],
30
+ lemonSqueezy: { variantId: "123456" },
31
+ },
32
+ },
33
+ });
34
+ ```
35
+
36
+ The catalog is the only place a variant id appears. Gating code names `pro`.
37
+
38
+ A variant id is **publishable** by design — it is what a hosted checkout names, so it may reach a browser. The API key and the webhook signing secret never do; they live in the secrets store.
39
+
40
+ Test mode and live mode hold different products, and so different variant ids. So do your staging and production configs.
41
+
42
+ ## 2. Declare the return URLs
43
+
44
+ `lemonSqueezy.successUrl` is **required whenever the rail is on**, and a project that turns Lemon Squeezy on without it fails to parse its config at deploy. That is deliberate: the alternative is a build that ships, sells nothing, and reports it as a 404 on somebody's first checkout.
45
+
46
+ They are config rather than request input, and that is the security part. A client that could name where hosted checkout returns to could send a paying customer to a page it controls. Build both on `PUBLIC_ORIGIN` and never on a literal — an origin written down is production's origin written into staging.
47
+
48
+ `successUrl` is what the created checkout carries as its `redirect_url`. It is the only one, where Stripe takes three, and both absences are the store's rather than an omission in Pithy: Lemon Squeezy's checkout has no cancel destination — a buyer who backs out closes the tab — and its customer portal is a signed, expiring link with nowhere to return to.
49
+
50
+ There is **no `portalReturnUrl`**, and there is nowhere for one to go. Stripe mints a Billing Portal session per caller and takes a return URL with it; Lemon Squeezy's customer portal is a signed, expiring link hanging off the customer object, which `POST /payments/portal` reads back and hands over. The link takes no return parameter — the subscriber closes the tab — so this rail declines one in the type rather than accepting a URL it would silently drop.
51
+
52
+ **Your success page must show a pending state and wait for the webhook. It cannot post a receipt, because this rail has no receipt to post.** `POST /payments/purchases` refuses a Lemon Squeezy submission with `payments/invalid_receipt`, and that refusal is the secure answer rather than a gap.
53
+
54
+ Stripe can confirm on return because the buyer comes back holding a Checkout Session id Stripe substituted into the URL: an unguessable token your own server trades for a session carrying the reference it set. Lemon Squeezy has no equivalent. Its order ids are **sequential integers**, so a `verify` that trusted a submitted order id would let any authenticated caller claim any order in the store by counting — including one belonging to somebody who has not signed in yet, whose purchase would then be bound to the attacker forever, since the first pairing wins and nothing rebinds it.
55
+
56
+ The order's UUID `identifier` looks like the repair and is not one. It is unguessable, but it is not a *credential*: it appears in the buyer's own receipt email and in the storefront's return URL, so it proves possession of a value that was never secret, and the order object carries no reference this server set that could be checked against the caller. Binding on an unguessable-but-unauthenticated identifier is what lets a client's choice of value decide who a purchase belongs to.
57
+
58
+ What it costs, stated plainly: a Lemon Squeezy buyer's entitlement appears when the webhook lands rather than the moment they return — seconds, usually. Show pending, poll `GET /payments/entitlements`, and render the entitlement when it arrives. The webhook is authoritative on every rail anyway; here it is the only path.
59
+
60
+ ## 3. Create the webhook endpoint
61
+
62
+ Under **Settings** → **Webhooks**, add an endpoint pointing at `https://<your-worker>/payments/webhooks/lemon-squeezy`, one per environment. If you moved `basePath`, register the URL to match.
63
+
64
+ You choose the **signing secret** yourself when you create the webhook. That is what verifies every delivery: a bare HMAC-SHA256 over the exact received bytes, in `X-Signature`.
65
+
66
+ Subscribe it to these events, and only these:
67
+
68
+ | Event | What it does |
69
+ |---|---|
70
+ | `subscription_created` | A subscription began — writes the `state` row that carries access |
71
+ | `subscription_updated` | It renewed, lapsed into dunning, or had its status moved otherwise |
72
+ | `subscription_cancelled` | Auto-renew was turned off; access runs to the date the row now carries |
73
+ | `subscription_expired` | It ended |
74
+ | `subscription_paused` | The subscriber suspended it; `paused` grants nothing |
75
+ | `subscription_unpaused` | They resumed it |
76
+ | `subscription_payment_success` | One billing period was paid — writes the `charge` row that credits a `grants` clause |
77
+ | `subscription_payment_failed` | An invoice was not paid; the charge row records it and credits nothing |
78
+ | `subscription_payment_recovered` | A failed invoice was collected after all |
79
+ | `subscription_payment_refunded` | The invoice was refunded, and access is revoked with it |
80
+ | `order_created` | A one-off purchase — money and state in one row |
81
+ | `order_refunded` | It was refunded |
82
+
83
+ Anything else is recorded and ignored, so subscribing to more costs you table rows and nothing else. Subscribing to fewer loses purchases.
84
+
85
+ ## 4. Get the three credentials
86
+
87
+ The **API key** from **Settings** → **API**. It creates hosted checkouts, reads orders and subscriptions, and mints customer-portal links.
88
+
89
+ The **webhook signing secret** — the one you chose in step 3.
90
+
91
+ The **store id** from your store's settings. It is the store this deployment sells through, and the checkout call names it.
92
+
93
+ The API key is **account-wide**: it returns test-mode objects to a production deployment too. That is why `test_mode` on the object — never the key — decides a purchase's environment, which is the opposite of Stripe's arrangement and the thing to know before reading the next section but one.
94
+
95
+ ## Where the credentials live
96
+
97
+ **All three values travel as one typed JSON secret**, through `@pithy-sh/secrets` — never committed, never an env literal. Lemon Squeezy's block sits inside `payments-provider-credentials` alongside any other rail's:
98
+
99
+ ```sh
100
+ echo '{"lemonSqueezy":{"apiKey":"…","webhookSecret":"…","storeId":"12345"}}' \
101
+ | pithy secrets create payments-provider-credentials --env prod
102
+ ```
103
+
104
+ The value comes from stdin, or from a prompt at a terminal. The secret is environment-scoped, so `--env` is required and each environment holds its own — which is what keeps a staging store's signing secret and production's apart.
105
+
106
+ A rail's block is present in full or absent entirely, and that is enforced where you can see it: the registry's schema is checked before the write lands, so half a credential is a refusal in your terminal rather than a signature check that silently never passes.
107
+
108
+ The secret is **rotatable**, per environment, but a Lemon Squeezy rotation is a cutover rather than an overlap. Its scheme sends one signature and this rail checks the one secret stored for the environment, so change the webhook's secret and the stored value in the same sitting. Deliveries signed with the old secret are refused 401 in between, and Lemon Squeezy redelivers them.
109
+
110
+ ## What a Lemon Squeezy purchase actually does
111
+
112
+ Worth knowing, because it explains the failure modes:
113
+
114
+ 1. Your paywall posts to `POST /payments/checkout` with a catalog product id. The **variant** comes from the catalog, the **return URL** from config, and the **subject** — the person or the organization this purchase will belong to — from the subject seam, which resolves it from the authenticated session. None of the three comes from the request body.
115
+ 2. Payments creates a hosted checkout with that subject stamped into `checkout_data.custom` as `pithy_account_reference` — one string, `user:<id>` or `organization:<id>`, because either half of the pair alone is ambiguous — and this deployment's environment beside it as `pithy_env`. Both keys are snake_case because Lemon Squeezy normalizes them before echoing them back. The browser goes to the URL Lemon Squeezy returns.
116
+ 3. Lemon Squeezy takes the money as merchant of record and POSTs its webhook. The echoed `pithy_account_reference` and the delivery's `customer_id` are what write the `(lemonSqueezy, <customer id>) → subject` row. **This is the only place that link is ever made**, because this rail has no client-submission path at all. A reference that is not exactly that encoding decodes to nobody — a bare id, the format that predates subjects, is refused rather than read as a user — and the delivery lands unbound.
117
+ 4. Every delivery's `X-Signature` is verified — an HMAC over the exact received bytes — then recorded, then projected. There is no timestamp in the scheme and so **no freshness window**: replay protection is entirely the `UNIQUE (rail, providerEventId)` insert, with the projection's monotonic `providerEventAt` rule behind it.
118
+ 5. An invoice-domain delivery costs one read. A subscription invoice carries no variant, and the writer needs one, so the rail reads the invoice's subscription to learn which variant it bills. Subscription and order deliveries carry everything they need and cost nothing.
119
+ 6. Every delivery lands in `pithy_payments_webhook_events`. A delivery that projected carries `processedAt`; one that did not carries the reason and no `processedAt`, so the store's next attempt — or your replay — runs it again.
120
+
121
+ So: a delivery that fails its signature is 401 and **nothing is recorded**, which is what stops a forger filling the table. A delivery Lemon Squeezy signed but this build does not map — a license-key event, an affiliate payout — is 200 with a row and nothing else. A test-mode purchase against a production deployment is 200, is not projected, and records `payments/environment_mismatch`. An outage at Lemon Squeezy while an invoice delivery is being read answers `payments/provider_unavailable` (503) rather than a signature failure, so the store redelivers instead of an operator hunting for a rotated key.
122
+
123
+ **Identifiers, since they surface in your data.** Lemon Squeezy numbers **each object type from one** and renders the number as a string, so order `8801` and subscription-invoice `8801` are different objects wearing the same id. A purchase row's identity is `UNIQUE (rail, providerTransactionId)`, so storing the bare id would fuse an order and an invoice into one row — one buyer's refund landing on another buyer's subscription. This rail therefore namespaces every id by the object type it came from: `subscription:90001`, `subscription_invoice:8001`, `order:7001`. Stripe never faced this, because `pi_`, `in_` and `sub_` are globally distinct. The prefix is part of the row's identity forever.
124
+
125
+ A subscription also produces **two kinds of row**, which no other rail does. One `role = 'state'` row keyed `subscription:<id>` carries the standing — active, paused, canceled — and is what grants access. One `role = 'charge'` row keyed `subscription_invoice:<id>` per billing period carries the money, and names the subscription as its family. **Only `charge` rows fulfill a `grants` clause**, so N renewals credit exactly N times. This is not a modeling preference: Lemon Squeezy splits money from state at the source. `subscription_payment_*` delivers an amount and nothing about whether the subscription is still live, `subscription_*` delivers a status and no charge, and its subscription object carries no latest-invoice pointer, so neither names the other's key. Collapsing them would stamp two different clocks into one monotonic watermark and drop the renewal that follows a refund.
126
+
127
+ **One bounded gap.** A purchase made in the Lemon Squeezy storefront rather than through `POST /payments/checkout` carries no stamp of yours, so nothing Pithy trusts says which subject it belongs to. If that customer has bought through your checkout before, the provider-account map already knows them and the purchase projects onto that subject. If they have not, the delivery is recorded as orphaned with an audit warning and projects nothing — there is no subject to project it onto, and no number of retries conjures one. Sell through `POST /payments/checkout` and it never arises.
128
+
129
+ ## Sharing one store across environments
130
+
131
+ A Lemon Squeezy store is **one namespace across every environment**. Test mode is a flag on an object, not a separate store, so a `dev` deployment and a `staging` deployment pointed at one store both hear everything the other's buyers do.
132
+
133
+ Pithy stamps this deployment's `ENVIRONMENT` into `checkout_data.custom` as `pithy_env` when it creates the checkout, and reads it back off `meta.custom_data` on every delivery. An event stamped for another deployment projects nothing: no row, no entitlement, no audit warning, and a 200. The other deployment, which is the one that started that checkout, projects it.
134
+
135
+ Two things are deliberately outside the fence. A delivery carrying **no** stamp is not fenced out — a storefront order was still a real sale, and fencing on absence would drop it silently. And a deployment that does not know its own `ENVIRONMENT` fences nothing, which is how the other three rails behave too.
136
+
137
+ **An unstamped delivery is projected but never trusted to say who it belongs to**, and that distinction is load-bearing. Lemon Squeezy's public buy links accept `checkout[custom][...]` parameters, and the webhook echoes them exactly as one of yours would — so `custom_data` alone is not evidence your server wrote anything. A stranger could otherwise put any reference in it and bind their store customer to it permanently, since the provider-account map never rebinds — and under organization billing the reference to aim at is guessable from a company id they may already know. Pithy therefore honors `pithy_account_reference` only when its own environment stamp is beside it, and it compares both halves of the pair it decodes to, never the id alone. A storefront purchase by a customer your checkout already knows still lands on their account, through the account map; one by a stranger lands unbound and is repairable from the trail.
138
+
139
+ `test_mode` is a separate axis. It drives the purchase's `environment`, and the projection writer refuses across that one outright.
140
+
141
+ ## Testing without spending money
142
+
143
+ Turn on test mode in the dashboard and buy through the test-mode variant. Every object Lemon Squeezy makes there carries `test_mode: true`, and payments treats it as **sandbox** — a test purchase reaching a production deployment is refused with `payments/environment_mismatch` and grants nothing. That is deliberate and it is the single most common in-app-purchase defect there is.
144
+
145
+ An object that does not say which mode it is in lands on sandbox as well. The failure directions are not symmetric: treating production as sandbox loses a purchase that reconciliation repairs, while treating sandbox as production hands out real entitlements for test money.
146
+
147
+ Note that the account-wide API key is not part of this decision. There is no test key to hold apart from a live one, so the object's own flag is the only signal — which is why a staging deployment and a production one can share a key and must not share a stamp.
148
+
149
+ There is no local forwarder for this rail. Lemon Squeezy POSTs to a URL it can reach, so point a staging deployment with its own webhook and its own signing secret at test mode, and test purchases project there.
150
+
151
+ ## What Pithy deliberately does not do
152
+
153
+ No card fields, and no embedded checkout. No proration, plan-change, or upgrade logic. No tax configuration — that is the merchant of record's job, and it is the reason you chose this rail. No license keys: their events are recorded and ignored, and nothing in this package issues or validates one. No affiliates, no discount codes, and no quantity — the variant *is* the price, and a checkout that could name an amount would be a checkout a client could name an amount on.
154
+
155
+ All of it is Lemon Squeezy's, reachable from its dashboard and its customer portal, and none of it is a small edit away in this package.
156
+
157
+ ## Checklist
158
+
159
+ - [ ] Store created, and its id copied.
160
+ - [ ] Products and variants created; variant ids in `pithy.config.ts` under each product's `lemonSqueezy` block.
161
+ - [ ] `lemonSqueezy.successUrl` declared, built on `PUBLIC_ORIGIN`.
162
+ - [ ] Success page shows a pending state and polls — it posts no receipt, because there is none.
163
+ - [ ] Webhook endpoint created per environment, pointing at `/payments/webhooks/lemon-squeezy`.
164
+ - [ ] Subscribed to the twelve events in step 3 — no fewer.
165
+ - [ ] Signing secret chosen at creation and stored for that environment alone.
166
+ - [ ] `apiKey` + `webhookSecret` + `storeId` stored together via `pithy secrets create payments-provider-credentials`; `rails: { lemonSqueezy: true }` in config.
167
+ - [ ] Test-mode variants bought against a staging deployment with its own webhook and secret.