@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,79 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { PaymentsVerificationFailedError } from "../../error/errors";
5
+ import type { PaymentsStripeCredentials } from "../../secret/registry";
6
+ import type { VerifiedNotification, WebhookDelivery } from "../contract";
7
+ import { mapStripeEvent, StripeEvent } from "./objects";
8
+ import { STRIPE_SIGNATURE_HEADER, verifyStripeSignature } from "./signature";
9
+
10
+ /**
11
+ * One Stripe webhook delivery: verified, read, and normalized.
12
+ *
13
+ * Three steps, in this order, and the order is the point. **Authenticity first** — the HMAC covers the exact
14
+ * received bytes, so nothing about the body is trustworthy until it passes, and a forgery is refused before a
15
+ * single field is read. Then the **envelope**, because an event's `type` is what says which object it carries.
16
+ * Then the **mapping**, which is `objects.ts`'s job and the only part that knows what Stripe's vocabulary means.
17
+ *
18
+ * The stored payload is the whole event, not the object inside it. The webhook row is the replay source, and
19
+ * the envelope carries the event id, the type, and Stripe's own timestamp — which is what makes "why didn't this
20
+ * renew" answerable from the table without going back to Stripe.
21
+ */
22
+
23
+ /** What verifying one Stripe delivery needs: the endpoint's secret, and a clock for the replay window. */
24
+ export interface StripeWebhookOptions {
25
+ /** Stripe's credential block. Only `webhookSecret` is used here; the API key belongs to the other paths. */
26
+ credentials: PaymentsStripeCredentials;
27
+ /** The clock, for the signature's freshness window. Injected so tests are deterministic. */
28
+ now: Date;
29
+ /** The freshness window, in seconds. Defaults to Stripe's own five minutes. */
30
+ toleranceSeconds?: number;
31
+ }
32
+
33
+ /** Verify and normalize one Stripe webhook delivery. Throws when authenticity cannot be established. */
34
+ export async function parseStripeNotification(
35
+ delivery: WebhookDelivery,
36
+ options: StripeWebhookOptions,
37
+ ): Promise<VerifiedNotification> {
38
+ await verifyStripeSignature(
39
+ delivery.body,
40
+ delivery.headers.get(STRIPE_SIGNATURE_HEADER),
41
+ options.credentials.webhookSecret,
42
+ { now: options.now, toleranceSeconds: options.toleranceSeconds },
43
+ );
44
+
45
+ let raw: unknown;
46
+ try {
47
+ raw = JSON.parse(delivery.body) as unknown;
48
+ } catch (cause) {
49
+ // Cannot happen from Stripe — the bytes just proved they came from Stripe. If it ever does, it must not
50
+ // become a half-read event.
51
+ throw new PaymentsVerificationFailedError(
52
+ { detail: "Stripe: the delivery verified but its body is not JSON." },
53
+ { cause },
54
+ );
55
+ }
56
+
57
+ const parsed = StripeEvent.safeParse(raw);
58
+ if (!parsed.success) {
59
+ // Never echo the body: it is a customer's purchase, and it carries their Stripe identifiers.
60
+ throw new PaymentsVerificationFailedError({
61
+ detail: `Stripe: the delivery is not a Stripe event — ${parsed.error.issues
62
+ .map((issue) => `${issue.path.join(".") || "<root>"}:${issue.code}`)
63
+ .join(", ")}.`,
64
+ });
65
+ }
66
+
67
+ const event = parsed.data;
68
+ const mapped = mapStripeEvent(event);
69
+ return {
70
+ // Stripe's own event id. `UNIQUE (rail, providerEventId)` on it is what makes a redelivery recognized rather
71
+ // than reprocessed, and Stripe retries every non-2xx for three days.
72
+ providerEventId: event.id,
73
+ payload: event as Record<string, unknown>,
74
+ event: mapped.event,
75
+ providerAccountId: mapped.providerAccountId,
76
+ accountReference: mapped.accountReference,
77
+ note: mapped.note,
78
+ };
79
+ }
@@ -0,0 +1,275 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { defineSecretRegistry } from "@pithy-sh/secrets/src/registry";
5
+ import { z } from "zod";
6
+ import type { PaymentsRail } from "../data/rail";
7
+ import { PaymentsRailNotConfiguredError } from "../error/errors";
8
+
9
+ /**
10
+ * The one secret payments reads, and the shape of it.
11
+ *
12
+ * **One entry, five optional rails.** Every rail's credentials live inside a single JSON secret rather than
13
+ * one secret per rail, and that is a storage decision worth stating: adding a rail then never reshapes
14
+ * storage, never adds a binding to a `wrangler.jsonc`, and never needs a migration of the secrets store. It
15
+ * is the arrangement `@pithy-sh/turnstile` uses for its per-mode widget keys, for the same reason — one
16
+ * secret serves one *or* both, so the second costs nothing.
17
+ *
18
+ * A rail's block is present or entirely absent. There is no partial credential: `strictObject` with every
19
+ * field required means an operator who supplies half of Stripe's pair gets a loud
20
+ * `secrets/invalid_value` at the read rather than a signature check that silently never passes.
21
+ *
22
+ * ## Reading it
23
+ *
24
+ * `await sharedSecretsStore(env, paymentsSecretsRegistry)` then `.get(PAYMENTS_PROVIDER_SECRET)`, at the
25
+ * point of need — never off a raw `env.X`, never through `CloudflareSecretsStoreManager`, never cached in a
26
+ * module variable, and never spread into a log or an audit payload. Grep `sharedSecretsStore(` to find every
27
+ * read site. `backend` is the single place the storage location is decided; moving this bundle to the
28
+ * Cloudflare Secrets Store would be a one-line edit here (plus a binding) and no change at any read site.
29
+ *
30
+ * `rotatable: true` because two of these genuinely rotate — Apple's App Store Connect key and Stripe's
31
+ * webhook signing secret — and a verifier that must span a rotation reads `getVersions` instead of `get`.
32
+ * Rotation changes nothing about how the value is stored, so declaring it now costs nothing and declaring it
33
+ * later would be a registry edit during an incident.
34
+ *
35
+ * ## What is not here
36
+ *
37
+ * Apple's **root certificates**, because they are public: they ship in `rails/apple/certs.ts` as pinned
38
+ * assets. Storing a public key as a secret would suggest the verification depends on its secrecy, and it does
39
+ * not — it depends on it being *ours*.
40
+ *
41
+ * The **bundle id** is here, in Apple's block, and it is not secret either. It sits with the credentials
42
+ * because it is part of the app's identity at Apple: it is what a notification is checked against, and it is
43
+ * the `bid` claim an App Store Server API token carries. Splitting it into config would put one half of one
44
+ * app's identity in git and the other in the secrets store.
45
+ */
46
+
47
+ /** The name the credential bundle is stored and resolved under. The join key across every registry. */
48
+ export const PAYMENTS_PROVIDER_SECRET = "payments-provider-credentials";
49
+
50
+ /**
51
+ * The console each rail's credentials are taken from, and taken from again ninety days later.
52
+ *
53
+ * **This is the honest answer, and it does not fit in `documentation`.** That field holds one required
54
+ * URL; this secret spans five issuers, and every one of them publishes its own page. There is no sixth
55
+ * page that names all five, because no company documents a competitor's console. A single string can
56
+ * therefore be true of at most one rail out of five, and the entry has to say something for all of them.
57
+ *
58
+ * So the entry points at ours, `PAYMENTS_CREDENTIALS_PAGE`, whose only job is to carry this table to an
59
+ * operator who arrived by clicking. The table is the destination; the page is the redirect. That is one
60
+ * hop more than the field promises — `SecretOrigin` restricting `documentation` to a single URL is the
61
+ * reason, and #332 is where the shape is argued.
62
+ *
63
+ * Not a rail's marketing page and not a "how to integrate" guide: the deep link to the settings screen
64
+ * that shows the key, so an operator lands on the thing they came to copy.
65
+ */
66
+ export const PAYMENTS_RAIL_CONSOLES: Readonly<Record<PaymentsRail, string>> = {
67
+ // Users and Access → Integrations → App Store Connect API. The `.p8` downloads exactly once here, and
68
+ // the key id and issuer id are both on this screen.
69
+ apple: "https://appstoreconnect.apple.com/access/integrations/api",
70
+ // The service account and its JSON key. Play Console grants that account access; the key is minted here.
71
+ google: "https://console.cloud.google.com/iam-admin/serviceaccounts",
72
+ // The secret key. The webhook signing secret is per endpoint, one screen across in Developers → Webhooks.
73
+ stripe: "https://dashboard.stripe.com/apikeys",
74
+ lemonSqueezy: "https://app.lemonsqueezy.com/settings/api",
75
+ paddle: "https://vendors.paddle.com/authentication-v2",
76
+ };
77
+
78
+ /**
79
+ * Where a human goes for these credentials, and where the same human replaces them.
80
+ *
81
+ * **Five consoles, one `issuer` field.** Apple's `.p8`, Google's service-account key, Stripe's key pair,
82
+ * Lemon Squeezy's API key and Paddle's are each taken by hand from a different company's console, and they
83
+ * share one secret because they share one storage decision (see above) — not because they share an issuer.
84
+ * The axis holds a single name, so the honest one is `other`: *somebody issues this, and it is not one
85
+ * somebody.* Naming any single rail would be wrong for every deployment that does not sell through it.
86
+ *
87
+ * So the link is the one page that names all five rather than a rail's settings page. It has to *name*
88
+ * them: this constant pointed at the same document before #332 and the document listed no console at all,
89
+ * which cost the click and returned the reader to the search they started with. The section is fixed by
90
+ * name, and `registry.test.ts` reads the file off disk and fails if a rail's console is not in it.
91
+ *
92
+ * The day this bundle is split per rail — or `SecretOrigin` grows a destination per issuer — each half
93
+ * names its own console and this constant goes.
94
+ */
95
+ export const PAYMENTS_CREDENTIALS_PAGE =
96
+ "https://github.com/pithy-sh/pithy/blob/main/docs/commands/payments.md#where-each-rails-credentials-come-from";
97
+
98
+ export const PaymentsAppleCredentials = z
99
+ .strictObject({
100
+ bundleId: z
101
+ .string()
102
+ .min(1)
103
+ .describe(
104
+ "The app's bundle id. Not a secret, but part of the app's identity at Apple: every notification and receipt is checked against it, because an Apple signature proves Apple signed the payload and never that it is about this app.",
105
+ ),
106
+ keyId: z
107
+ .string()
108
+ .min(1)
109
+ .describe("The App Store Connect API key id — the `kid` of the token that calls the App Store Server API."),
110
+ issuerId: z
111
+ .string()
112
+ .min(1)
113
+ .describe("The App Store Connect issuer id, from the Keys page. The `iss` of that same token."),
114
+ privateKey: z
115
+ .string()
116
+ .min(1)
117
+ .describe(
118
+ "The App Store Connect private key, the `.p8` file's contents including its PEM header and footer. Downloadable exactly once from App Store Connect, so it is supplied rather than minted.",
119
+ ),
120
+ })
121
+ .describe("Apple's credentials: the app's identity, and the App Store Connect key that signs server-API calls.");
122
+ export type PaymentsAppleCredentials = z.infer<typeof PaymentsAppleCredentials>;
123
+
124
+ export const PaymentsGoogleCredentials = z
125
+ .strictObject({
126
+ packageName: z
127
+ .string()
128
+ .min(1)
129
+ .describe(
130
+ "The Android application id. What a Play purchase token is looked up against, and the equivalent of Apple's bundle id.",
131
+ ),
132
+ serviceAccountEmail: z
133
+ .string()
134
+ .min(1)
135
+ .describe(
136
+ "The Google Cloud service account that reads the Play Developer API and is the audience of the Pub/Sub push token.",
137
+ ),
138
+ privateKey: z
139
+ .string()
140
+ .min(1)
141
+ .describe(
142
+ "The service account's private key, as the downloaded JSON's `private_key` field. Supplied, never minted.",
143
+ ),
144
+ pubsubAudience: z
145
+ .string()
146
+ .min(1)
147
+ .describe(
148
+ "The audience the Pub/Sub push OIDC token must claim. Checked on every notification: a token with the right signature and the wrong audience is one issued for somebody else's endpoint.",
149
+ ),
150
+ })
151
+ .describe("Google's credentials: the app's identity, the Play Developer API service account, and the push audience.");
152
+ export type PaymentsGoogleCredentials = z.infer<typeof PaymentsGoogleCredentials>;
153
+
154
+ export const PaymentsStripeCredentials = z
155
+ .strictObject({
156
+ secretKey: z
157
+ .string()
158
+ .min(1)
159
+ .describe(
160
+ "The Stripe secret API key — `sk_live_…` or `sk_test_…`. Creates Checkout and Billing Portal sessions.",
161
+ ),
162
+ webhookSecret: z
163
+ .string()
164
+ .min(1)
165
+ .describe(
166
+ "The webhook endpoint's signing secret — `whsec_…`. What the `Stripe-Signature` HMAC is checked against, and the one Stripe rotates.",
167
+ ),
168
+ })
169
+ .describe("Stripe's credentials: the secret key that creates hosted sessions, and the webhook signing secret.");
170
+ export type PaymentsStripeCredentials = z.infer<typeof PaymentsStripeCredentials>;
171
+
172
+ export const PaymentsLemonSqueezyCredentials = z
173
+ .strictObject({
174
+ apiKey: z
175
+ .string()
176
+ .min(1)
177
+ .describe(
178
+ "The Lemon Squeezy API key. Creates hosted checkouts, reads orders and subscriptions, and mints customer-portal links. Account-wide: it returns test-mode objects to a production deployment too, which is why `test_mode` on the object — never the key — decides a purchase's environment.",
179
+ ),
180
+ webhookSecret: z
181
+ .string()
182
+ .min(1)
183
+ .describe(
184
+ "The webhook's signing secret, set when the webhook is created. What the `X-Signature` HMAC-SHA256 over the exact received body is checked against.",
185
+ ),
186
+ storeId: z
187
+ .string()
188
+ .min(1)
189
+ .describe(
190
+ "The Lemon Squeezy store id this deployment sells through. Account-level identity, the way Apple's `bundleId` is, which is why it sits with the credentials rather than in config.",
191
+ ),
192
+ })
193
+ .describe("Lemon Squeezy's credentials: the API key, the webhook signing secret, and the store's identity.");
194
+ export type PaymentsLemonSqueezyCredentials = z.infer<typeof PaymentsLemonSqueezyCredentials>;
195
+
196
+ export const PaymentsPaddleCredentials = z
197
+ .strictObject({
198
+ apiKey: z
199
+ .string()
200
+ .min(1)
201
+ .describe(
202
+ "The Paddle API key — `pdl_live_apikey_…` or `pdl_sdbx_apikey_…`. Creates transactions and discounts, reads subscriptions and the event stream, and mints customer-portal sessions. It needs `customer_portal_session.write`, or Paddle returns a portal session with no authenticated URLs and the buyer lands on a sign-in page.",
203
+ ),
204
+ webhookSecret: z
205
+ .string()
206
+ .min(1)
207
+ .describe(
208
+ "The notification destination's signing secret — `pdl_ntfset_…`. What the `Paddle-Signature` HMAC-SHA256 over `ts:body` is checked against, and the key the checkout's ownership proof is domain-separated under.",
209
+ ),
210
+ })
211
+ .describe(
212
+ "Paddle's credentials: the API key, and the notification destination's signing secret. The client token is not here — it is publishable by design and lives in config, because putting it behind the secrets store would suggest verification depended on its secrecy.",
213
+ );
214
+ export type PaymentsPaddleCredentials = z.infer<typeof PaymentsPaddleCredentials>;
215
+
216
+ export const PaymentsProviderCredentials = z
217
+ .strictObject({
218
+ apple: PaymentsAppleCredentials.optional().describe("Apple's credentials, when the Apple rail is enabled."),
219
+ google: PaymentsGoogleCredentials.optional().describe("Google's credentials, when the Google rail is enabled."),
220
+ stripe: PaymentsStripeCredentials.optional().describe("Stripe's credentials, when the Stripe rail is enabled."),
221
+ lemonSqueezy: PaymentsLemonSqueezyCredentials.optional().describe(
222
+ "Lemon Squeezy's credentials, when that rail is enabled.",
223
+ ),
224
+ paddle: PaymentsPaddleCredentials.optional().describe("Paddle's credentials, when the Paddle rail is enabled."),
225
+ })
226
+ .describe(
227
+ "Every enabled rail's credentials, in one secret. A rail's block is present in full or absent entirely — adding a rail never reshapes storage.",
228
+ );
229
+ export type PaymentsProviderCredentials = z.infer<typeof PaymentsProviderCredentials>;
230
+
231
+ /** Payments' secret-registry slice, aggregated into the shared accessor at worker startup. */
232
+ export const paymentsSecretsRegistry = defineSecretRegistry({
233
+ [PAYMENTS_PROVIDER_SECRET]: {
234
+ // An encrypted row in the per-environment secrets D1 — where this bundle actually lives. No
235
+ // wrangler template binds it from the Cloudflare Secrets Store; `pithy payments provision` writes
236
+ // it through `dispatchSecretWrite` → the manager Workflow → `SystemSecretsStore`, the D1 path.
237
+ backend: "d1",
238
+ scope: "environment",
239
+ rotatable: true,
240
+ valueType: "json",
241
+ schema: PaymentsProviderCredentials,
242
+ // `obtained`, and it always will be: a rail's key authenticates against that rail, so a minted one
243
+ // authenticates against nothing and hides the real gap behind a filled-in field.
244
+ origin: { kind: "obtained", issuer: "other", documentation: PAYMENTS_CREDENTIALS_PAGE },
245
+ // `manual`, and note it does not follow from `rotatable: true`. `rotatable` says the store may hold
246
+ // two live versions of this bundle at once — which it must, because a Stripe webhook signed under
247
+ // the old secret can arrive after the new one is written. Who performs the replacement is a human in
248
+ // a console, whatever the store can hold while they do it.
249
+ rotation: { kind: "manual", issuer: "other", documentation: PAYMENTS_CREDENTIALS_PAGE },
250
+ },
251
+ });
252
+
253
+ /**
254
+ * The credentials for one rail, or a refusal.
255
+ *
256
+ * A rail enabled in `pithy.config.ts` whose credentials were never provisioned is a 404, not a 500: from the
257
+ * caller's side that payment method genuinely is not available here, and telling a client which of config or
258
+ * provisioning is missing tells it about our deployment. `detail` carries the distinction for the operator.
259
+ *
260
+ * `action` carries the console. Which rail is missing is known here, so the one page out of five that
261
+ * answers "where do I get one" is known here too — and this is the moment the question is being asked.
262
+ */
263
+ export function railCredentials<R extends PaymentsRail>(
264
+ credentials: PaymentsProviderCredentials,
265
+ rail: R,
266
+ ): NonNullable<PaymentsProviderCredentials[R]> {
267
+ const block = credentials[rail];
268
+ if (!block) {
269
+ throw new PaymentsRailNotConfiguredError({
270
+ detail: `The ${rail} rail has no credentials in "${PAYMENTS_PROVIDER_SECRET}" for this environment. Run \`pithy secrets set\` for it.`,
271
+ action: `Take the ${rail} credentials from ${PAYMENTS_RAIL_CONSOLES[rail]} and set them with \`pithy secrets set ${PAYMENTS_PROVIDER_SECRET}\`.`,
272
+ });
273
+ }
274
+ return block as NonNullable<PaymentsProviderCredentials[R]>;
275
+ }
@@ -0,0 +1,181 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { EXAMPLE_ADA, EXAMPLE_ALAN, EXAMPLE_GRACE } from "@pithy-sh/core/src/seed/exampleIdentities";
5
+ import { d1SeedGroup, defineSeed, type SeedSet } from "@pithy-sh/core/src/seed/seed";
6
+ import { PaymentsEntitlement } from "../data/entitlement";
7
+ import { PaymentsPurchase } from "../data/purchase";
8
+ import { PAYMENTS_ENTITLEMENTS_TABLE, PAYMENTS_PURCHASES_TABLE } from "../data/tables";
9
+
10
+ /**
11
+ * Three purchases and the entitlements they derive, for the canonical example cast.
12
+ *
13
+ * The set is spread deliberately rather than repeated: **Ada** holds a live Apple subscription, **Grace**
14
+ * a Stripe non-consumable she owns forever, **Alan** a refunded Google consumable. That is one row per
15
+ * rail, one per product type, and the three purchase states anything reading this table has to handle —
16
+ * so a fresh `dev` backend can render a real subscription screen, a real receipt, and a real refund
17
+ * without anybody authoring a fixture first.
18
+ *
19
+ * The users are the shared cast from `@pithy-sh/core`, so these purchases belong to the same people
20
+ * `auth` seeds and `ledger` opens balances for. Order 250 puts them after auth's users (100), exactly the
21
+ * way the migration registry encodes a dependency.
22
+ *
23
+ * **Every row is a `user` subject, and a project on `billingSubject: "organization"` gets rows that
24
+ * resolve for nobody.** That is honest rather than a bug. The shared cast is three people; the kit has no
25
+ * example organization, and inventing one here would mint an id nothing else joins to — no `auth` row, no
26
+ * membership, nothing the adopter's own subject seam would ever return. So the fixture shows the shape of
27
+ * the tables and the states a reader must handle, and under organization billing the only real subjects
28
+ * are the adopter's own. Pinning it to `EXAMPLE_ADA/GRACE/ALAN` is what keeps the set idempotent and
29
+ * connected; a fabricated organization would be neither.
30
+ *
31
+ * **Everything is fixed — the ids and the clock.** `pithy seed` is `INSERT OR IGNORE`, so a generated
32
+ * UUID would give Ada a second subscription on every run. A fixed `CREATED_AT` does the same job for the
33
+ * timestamps.
34
+ *
35
+ * **No payloads worth the name.** The `payload` column really holds a verified provider response, and a
36
+ * real one is a bearer artifact. A committed fixture gets an identifier and nothing else.
37
+ *
38
+ * Composed in only when the project turns on `seed.includeExamples`, and only for `dev` and `staging` —
39
+ * an example fixture never targets production, whatever that setting says.
40
+ */
41
+
42
+ /** Where this set sorts in the whole project's seed registry. After auth (100), which owns these users. */
43
+ const PAYMENTS_EXAMPLE_SEED_ORDER = 250;
44
+
45
+ /** Fixed ids, so the fixture is idempotent and an entitlement's provenance cannot drift off its purchase. */
46
+ const PRO_PURCHASE_ID = "b4e1f2a0-6c3d-4f18-9a52-1d7e8c0b3f41";
47
+ const ADS_PURCHASE_ID = "c5f2a3b1-7d4e-4a29-8b63-2e8f9d1c4a52";
48
+ const COINS_PURCHASE_ID = "d6a3b4c2-8e5f-4b3a-9c74-3f9a0e2d5b63";
49
+
50
+ const PRO_ENTITLEMENT_ID = "e7b4c5d3-9f60-4c4b-8d85-4a0b1f3e6c74";
51
+ const ADS_ENTITLEMENT_ID = "f8c5d6e4-a071-4d5c-9e96-5b1c2a4f7d85";
52
+
53
+ /** One fixed moment every row was written at. The demo is about state, not about a calendar. */
54
+ const CREATED_AT = new Date("2026-01-01T00:00:00.000Z");
55
+
56
+ /**
57
+ * When Ada's subscription runs to.
58
+ *
59
+ * Deliberately far out rather than `now + 30 days`. A computed date would make the fixture depend on when
60
+ * it ran, which breaks the one property that matters — that re-seeding writes identical rows — and the
61
+ * read path evaluates `expiresAt` on every request, so a date in the past would quietly turn the live
62
+ * subscription into a lapsed one the day after somebody seeded it.
63
+ */
64
+ const RENEWS_AT = new Date("2099-01-01T00:00:00.000Z");
65
+
66
+ /** When Alan asked for his money back. */
67
+ const REFUNDED_AT = new Date("2026-01-08T00:00:00.000Z");
68
+
69
+ export const paymentsExampleSeed: SeedSet = defineSeed({
70
+ name: "example",
71
+ order: PAYMENTS_EXAMPLE_SEED_ORDER,
72
+ environments: ["dev", "staging"],
73
+ example: true,
74
+ d1: [
75
+ d1SeedGroup("app", PAYMENTS_PURCHASES_TABLE, PaymentsPurchase, [
76
+ {
77
+ id: PRO_PURCHASE_ID,
78
+ subjectType: "user",
79
+ subjectId: EXAMPLE_ADA.id,
80
+ rail: "apple",
81
+ role: "charge",
82
+ providerTransactionId: "2000000512345678",
83
+ productId: "pro_monthly",
84
+ providerProductId: "com.example.pro.monthly",
85
+ type: "subscription",
86
+ status: "active",
87
+ // Sandbox, because a seeded environment is dev or staging and a purchase carries where it happened.
88
+ environment: "sandbox",
89
+ purchasedAt: CREATED_AT,
90
+ expiresAt: RENEWS_AT,
91
+ revokedAt: null,
92
+ resumesAt: null,
93
+ // A renewal chains back to the transaction that started the subscription; this is that one.
94
+ originalTransactionId: "2000000512345678",
95
+ amountMinor: 999,
96
+ currency: "USD",
97
+ providerEventAt: CREATED_AT,
98
+ payload: { transactionId: "2000000512345678", type: "Auto-Renewable Subscription" },
99
+ createdAt: CREATED_AT,
100
+ updatedAt: CREATED_AT,
101
+ },
102
+ {
103
+ id: ADS_PURCHASE_ID,
104
+ subjectType: "user",
105
+ subjectId: EXAMPLE_GRACE.id,
106
+ rail: "stripe",
107
+ role: "charge",
108
+ providerTransactionId: "pi_3ExampleRemoveAds",
109
+ productId: "remove_ads",
110
+ providerProductId: "price_example_remove_ads",
111
+ type: "non_consumable",
112
+ status: "active",
113
+ environment: "sandbox",
114
+ purchasedAt: CREATED_AT,
115
+ // Owned forever. Null is what makes the read path stop checking a clock for this one.
116
+ expiresAt: null,
117
+ revokedAt: null,
118
+ resumesAt: null,
119
+ originalTransactionId: null,
120
+ amountMinor: 299,
121
+ currency: "USD",
122
+ providerEventAt: CREATED_AT,
123
+ payload: { id: "pi_3ExampleRemoveAds", object: "payment_intent" },
124
+ createdAt: CREATED_AT,
125
+ updatedAt: CREATED_AT,
126
+ },
127
+ {
128
+ id: COINS_PURCHASE_ID,
129
+ subjectType: "user",
130
+ subjectId: EXAMPLE_ALAN.id,
131
+ rail: "google",
132
+ role: "charge",
133
+ providerTransactionId: "GPA.3300-0000-0000-00000",
134
+ productId: "coins_100",
135
+ providerProductId: "coins_100",
136
+ type: "consumable",
137
+ status: "refunded",
138
+ environment: "sandbox",
139
+ purchasedAt: CREATED_AT,
140
+ expiresAt: null,
141
+ revokedAt: REFUNDED_AT,
142
+ resumesAt: null,
143
+ originalTransactionId: null,
144
+ amountMinor: 199,
145
+ currency: "USD",
146
+ providerEventAt: REFUNDED_AT,
147
+ payload: { orderId: "GPA.3300-0000-0000-00000", purchaseState: 0 },
148
+ createdAt: CREATED_AT,
149
+ updatedAt: REFUNDED_AT,
150
+ },
151
+ ]),
152
+ d1SeedGroup("app", PAYMENTS_ENTITLEMENTS_TABLE, PaymentsEntitlement, [
153
+ {
154
+ id: PRO_ENTITLEMENT_ID,
155
+ subjectType: "user",
156
+ subjectId: EXAMPLE_ADA.id,
157
+ entitlement: "pro",
158
+ active: true,
159
+ expiresAt: RENEWS_AT,
160
+ sourcePurchaseId: PRO_PURCHASE_ID,
161
+ manual: false,
162
+ createdAt: CREATED_AT,
163
+ updatedAt: CREATED_AT,
164
+ },
165
+ {
166
+ id: ADS_ENTITLEMENT_ID,
167
+ subjectType: "user",
168
+ subjectId: EXAMPLE_GRACE.id,
169
+ entitlement: "ads_removed",
170
+ active: true,
171
+ expiresAt: null,
172
+ sourcePurchaseId: ADS_PURCHASE_ID,
173
+ manual: false,
174
+ createdAt: CREATED_AT,
175
+ updatedAt: CREATED_AT,
176
+ },
177
+ // Alan gets no row, and that is the derivation rather than an omission: a consumable credits a
178
+ // balance, so a refunded one leaves nothing in the read model to take away.
179
+ ]),
180
+ ],
181
+ });
@@ -0,0 +1,16 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ // GENERATED by scripts/stampVersions.ts — do not edit by hand. Regenerate with `bun run stamp-versions`.
5
+ //
6
+ // A Worker cannot read its own package.json, so this is how @pithy-sh/payments knows its own version at
7
+ // runtime. The capability attaches it, and `GET /control-plane/manifest` reports it per capability —
8
+ // which is what answers "should this project upgrade" and "is this customer exposed to what we just
9
+ // fixed". Those questions are only answerable per module, because a project composes some capabilities
10
+ // and not others.
11
+
12
+ /** This package's npm name — the join key against a release feed. */
13
+ export const PACKAGE_NAME = "@pithy-sh/payments";
14
+
15
+ /** This package's version, stamped from its own package.json at generation time. */
16
+ export const PACKAGE_VERSION = "0.1.0";