@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,341 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { D1Database } from "@cloudflare/workers-types";
|
|
5
|
+
import type { PithyHonoEnv } from "@pithy-sh/core/src/capability/capability";
|
|
6
|
+
import { safeEmit } from "@pithy-sh/core/src/controlPlane/audit/actions";
|
|
7
|
+
import { withD1Retry } from "@pithy-sh/core/src/data/withD1Retry";
|
|
8
|
+
import { InternalError, PithyError } from "@pithy-sh/core/src/error/pithyError";
|
|
9
|
+
import type { SecretsStoreEnv } from "@pithy-sh/secrets/src/env/bindings";
|
|
10
|
+
import { sharedSecretsStore } from "@pithy-sh/secrets/src/sharedSecretsStore";
|
|
11
|
+
import type { Context, MiddlewareHandler } from "hono";
|
|
12
|
+
import { PaymentsAuditActions } from "../audit/actions";
|
|
13
|
+
import type { PaymentsConfig } from "../config/config";
|
|
14
|
+
import type { PaymentsRail } from "../data/rail";
|
|
15
|
+
import { PAYMENTS_WEBHOOK_EVENTS_TABLE, paymentsDatabase } from "../data/tables";
|
|
16
|
+
import { isWebhookEventFinished, PaymentsWebhookEvent, webhookEventState } from "../data/webhookEvent";
|
|
17
|
+
import { PaymentsWebhookUnverifiedError } from "../error/errors";
|
|
18
|
+
import type { VerifiedNotification } from "../rails/contract";
|
|
19
|
+
import { type RailTrustOptions, resolveRailProvider } from "../rails/providers";
|
|
20
|
+
import { PAYMENTS_PROVIDER_SECRET, paymentsSecretsRegistry } from "../secret/registry";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The `signed-webhook` gate — the repo's first, and the shape the Google and Stripe webhooks will reuse.
|
|
24
|
+
*
|
|
25
|
+
* ## Why it reads the body itself
|
|
26
|
+
*
|
|
27
|
+
* All three rails prove authenticity over the **exact received bytes**: Apple signs them, Stripe HMACs them,
|
|
28
|
+
* Google signs a token alongside them. A parsed-and-re-serialized object is different bytes — key order alone
|
|
29
|
+
* changes it — so the gate must see what arrived, not a reconstruction. That is why the Biome plugin bans
|
|
30
|
+
* `c.req.json()` under `src/http/**` but deliberately allows `c.req.text()`, `c.req.header()`, and
|
|
31
|
+
* `c.req.raw`: reading the body for a signature check is the sanctioned use, and this is the module that has
|
|
32
|
+
* it.
|
|
33
|
+
*
|
|
34
|
+
* `c.req.text()` rather than `c.req.raw.text()`, and that distinction matters. Hono caches its own body reads,
|
|
35
|
+
* so the route's `zValidator("json", …)` afterwards is served from the same cache and sees identical bytes.
|
|
36
|
+
* Reading `c.req.raw` directly would consume the stream and leave the validator with nothing.
|
|
37
|
+
*
|
|
38
|
+
* ## Why it persists before it hands off
|
|
39
|
+
*
|
|
40
|
+
* Every provider delivers at-least-once and retries, so a redelivery is expected rather than exceptional. The
|
|
41
|
+
* row goes in first, keyed `UNIQUE (rail, providerEventId)`, and a delivery that has already been **finished
|
|
42
|
+
* with** short-circuits with 200 instead of running again. Two things follow from that. A retry storm costs
|
|
43
|
+
* one insert instead of a projection. And "why didn't this renew" becomes answerable: `receivedAt` says
|
|
44
|
+
* whether the notification arrived at all, `processedAt` whether it was finished with, `abandonedAt` whether
|
|
45
|
+
* a repair pass gave up on it, and `error` why.
|
|
46
|
+
*
|
|
47
|
+
* **Finished, not seen.** The distinction is the whole of #337 and it is worth the sentence. A delivery that
|
|
48
|
+
* arrived and failed to project must be reprocessed by the next one — that is the only repair path this
|
|
49
|
+
* package has, because a provider's retry and an operator's replay both reuse the original event id. So the
|
|
50
|
+
* short-circuit reads {@link isWebhookEventFinished}, and only a row nothing is left to do with is answered
|
|
51
|
+
* as a duplicate.
|
|
52
|
+
*
|
|
53
|
+
* A forgery is never persisted. Verification comes first, so the table holds only notifications the store
|
|
54
|
+
* actually sent — otherwise anyone could fill it.
|
|
55
|
+
*
|
|
56
|
+
* ## Why the verified payload travels in a WeakMap
|
|
57
|
+
*
|
|
58
|
+
* The gate has already decoded the notification in order to verify it, and the handler needs that result. The
|
|
59
|
+
* options were a `ContextVariableMap` augmentation, which would put a payments-only variable on the types of
|
|
60
|
+
* every Hono app in an adopter's project including the ones that never compose payments; re-exporting an
|
|
61
|
+
* unverified decoder for the handler to call, which is a function whose only safe use is the one place it is
|
|
62
|
+
* called from; or this — a request-scoped map keyed on the `Request` object, read through an accessor that
|
|
63
|
+
* throws when the gate did not run. The last is the only one with no way to reach an unverified payload and no
|
|
64
|
+
* effect outside this package.
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Our own name for what refused a delivery, for the audit row's `metadata.step`.
|
|
69
|
+
*
|
|
70
|
+
* Derived from the rail's error **code**, never from its `detail` or a raw `Error.message`: a code is a value
|
|
71
|
+
* this package defined, where a message can carry text a sender influenced, and the trail must hold nothing a
|
|
72
|
+
* forger wrote. An unrecognized throw is `unknown` rather than its message, for the same reason.
|
|
73
|
+
*
|
|
74
|
+
* `detail` on the 401 still carries the full reason for the operator reading logs — this is the queryable
|
|
75
|
+
* half, and it is deliberately coarser.
|
|
76
|
+
*/
|
|
77
|
+
function failingStep(cause: unknown): string {
|
|
78
|
+
return cause instanceof PithyError ? cause.payload.code : "unknown";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The codes a rail may raise that describe our side rather than the sender's, and so keep their own status. */
|
|
82
|
+
const PASS_THROUGH_CODES: ReadonlySet<string> = new Set([
|
|
83
|
+
"payments/provider_unavailable",
|
|
84
|
+
"payments/rail_not_configured",
|
|
85
|
+
]);
|
|
86
|
+
|
|
87
|
+
/** A verified delivery, as the handler receives it: the notification, and the row recording it. */
|
|
88
|
+
export interface VerifiedWebhook {
|
|
89
|
+
/** Which rail delivered it. */
|
|
90
|
+
rail: PaymentsRail;
|
|
91
|
+
/** The verified, parsed notification. `event` is null when it reports no transaction state. */
|
|
92
|
+
notification: VerifiedNotification;
|
|
93
|
+
/** The `pithy_payments_webhook_events` row id, so the handler can mark it processed or failed. */
|
|
94
|
+
eventRowId: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Request-scoped, keyed on the `Request` object so an entry cannot outlive its request and cannot be reached
|
|
99
|
+
* from another one. Weak, so a dropped request drops its entry with no bookkeeping.
|
|
100
|
+
*/
|
|
101
|
+
const verified = new WeakMap<Request, VerifiedWebhook>();
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The verified delivery for this request. Throws when the gate did not run, because a handler reading an
|
|
105
|
+
* absent verification would be a handler processing an unverified notification.
|
|
106
|
+
*/
|
|
107
|
+
export function verifiedWebhook(c: Context<PithyHonoEnv>): VerifiedWebhook {
|
|
108
|
+
const found = verified.get(c.req.raw);
|
|
109
|
+
if (!found) {
|
|
110
|
+
throw new InternalError({
|
|
111
|
+
detail: "A payments webhook handler ran without its signed-webhook guard. Put the guard on the route line.",
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
return found;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** What the guard needs beyond the request: the catalog, and an injectable clock. */
|
|
118
|
+
export interface WebhookGuardOptions {
|
|
119
|
+
/** The resolved catalog. Decides whether the rail is enabled at all. */
|
|
120
|
+
config: PaymentsConfig;
|
|
121
|
+
/** The clock, for signature freshness and `receivedAt`. Injected so tests are deterministic. */
|
|
122
|
+
now?: () => Date;
|
|
123
|
+
/** Additional certificate roots, additive only. Absent in production. */
|
|
124
|
+
trust?: RailTrustOptions;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The app `DB` binding, or a wiring failure. */
|
|
128
|
+
function database(c: Context<PithyHonoEnv>): D1Database {
|
|
129
|
+
const binding = (c.env as Record<string, unknown>).DB as D1Database | undefined;
|
|
130
|
+
if (!binding) {
|
|
131
|
+
throw new InternalError({
|
|
132
|
+
message: "Payments is not configured.",
|
|
133
|
+
action: "Bind a D1 database named DB in wrangler.jsonc.",
|
|
134
|
+
detail: "Payments requires a `DB` D1 binding; none was present on env.",
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
return binding;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Verify, record, and de-duplicate one rail's notification.
|
|
142
|
+
*
|
|
143
|
+
* Every failure to establish authenticity becomes `payments/webhook_unverified` (401), whatever the rail threw
|
|
144
|
+
* underneath. The rails distinguish malformed from unverified for the client-submission path, where the
|
|
145
|
+
* distinction helps a developer; on a webhook it would only tell a forger how close it got. The original code
|
|
146
|
+
* rides in `detail`, which the HTTP codec strips.
|
|
147
|
+
*
|
|
148
|
+
* **Two codes pass through unchanged, because they are not the caller's failure.** A rail that cannot reach its
|
|
149
|
+
* store (`payments/provider_unavailable`) or cannot use its own credentials (`payments/rail_not_configured`) has
|
|
150
|
+
* not judged the delivery at all — Google's rail must call the Play Developer API to resolve a notification, and
|
|
151
|
+
* an outage there is ours, not the sender's. Reporting either as a failed signature would send an operator
|
|
152
|
+
* hunting for a rotated key while the real answer is in a status page, and would tell the audit trail a forgery
|
|
153
|
+
* arrived when none did. Both remain non-2xx, so the provider still redelivers.
|
|
154
|
+
*/
|
|
155
|
+
export function requireSignedWebhook(
|
|
156
|
+
rail: PaymentsRail,
|
|
157
|
+
options: WebhookGuardOptions,
|
|
158
|
+
): MiddlewareHandler<PithyHonoEnv> {
|
|
159
|
+
return async (c, next) => {
|
|
160
|
+
const now = options.now?.() ?? new Date();
|
|
161
|
+
|
|
162
|
+
// Read the credentials at the point of need, through the one reader. Never off `env.X`, never cached in a
|
|
163
|
+
// module variable, and never spread into a log or an audit payload.
|
|
164
|
+
const secrets = await sharedSecretsStore(c.env as unknown as SecretsStoreEnv, paymentsSecretsRegistry);
|
|
165
|
+
const provider = resolveRailProvider(
|
|
166
|
+
rail,
|
|
167
|
+
options.config,
|
|
168
|
+
secrets.get(PAYMENTS_PROVIDER_SECRET),
|
|
169
|
+
options.trust ?? {},
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
// The exact received bytes. Hono caches this read, so the route's json validator sees the same ones.
|
|
173
|
+
const body = await c.req.text();
|
|
174
|
+
|
|
175
|
+
let notification: VerifiedNotification;
|
|
176
|
+
try {
|
|
177
|
+
const deployment = (c.env as Record<string, unknown>).ENVIRONMENT;
|
|
178
|
+
notification = await provider.parseNotification(
|
|
179
|
+
{ body, headers: c.req.raw.headers },
|
|
180
|
+
{ now, deployment: typeof deployment === "string" && deployment !== "" ? deployment : undefined },
|
|
181
|
+
);
|
|
182
|
+
} catch (cause) {
|
|
183
|
+
// Not the sender's failure, so not the sender's error code. See the module doc.
|
|
184
|
+
if (cause instanceof PithyError && PASS_THROUGH_CODES.has(cause.payload.code)) throw cause;
|
|
185
|
+
|
|
186
|
+
// The one payments event that is about an attacker rather than about a customer. One rejection is
|
|
187
|
+
// noise; a run of them against one endpoint is somebody probing a payment rail, and that pattern is
|
|
188
|
+
// exactly what a trail is read for. Recorded through `safeEmit` because the 401 is already decided by
|
|
189
|
+
// the time we get here — an audit write that threw would hand a forger a 500 for a failing store and a
|
|
190
|
+
// 401 for a healthy one, which is both an availability bug and a signal it should not have.
|
|
191
|
+
await safeEmit(
|
|
192
|
+
c.var.emit,
|
|
193
|
+
{
|
|
194
|
+
action: PaymentsAuditActions.webhookUnverified,
|
|
195
|
+
outcome: "denied",
|
|
196
|
+
severity: "warning",
|
|
197
|
+
actorType: "service",
|
|
198
|
+
actorId: rail,
|
|
199
|
+
resourceType: "webhook",
|
|
200
|
+
resourceId: rail,
|
|
201
|
+
// The rail and the step that failed, and nothing the sender supplied verbatim — not the body, not
|
|
202
|
+
// the signature header, not an id it chose. The trail is queryable and long-lived, and a forger
|
|
203
|
+
// must not be able to write into it. The step is our own code's name for what it refused.
|
|
204
|
+
metadata: { rail, step: failingStep(cause) },
|
|
205
|
+
},
|
|
206
|
+
c.var.log,
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
throw new PaymentsWebhookUnverifiedError(
|
|
210
|
+
{
|
|
211
|
+
detail: `${rail}: ${cause instanceof PithyError ? `${cause.payload.code} — ${cause.payload.detail ?? cause.payload.message}` : "the notification could not be verified"}`,
|
|
212
|
+
},
|
|
213
|
+
{ cause },
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const d1 = database(c);
|
|
218
|
+
const db = paymentsDatabase(d1);
|
|
219
|
+
const row = PaymentsWebhookEvent.encode({
|
|
220
|
+
id: crypto.randomUUID(),
|
|
221
|
+
rail,
|
|
222
|
+
providerEventId: notification.providerEventId,
|
|
223
|
+
payload: notification.payload,
|
|
224
|
+
receivedAt: now,
|
|
225
|
+
processedAt: null,
|
|
226
|
+
error: null,
|
|
227
|
+
createdAt: now,
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// `DO NOTHING` rather than an upsert: the first delivery's record is the one worth keeping, and
|
|
231
|
+
// overwriting it would erase the `error` that explains why the first attempt failed.
|
|
232
|
+
await withD1Retry(() =>
|
|
233
|
+
db
|
|
234
|
+
.insertInto(PAYMENTS_WEBHOOK_EVENTS_TABLE)
|
|
235
|
+
// biome-ignore lint/suspicious/noExplicitAny: an encoded row; Kysely's insert type derives from z.input.
|
|
236
|
+
.values(row as any)
|
|
237
|
+
.onConflict((oc) => oc.columns(["rail", "providerEventId"]).doNothing())
|
|
238
|
+
.execute(),
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
const stored = await db
|
|
242
|
+
.selectFrom(PAYMENTS_WEBHOOK_EVENTS_TABLE)
|
|
243
|
+
.select(["id", "processedAt", "abandonedAt", "error"])
|
|
244
|
+
.where("rail", "=", rail)
|
|
245
|
+
.where("providerEventId", "=", notification.providerEventId)
|
|
246
|
+
.executeTakeFirst();
|
|
247
|
+
if (!stored) {
|
|
248
|
+
throw new InternalError({
|
|
249
|
+
detail: `${rail}: notification ${notification.providerEventId} was recorded but could not be read back.`,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Already **finished** — not merely already seen. 200 rather than an error: the store did nothing wrong,
|
|
254
|
+
// and answering non-2xx would make it retry a delivery that has already taken effect.
|
|
255
|
+
//
|
|
256
|
+
// A delivery that arrived and failed, and one a repair pass abandoned, both fall through to the handler
|
|
257
|
+
// and are processed again. That is the point of #337: those are the two rows whose purchase has *not*
|
|
258
|
+
// been projected, and short-circuiting them made a redelivery — and a manual replay, which reuses the
|
|
259
|
+
// provider's event id — the one thing that could never repair them.
|
|
260
|
+
if (isWebhookEventFinished(webhookEventState(stored))) {
|
|
261
|
+
return c.json({ received: true, duplicate: true }, 200);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
verified.set(c.req.raw, { rail, notification, eventRowId: stored.id });
|
|
265
|
+
await next();
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* What became of a verified delivery, as the handler reports it. **Three cases, because there are three.**
|
|
271
|
+
*
|
|
272
|
+
* `{ at }` — it projected. Finished.
|
|
273
|
+
*
|
|
274
|
+
* `{ at, note }` — it was never going to project, and here is why. The rail read the notification and said
|
|
275
|
+
* it carries no transaction state: a partial refund that takes nothing away, a token Play will not show us
|
|
276
|
+
* — its rail's own words are "the answer will not change" — a subscription Lemon Squeezy no longer knows, a
|
|
277
|
+
* type the store shipped after this package did. Also finished. **A later delivery of the same bytes gets
|
|
278
|
+
* the same answer from the same build**, so the note is an explanation attached to a finished row, not a
|
|
279
|
+
* failure.
|
|
280
|
+
*
|
|
281
|
+
* `{ at, error }` — it arrived carrying a purchase and did not project, and a later attempt still could.
|
|
282
|
+
* The orphan whose account link has not arrived, the void naming a purchase not yet submitted, the SKU not
|
|
283
|
+
* yet in the catalog. `processedAt` stays null so the redelivery, the replay, or the sweep runs it again.
|
|
284
|
+
*
|
|
285
|
+
* **Why `note` and `error` are separate when both land in the same column.** #337 made the presence of a
|
|
286
|
+
* reason the discriminant, which split one state in two: a delivery with nothing to project was finished
|
|
287
|
+
* when the rail said nothing about it, and outstanding when the rail explained why. The *explanation* was
|
|
288
|
+
* the only difference, and an explanation must not be able to change a state.
|
|
289
|
+
*
|
|
290
|
+
* What that cost is the two things this column is read for. A null `processedAt` under an old `receivedAt`
|
|
291
|
+
* is this table's documented drift signal, and every explained delivery became one permanently. And the
|
|
292
|
+
* short-circuit is what makes a retry storm cost one insert instead of a projection — four of the five
|
|
293
|
+
* rails have no repair pass at all, so those rows never settled, and each of the store's retries ran the
|
|
294
|
+
* whole handler again.
|
|
295
|
+
*
|
|
296
|
+
* The three are a union rather than two optional fields so no call site can pass both and no reader has to
|
|
297
|
+
* decide what a row means that claims to be finished and failed at once. That contradiction was #337.
|
|
298
|
+
*/
|
|
299
|
+
export type WebhookCompletion =
|
|
300
|
+
| { readonly at: Date }
|
|
301
|
+
| { readonly at: Date; readonly note: string }
|
|
302
|
+
| { readonly at: Date; readonly error: string };
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Mark a recorded notification finished, or record why it is not.
|
|
306
|
+
*
|
|
307
|
+
* The outcome decides whether `processedAt` is written at all — see {@link WebhookCompletion}. Only the
|
|
308
|
+
* repairable case withholds it, and withholding it is what keeps the row reprocessable: the guard's
|
|
309
|
+
* short-circuit and the Paddle sweep's freshness check both read that column.
|
|
310
|
+
*
|
|
311
|
+
* That reverses what this function did before #337. Setting the timestamp beside an error read as "the
|
|
312
|
+
* notification *was* handled" — but the guard short-circuits on that column, so it also meant a failed
|
|
313
|
+
* delivery could never be repaired: the provider's retry was answered 200, a manual replay reuses the same
|
|
314
|
+
* event id and was answered 200, and the sweep skipped it. Nothing in this package repaired a failed
|
|
315
|
+
* delivery, on any rail. #339 is the other edge of the same cut: the fix withheld the timestamp from
|
|
316
|
+
* deliveries that had nothing to repair, and made a repair pass chase them.
|
|
317
|
+
*
|
|
318
|
+
* **`abandonedAt` is never touched here.** It belongs to the repair pass that wrote it, and a webhook that
|
|
319
|
+
* projects one of those rows sets `processedAt` — which wins, so the event reads finished while the row
|
|
320
|
+
* still records that a sweep once gave up on it.
|
|
321
|
+
*
|
|
322
|
+
* A genuine D1 failure propagates rather than being swallowed, and that is the right direction: the store
|
|
323
|
+
* would answer a 5xx by retrying, the projection is idempotent, and an unmarked row would otherwise leave a
|
|
324
|
+
* notification looking permanently unprocessed.
|
|
325
|
+
*/
|
|
326
|
+
export async function completeWebhook(d1: D1Database, eventRowId: string, outcome: WebhookCompletion): Promise<void> {
|
|
327
|
+
const repairable = "error" in outcome;
|
|
328
|
+
const db = paymentsDatabase(d1);
|
|
329
|
+
await withD1Retry(() =>
|
|
330
|
+
db
|
|
331
|
+
.updateTable(PAYMENTS_WEBHOOK_EVENTS_TABLE)
|
|
332
|
+
.set({
|
|
333
|
+
processedAt: repairable ? null : outcome.at.getTime(),
|
|
334
|
+
// One column: it is "why", and an operator reads it the same way whichever of the two it is.
|
|
335
|
+
error: "error" in outcome ? outcome.error : "note" in outcome ? outcome.note : null,
|
|
336
|
+
// biome-ignore lint/suspicious/noExplicitAny: encoded column values, not the app shape.
|
|
337
|
+
} as any)
|
|
338
|
+
.where("id", "=", eventRowId)
|
|
339
|
+
.execute(),
|
|
340
|
+
);
|
|
341
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The package entrypoint — the surface `pithy add payments` wires into `pithy.config.ts`. Deliberately
|
|
6
|
+
* narrow: the capability factory, its catalog types and lookups, the four table schemas, and the projection
|
|
7
|
+
* writer and read path other capabilities and the Workflow host call. Every other module is reached by deep
|
|
8
|
+
* path (`@pithy-sh/payments/src/...`); this is the documented contract, not a barrel over the package.
|
|
9
|
+
*
|
|
10
|
+
* The subject surface is here for one reason, and it is the narrowness rule rather than an exception to it:
|
|
11
|
+
* under `billingSubject: "organization"` an adopter **must** write a `resolveSubject`, and it is the only
|
|
12
|
+
* thing this package asks them to author. A function they cannot name the type of is a function they write
|
|
13
|
+
* against inference and get subtly wrong — so `PaymentsSubjectResolver`, the pair it returns, and the two
|
|
14
|
+
* helpers that answer the same question on a route (`resolvePaymentsSubject`, `requirePaymentsSubject`) sit
|
|
15
|
+
* beside the factory that takes it. `sameSubject` and the reference codec come with it because a resolver's
|
|
16
|
+
* output is compared and encoded by the adopter's own code as often as by ours.
|
|
17
|
+
*
|
|
18
|
+
* The client modules are **not** here, and must not be. `src/client/hooks.ts` imports `react`, and
|
|
19
|
+
* re-exporting it would drag React into every Worker bundle that composes payments; `src/client/api.ts` is
|
|
20
|
+
* its framework-free half and belongs to the same browser surface. Both are reached by their own deep
|
|
21
|
+
* path — `@pithy-sh/payments/src/client/hooks`.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export { type PaymentsAuditAction, PaymentsAuditActions } from "./audit/actions";
|
|
25
|
+
export {
|
|
26
|
+
isPaymentsCapability,
|
|
27
|
+
PAYMENTS_MIGRATION_ORDER,
|
|
28
|
+
type PaymentsCapability,
|
|
29
|
+
type PaymentsOptions,
|
|
30
|
+
payments,
|
|
31
|
+
} from "./capability";
|
|
32
|
+
export {
|
|
33
|
+
entitlementsForProduct,
|
|
34
|
+
grantableEntitlements,
|
|
35
|
+
PaymentsAppleProduct,
|
|
36
|
+
type PaymentsCatalogEntry,
|
|
37
|
+
PaymentsConfig,
|
|
38
|
+
type PaymentsConfigInput,
|
|
39
|
+
PaymentsGoogleProduct,
|
|
40
|
+
PaymentsGrants,
|
|
41
|
+
PaymentsLedgerGrant,
|
|
42
|
+
PaymentsLemonSqueezyProduct,
|
|
43
|
+
PaymentsLemonSqueezySettings,
|
|
44
|
+
PaymentsPaddleProduct,
|
|
45
|
+
PaymentsPaddleSettings,
|
|
46
|
+
type PaymentsPaddleSettingsInput,
|
|
47
|
+
PaymentsProduct,
|
|
48
|
+
type PaymentsProductInput,
|
|
49
|
+
PaymentsProductType,
|
|
50
|
+
PaymentsRailToggles,
|
|
51
|
+
type PaymentsRailTogglesInput,
|
|
52
|
+
PaymentsStripeProduct,
|
|
53
|
+
PaymentsStripeSettings,
|
|
54
|
+
type PaymentsStripeSettingsInput,
|
|
55
|
+
productForProviderSku,
|
|
56
|
+
providerProductId,
|
|
57
|
+
railEnabled,
|
|
58
|
+
resolveProduct,
|
|
59
|
+
} from "./config/config";
|
|
60
|
+
export { PaymentsEntitlement } from "./data/entitlement";
|
|
61
|
+
export { minorUnitDigits, minorUnitsFromScaled } from "./data/money";
|
|
62
|
+
export { PaymentsProviderAccount } from "./data/providerAccount";
|
|
63
|
+
export { PaymentsPurchase, PurchaseEnvironment } from "./data/purchase";
|
|
64
|
+
export { PAYMENTS_RAILS, PaymentsRail } from "./data/rail";
|
|
65
|
+
export {
|
|
66
|
+
PaymentsReconcileRun,
|
|
67
|
+
RECONCILE_RUN_RETENTION_DAYS,
|
|
68
|
+
type ReconcileRunInput,
|
|
69
|
+
recordReconcileRun,
|
|
70
|
+
} from "./data/reconcileRun";
|
|
71
|
+
export { ACCESS_GRANTING_STATUSES, grantingStatuses, PurchaseStatus, statusGrantsAccess } from "./data/status";
|
|
72
|
+
export {
|
|
73
|
+
decodeSubjectReference,
|
|
74
|
+
encodeSubjectReference,
|
|
75
|
+
PaymentsSubject,
|
|
76
|
+
PaymentsSubjectType,
|
|
77
|
+
sameSubject,
|
|
78
|
+
} from "./data/subject";
|
|
79
|
+
export {
|
|
80
|
+
PAYMENTS_ENTITLEMENTS_TABLE,
|
|
81
|
+
PAYMENTS_PROVIDER_ACCOUNTS_TABLE,
|
|
82
|
+
PAYMENTS_PURCHASES_TABLE,
|
|
83
|
+
PAYMENTS_RECONCILE_RUNS_TABLE,
|
|
84
|
+
PAYMENTS_WEBHOOK_EVENTS_TABLE,
|
|
85
|
+
type PaymentsDatabase,
|
|
86
|
+
paymentsDatabase,
|
|
87
|
+
paymentsTables,
|
|
88
|
+
} from "./data/tables";
|
|
89
|
+
export { PaymentsWebhookEvent, WEBHOOK_EVENT_ORPHANED, webhookEventAwaitsOwner } from "./data/webhookEvent";
|
|
90
|
+
export {
|
|
91
|
+
grantEntitlement,
|
|
92
|
+
type ManualEntitlementInput,
|
|
93
|
+
type ManualEntitlementOptions,
|
|
94
|
+
revokeEntitlement,
|
|
95
|
+
} from "./entitlement/manual";
|
|
96
|
+
export {
|
|
97
|
+
createPaymentsEntitlementResolver,
|
|
98
|
+
installEntitlementResolver,
|
|
99
|
+
PAYMENTS_ENTITLEMENT_PROVIDER,
|
|
100
|
+
} from "./entitlement/resolver";
|
|
101
|
+
export {
|
|
102
|
+
authenticatedUserSubject,
|
|
103
|
+
type PaymentsSubjectResolver,
|
|
104
|
+
type PaymentsSubjectSeam,
|
|
105
|
+
requirePaymentsSubject,
|
|
106
|
+
resolvePaymentsSubject,
|
|
107
|
+
} from "./entitlement/subjectSeam";
|
|
108
|
+
export {
|
|
109
|
+
type AppliedGrant,
|
|
110
|
+
applyGrants,
|
|
111
|
+
type FulfillmentReport,
|
|
112
|
+
type FulfillPurchaseOptions,
|
|
113
|
+
fulfillPurchase,
|
|
114
|
+
type GrantOptions,
|
|
115
|
+
grantRef,
|
|
116
|
+
ledgerGrantFor,
|
|
117
|
+
purchaseIsPaid,
|
|
118
|
+
} from "./grants/apply";
|
|
119
|
+
export { type ClawbackOutcome, clawbackGrants, clawbackRef } from "./grants/clawback";
|
|
120
|
+
export {
|
|
121
|
+
type OpenPaymentsLedgerOptions,
|
|
122
|
+
openPaymentsLedger,
|
|
123
|
+
type PaymentsLedger,
|
|
124
|
+
type PaymentsLedgerLoader,
|
|
125
|
+
} from "./grants/ledgerSeam";
|
|
126
|
+
export { PAYMENTS_RECONCILE_KEY, triggerPaymentsReconcile } from "./http/dispatch";
|
|
127
|
+
export { requireAuth } from "./http/guards";
|
|
128
|
+
export { type PaymentsRoutesOptions, registerPaymentsRoutes } from "./http/routes";
|
|
129
|
+
export {
|
|
130
|
+
AppleWebhookNotification,
|
|
131
|
+
CheckoutRequest,
|
|
132
|
+
EntitlementGrantRequest,
|
|
133
|
+
EntitlementRevokeRequest,
|
|
134
|
+
GoogleWebhookNotification,
|
|
135
|
+
PurchaseSubmission,
|
|
136
|
+
RestoreRequest,
|
|
137
|
+
StripeWebhookNotification,
|
|
138
|
+
} from "./http/schemas";
|
|
139
|
+
export {
|
|
140
|
+
PAYMENTS_CATALOG_READ_SCOPE,
|
|
141
|
+
PAYMENTS_CONTROL_PLANE_SCOPES,
|
|
142
|
+
PAYMENTS_ENTITLEMENT_GRANT_SCOPE,
|
|
143
|
+
PAYMENTS_ENTITLEMENT_REVOKE_SCOPE,
|
|
144
|
+
} from "./http/scopes";
|
|
145
|
+
export { ProviderEvent, type ProviderEventInput } from "./projection/event";
|
|
146
|
+
export {
|
|
147
|
+
ORPHAN_REPAIR_LIMIT,
|
|
148
|
+
type RepairedOrphans,
|
|
149
|
+
type RepairOrphansOptions,
|
|
150
|
+
repairOrphanedEvents,
|
|
151
|
+
} from "./projection/orphans";
|
|
152
|
+
export {
|
|
153
|
+
type LinkProviderAccountOptions,
|
|
154
|
+
linkProviderAccount,
|
|
155
|
+
type OwnerHints,
|
|
156
|
+
providerAccountForSubject,
|
|
157
|
+
resolveNotificationOwner,
|
|
158
|
+
} from "./projection/owner";
|
|
159
|
+
export { resolveEntitlements } from "./projection/resolve";
|
|
160
|
+
export { type ProjectPurchaseOptions, type PurchaseProjection, projectPurchase } from "./projection/writer";
|
|
161
|
+
export {
|
|
162
|
+
type PaymentsConfigParams,
|
|
163
|
+
paymentsWorkerName,
|
|
164
|
+
resolvePaymentsConfig,
|
|
165
|
+
} from "./provision/resolvePaymentsConfig";
|
|
166
|
+
export { APPLE_ROOT_CERTIFICATES } from "./rails/apple/certs";
|
|
167
|
+
export type { AppleHttpFetch, AppleHttpRequest, AppleHttpResponse } from "./rails/apple/http";
|
|
168
|
+
export {
|
|
169
|
+
APPLE_API_AUDIENCE,
|
|
170
|
+
APPLE_API_BASES,
|
|
171
|
+
AppleSubscriptionStatuses,
|
|
172
|
+
appleSubscriptionStatus,
|
|
173
|
+
mintAppleApiToken,
|
|
174
|
+
} from "./rails/apple/serverApi";
|
|
175
|
+
export {
|
|
176
|
+
type CheckoutHandoff,
|
|
177
|
+
type CheckoutRail,
|
|
178
|
+
type CheckoutSessionInput,
|
|
179
|
+
isCheckoutRail,
|
|
180
|
+
type NotificationNote,
|
|
181
|
+
noteIsRepairable,
|
|
182
|
+
noteText,
|
|
183
|
+
type PaymentsRailProvider,
|
|
184
|
+
type PortalHandoff,
|
|
185
|
+
type PortalSessionInput,
|
|
186
|
+
type PortalSubscriptionLinks,
|
|
187
|
+
type RailRequestContext,
|
|
188
|
+
type UnboundProviderEvent,
|
|
189
|
+
type VerifiedNotification,
|
|
190
|
+
type VerifiedPurchase,
|
|
191
|
+
type WebhookDelivery,
|
|
192
|
+
} from "./rails/contract";
|
|
193
|
+
export type { GoogleHttpFetch, GoogleHttpRequest, GoogleHttpResponse } from "./rails/google/http";
|
|
194
|
+
export { GOOGLE_JWKS_URL, GOOGLE_OIDC_ISSUERS, GoogleJwk, GoogleJwks } from "./rails/google/oidc";
|
|
195
|
+
export { implementedRails, type RailTrustOptions, resolveRailProvider } from "./rails/providers";
|
|
196
|
+
export {
|
|
197
|
+
STRIPE_API_VERSION,
|
|
198
|
+
type StripeHttpFetch,
|
|
199
|
+
type StripeHttpRequest,
|
|
200
|
+
type StripeHttpResponse,
|
|
201
|
+
} from "./rails/stripe/api";
|
|
202
|
+
export { STRIPE_METADATA_ACCOUNT_REFERENCE, STRIPE_METADATA_PRICE } from "./rails/stripe/objects";
|
|
203
|
+
export {
|
|
204
|
+
PAYMENTS_PROVIDER_SECRET,
|
|
205
|
+
PaymentsAppleCredentials,
|
|
206
|
+
PaymentsGoogleCredentials,
|
|
207
|
+
PaymentsProviderCredentials,
|
|
208
|
+
PaymentsStripeCredentials,
|
|
209
|
+
paymentsSecretsRegistry,
|
|
210
|
+
railCredentials,
|
|
211
|
+
} from "./secret/registry";
|
|
212
|
+
export { paymentsExampleSeed } from "./seeds/example";
|
|
213
|
+
export {
|
|
214
|
+
batchedRailAccess,
|
|
215
|
+
type RailAccessOptions,
|
|
216
|
+
type ReconcileRailAccess,
|
|
217
|
+
} from "./workflows/railAccess";
|
|
218
|
+
export {
|
|
219
|
+
type ReconcileDeps,
|
|
220
|
+
type ReconcileReport,
|
|
221
|
+
type ReconcileStep,
|
|
222
|
+
reconcilePayments,
|
|
223
|
+
} from "./workflows/reconcile";
|
|
224
|
+
export {
|
|
225
|
+
DEFAULT_EXPIRING_WITHIN_SECONDS,
|
|
226
|
+
DEFAULT_STALE_AFTER_SECONDS,
|
|
227
|
+
PAYMENTS_CAPABILITY,
|
|
228
|
+
PaymentsReconcileParams,
|
|
229
|
+
paymentsWorkflowRegistry,
|
|
230
|
+
paymentsWorkflows,
|
|
231
|
+
} from "./workflows/specs";
|