@pithy-sh/payments 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +157 -0
- package/dist/paddle-prices.iife.js +1 -0
- package/dist/pithy.manifest.json +68 -0
- package/docs/lemon-squeezy.md +167 -0
- package/docs/paddle.md +486 -0
- package/package.json +73 -0
- package/pithy.manifest.json +68 -0
- package/src/admin/coverage.ts +107 -0
- package/src/admin/read.ts +360 -0
- package/src/audit/actions.ts +214 -0
- package/src/capability.ts +377 -0
- package/src/client/api.ts +734 -0
- package/src/client/checkout.ts +252 -0
- package/src/client/fixtures/price-preview-de.json +54 -0
- package/src/client/fixtures/price-preview-gb.json +54 -0
- package/src/client/fixtures/price-preview-jp-yen.json +54 -0
- package/src/client/fixtures/price-preview-us-country-only.json +54 -0
- package/src/client/fixtures/price-preview-us-new-york.json +54 -0
- package/src/client/fixtures/pricePreview.ts +67 -0
- package/src/client/hooks.ts +590 -0
- package/src/client/paddle.ts +733 -0
- package/src/client/paddleCache.ts +265 -0
- package/src/client/paddlePrices.iife.ts +43 -0
- package/src/client/paddlePrices.ts +143 -0
- package/src/client/paddlePricesTag.ts +332 -0
- package/src/client/projection.ts +133 -0
- package/src/client/test-utils/cacheStore.ts +41 -0
- package/src/client/wholeUnits.ts +147 -0
- package/src/cloudflare-test.d.ts +16 -0
- package/src/config/config.ts +563 -0
- package/src/data/discount.ts +238 -0
- package/src/data/entitlement.ts +67 -0
- package/src/data/money.ts +75 -0
- package/src/data/pause.ts +148 -0
- package/src/data/providerAccount.ts +47 -0
- package/src/data/purchase.ts +131 -0
- package/src/data/rail.ts +74 -0
- package/src/data/reconcileRun.ts +208 -0
- package/src/data/renderMoney.ts +180 -0
- package/src/data/status.ts +67 -0
- package/src/data/subject.ts +148 -0
- package/src/data/subscription.ts +675 -0
- package/src/data/syncCursor.ts +44 -0
- package/src/data/tables.ts +61 -0
- package/src/data/webhookEvent.ts +196 -0
- package/src/entitlement/manual.ts +209 -0
- package/src/entitlement/resolver.ts +125 -0
- package/src/entitlement/subjectSeam.ts +171 -0
- package/src/error/errors.ts +421 -0
- package/src/grants/apply.ts +207 -0
- package/src/grants/clawback.ts +128 -0
- package/src/grants/ledgerSeam.ts +141 -0
- package/src/http/dispatch.ts +48 -0
- package/src/http/guards.ts +58 -0
- package/src/http/manifestConfig.ts +56 -0
- package/src/http/responses.ts +1147 -0
- package/src/http/routes.ts +2911 -0
- package/src/http/schemas.ts +577 -0
- package/src/http/scopes.ts +260 -0
- package/src/http/view.ts +188 -0
- package/src/http/webhookGuard.ts +341 -0
- package/src/index.ts +231 -0
- package/src/migrations/0001_purchases.ts +312 -0
- package/src/pricing/location.ts +175 -0
- package/src/pricing/visitor.ts +82 -0
- package/src/projection/event.ts +101 -0
- package/src/projection/orphans.ts +226 -0
- package/src/projection/owner.ts +247 -0
- package/src/projection/resolve.ts +57 -0
- package/src/projection/writer.ts +554 -0
- package/src/provision/resolvePaymentsConfig.ts +91 -0
- package/src/rails/apple/certs.ts +66 -0
- package/src/rails/apple/der.ts +249 -0
- package/src/rails/apple/fixtures/chain.ts +412 -0
- package/src/rails/apple/fixtures/did-fail-to-renew-grace.json +43 -0
- package/src/rails/apple/fixtures/did-renew.json +48 -0
- package/src/rails/apple/fixtures/one-time-charge.json +32 -0
- package/src/rails/apple/fixtures/refund.json +33 -0
- package/src/rails/apple/fixtures/subscribed-initial-buy-sandbox.json +38 -0
- package/src/rails/apple/fixtures/test.json +14 -0
- package/src/rails/apple/http.ts +128 -0
- package/src/rails/apple/jws.ts +123 -0
- package/src/rails/apple/notification.ts +357 -0
- package/src/rails/apple/rail.ts +114 -0
- package/src/rails/apple/serverApi.ts +412 -0
- package/src/rails/apple/verify.ts +93 -0
- package/src/rails/apple/x509.ts +424 -0
- package/src/rails/contract.ts +939 -0
- package/src/rails/google/fixtures/play-product-purchased.json +14 -0
- package/src/rails/google/fixtures/play-subscription-active.json +23 -0
- package/src/rails/google/fixtures/push.ts +152 -0
- package/src/rails/google/fixtures/rtdn-one-time-purchased.json +11 -0
- package/src/rails/google/fixtures/rtdn-subscription-renewed.json +11 -0
- package/src/rails/google/fixtures/rtdn-subscription-revoked.json +11 -0
- package/src/rails/google/fixtures/rtdn-test.json +8 -0
- package/src/rails/google/fixtures/rtdn-voided-purchase.json +11 -0
- package/src/rails/google/http.ts +124 -0
- package/src/rails/google/jwt.ts +127 -0
- package/src/rails/google/oidc.ts +308 -0
- package/src/rails/google/playApi.ts +598 -0
- package/src/rails/google/rail.ts +174 -0
- package/src/rails/google/rtdn.ts +331 -0
- package/src/rails/google/verify.ts +127 -0
- package/src/rails/lemonSqueezy/api.ts +192 -0
- package/src/rails/lemonSqueezy/checkout.ts +186 -0
- package/src/rails/lemonSqueezy/discounts.ts +166 -0
- package/src/rails/lemonSqueezy/fixtures/events.ts +180 -0
- package/src/rails/lemonSqueezy/objects.ts +511 -0
- package/src/rails/lemonSqueezy/portal.ts +82 -0
- package/src/rails/lemonSqueezy/pricing.ts +124 -0
- package/src/rails/lemonSqueezy/rail.ts +137 -0
- package/src/rails/lemonSqueezy/read.ts +72 -0
- package/src/rails/lemonSqueezy/refresh.ts +86 -0
- package/src/rails/lemonSqueezy/signature.ts +119 -0
- package/src/rails/lemonSqueezy/verify.ts +45 -0
- package/src/rails/lemonSqueezy/webhook.ts +326 -0
- package/src/rails/paddle/adjustments.ts +94 -0
- package/src/rails/paddle/api.ts +266 -0
- package/src/rails/paddle/checkout.ts +240 -0
- package/src/rails/paddle/discounts.ts +176 -0
- package/src/rails/paddle/events.ts +285 -0
- package/src/rails/paddle/fixtures/browserForged.ts +54 -0
- package/src/rails/paddle/fixtures/pausedSubscription.ts +62 -0
- package/src/rails/paddle/objects.ts +883 -0
- package/src/rails/paddle/portal.ts +130 -0
- package/src/rails/paddle/rail.ts +219 -0
- package/src/rails/paddle/read.ts +108 -0
- package/src/rails/paddle/recorded.ts +83 -0
- package/src/rails/paddle/refresh.ts +153 -0
- package/src/rails/paddle/refund.ts +297 -0
- package/src/rails/paddle/signature.ts +225 -0
- package/src/rails/paddle/subscription.ts +793 -0
- package/src/rails/paddle/verify.ts +120 -0
- package/src/rails/paddle/webhook.ts +405 -0
- package/src/rails/providers.ts +168 -0
- package/src/rails/stripe/api.ts +253 -0
- package/src/rails/stripe/checkout.ts +175 -0
- package/src/rails/stripe/discounts.ts +169 -0
- package/src/rails/stripe/fixtures/event-charge-refunded.json +23 -0
- package/src/rails/stripe/fixtures/event-invoice-paid.json +19 -0
- package/src/rails/stripe/fixtures/event-session-completed-payment.json +27 -0
- package/src/rails/stripe/fixtures/event-session-completed-subscription.json +27 -0
- package/src/rails/stripe/fixtures/event-subscription-canceled.json +43 -0
- package/src/rails/stripe/fixtures/event-subscription-created.json +42 -0
- package/src/rails/stripe/fixtures/event-subscription-deleted.json +44 -0
- package/src/rails/stripe/fixtures/events.ts +111 -0
- package/src/rails/stripe/objects.ts +642 -0
- package/src/rails/stripe/portal.ts +45 -0
- package/src/rails/stripe/pricing.ts +123 -0
- package/src/rails/stripe/rail.ts +139 -0
- package/src/rails/stripe/refresh.ts +79 -0
- package/src/rails/stripe/signature.ts +127 -0
- package/src/rails/stripe/verify.ts +105 -0
- package/src/rails/stripe/webhook.ts +79 -0
- package/src/secret/registry.ts +275 -0
- package/src/seeds/example.ts +181 -0
- package/src/version.generated.ts +16 -0
- package/src/workflows/paddleSweep.ts +717 -0
- package/src/workflows/railAccess.ts +87 -0
- package/src/workflows/reconcile.ts +648 -0
- package/src/workflows/report.ts +64 -0
- package/src/workflows/retryPolicy.ts +40 -0
- package/src/workflows/specs.ts +143 -0
- package/src/workflows/worker.ts +166 -0
- package/src/workflows/workerConfig.ts +73 -0
- package/src/workflows/wrangler.jsonc +55 -0
|
@@ -0,0 +1,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";
|