@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,312 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { type Kysely, sql } from "kysely";
|
|
5
|
+
import type { Migration } from "kysely/migration";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Payments' six tables: the purchase projection, the materialized entitlement read model, the
|
|
9
|
+
* provider-identity map, the raw webhook log, the reconciliation run log, and the sync cursors.
|
|
10
|
+
*
|
|
11
|
+
* camelCase identifiers; `CamelCasePlugin` snake-cases them in the DDL. `down` is the tested inverse.
|
|
12
|
+
*
|
|
13
|
+
* Two constraints carry the design. `UNIQUE (rail, providerTransactionId)` on purchases is what makes
|
|
14
|
+
* three write paths converge on one row — a replayed webhook violates it and the write becomes a
|
|
15
|
+
* no-op update rather than a second purchase. `UNIQUE (subjectType, subjectId, entitlement)` on
|
|
16
|
+
* entitlements is what makes the read model a read model: one row per subject per entitlement, whichever
|
|
17
|
+
* purchase currently grants it. Correctness lives in the schema, not in a hopeful application-level check
|
|
18
|
+
* a race could skip.
|
|
19
|
+
*
|
|
20
|
+
* The holder of a purchase is a **pair** — a kind and an id — and both columns are in that key, ahead of
|
|
21
|
+
* the entitlement, so the per-subject read is a covering prefix of it. Keying on the id alone would let an
|
|
22
|
+
* organization whose id equalled some user's read that user's grants, and nothing anywhere makes those two
|
|
23
|
+
* namespaces disjoint. The kind is closed at the database with a CHECK on all three subject-bearing
|
|
24
|
+
* tables, for the reason `environment` is: a value the whole schema is keyed on is not something to trust
|
|
25
|
+
* from whatever wrote the row.
|
|
26
|
+
*
|
|
27
|
+
* The indexes come in two families. Three serve the questions the capability asks of itself — a buyer's
|
|
28
|
+
* own purchases, the reconciliation sweep, the pending-delivery queue. Three serve the control-plane
|
|
29
|
+
* reads (#247), which ask a different question: the newest rows across every account. The purchases
|
|
30
|
+
* primary key is a **text UUID**, deliberately, so it is unique but not monotonic and is no use as a
|
|
31
|
+
* sort; without those three, every page of a purchases pane sorts a customer's entire order history to
|
|
32
|
+
* return twenty-five rows, and does it again for the next page. That is not a slow query so much as a
|
|
33
|
+
* defect shipped into other people's production databases, which is why the reads' indexes are part of
|
|
34
|
+
* the schema rather than a note about it.
|
|
35
|
+
*
|
|
36
|
+
* This is the whole payments schema, in one migration — see `CONTRIBUTING.md` §Migrations for why that
|
|
37
|
+
* is the shape while nothing is published, and what changes the day something is.
|
|
38
|
+
*/
|
|
39
|
+
export const payments_0001_purchases: Migration = {
|
|
40
|
+
up: async (db: Kysely<unknown>): Promise<void> => {
|
|
41
|
+
await db.schema
|
|
42
|
+
.createTable("pithyPaymentsPurchases")
|
|
43
|
+
// Text UUID: these surface in API responses, and sequential ids would leak order volume.
|
|
44
|
+
.addColumn("id", "text", (c) => c.primaryKey())
|
|
45
|
+
// The owner, as a pair. Adjacent and both not-null, because half a subject identifies nobody.
|
|
46
|
+
.addColumn("subjectType", "text", (c) => c.notNull())
|
|
47
|
+
.addColumn("subjectId", "text", (c) => c.notNull())
|
|
48
|
+
.addColumn("rail", "text", (c) => c.notNull())
|
|
49
|
+
.addColumn("providerTransactionId", "text", (c) => c.notNull())
|
|
50
|
+
.addColumn("productId", "text", (c) => c.notNull())
|
|
51
|
+
.addColumn("providerProductId", "text", (c) => c.notNull())
|
|
52
|
+
.addColumn("type", "text", (c) => c.notNull())
|
|
53
|
+
.addColumn("status", "text", (c) => c.notNull())
|
|
54
|
+
// Defaulted in SQL as well as in the Zod object: three of the four rails never mention it, and a
|
|
55
|
+
// row written by one of them must not depend on the writer remembering to set it.
|
|
56
|
+
.addColumn("role", "text", (c) => c.notNull().defaultTo("charge"))
|
|
57
|
+
.addColumn("environment", "text", (c) => c.notNull())
|
|
58
|
+
.addColumn("purchasedAt", "integer", (c) => c.notNull())
|
|
59
|
+
.addColumn("expiresAt", "integer")
|
|
60
|
+
.addColumn("revokedAt", "integer")
|
|
61
|
+
// When a paused subscription resumes, as the provider stated it. Nullable, because "paused
|
|
62
|
+
// indefinitely" is a real state on every rail that pauses at all — see `data/pause.ts`.
|
|
63
|
+
.addColumn("resumesAt", "integer")
|
|
64
|
+
.addColumn("originalTransactionId", "text")
|
|
65
|
+
.addColumn("amountMinor", "integer")
|
|
66
|
+
.addColumn("currency", "text")
|
|
67
|
+
.addColumn("providerEventAt", "integer", (c) => c.notNull())
|
|
68
|
+
.addColumn("payload", "text", (c) => c.notNull())
|
|
69
|
+
.addColumn("createdAt", "integer", (c) => c.notNull())
|
|
70
|
+
.addColumn("updatedAt", "integer", (c) => c.notNull())
|
|
71
|
+
// The idempotency guard all three write paths rely on. A replay's insert violates it, which routes
|
|
72
|
+
// the write into its `ON CONFLICT` branch, so one provider transaction is one row forever.
|
|
73
|
+
.addUniqueConstraint("pithyPaymentsPurchasesProviderIdx", ["rail", "providerTransactionId"])
|
|
74
|
+
// A sandbox transaction must never grant a production entitlement, so the value is constrained at
|
|
75
|
+
// the database rather than trusted from a provider payload.
|
|
76
|
+
.addCheckConstraint("pithyPaymentsPurchasesEnvironment", sql`environment in ('production', 'sandbox')`)
|
|
77
|
+
// Amounts are integer minor units, never floats, and never negative.
|
|
78
|
+
.addCheckConstraint("pithyPaymentsPurchasesAmount", sql`amount_minor is null or amount_minor >= 0`)
|
|
79
|
+
// A `state` row never fulfills a `grants` clause, so a typo here would credit a ledger for a
|
|
80
|
+
// subscription's standing. Constrained at the database for the same reason `environment` is.
|
|
81
|
+
.addCheckConstraint("pithyPaymentsPurchasesRole", sql`role in ('charge', 'state')`)
|
|
82
|
+
// A resume date only means anything on a paused row. Constrained at the database because it is what
|
|
83
|
+
// makes the column's null readable: on a paused row null is the provider saying "indefinitely", and
|
|
84
|
+
// everywhere else it is "not paused" — two facts a consumer has to tell apart, and a rail that wrote
|
|
85
|
+
// a renewal date or a period end into it would silently collapse them.
|
|
86
|
+
.addCheckConstraint("pithyPaymentsPurchasesResumes", sql`resumes_at is null or status = 'paused'`)
|
|
87
|
+
// The subject kind is closed, and closed here rather than only in Zod: every ownership check in the
|
|
88
|
+
// capability compares both halves, and a third spelling of "organization" would be a holder no gate
|
|
89
|
+
// ever matches. snake_case in the raw fragment — `CamelCasePlugin` does not reach inside one.
|
|
90
|
+
.addCheckConstraint("pithyPaymentsPurchasesSubjectType", sql`subject_type in ('user', 'organization')`)
|
|
91
|
+
.execute();
|
|
92
|
+
|
|
93
|
+
// The owner read: one subject's purchases, newest first. Both halves lead, in the order they are
|
|
94
|
+
// always known in — the kind comes from config, the id from the caller — so the index answers the
|
|
95
|
+
// whole question rather than filtering a kind out afterwards.
|
|
96
|
+
await db.schema
|
|
97
|
+
.createIndex("pithyPaymentsPurchasesOwnerIdx")
|
|
98
|
+
.on("pithyPaymentsPurchases")
|
|
99
|
+
.columns(["subjectType", "subjectId", "purchasedAt"])
|
|
100
|
+
.execute();
|
|
101
|
+
|
|
102
|
+
// The reconciliation read: subscriptions near expiry, oldest verification first.
|
|
103
|
+
await db.schema
|
|
104
|
+
.createIndex("pithyPaymentsPurchasesExpiryIdx")
|
|
105
|
+
.on("pithyPaymentsPurchases")
|
|
106
|
+
.columns(["status", "expiresAt"])
|
|
107
|
+
.execute();
|
|
108
|
+
|
|
109
|
+
// The purchase log, newest first: `GET {base}/admin/purchases`. The keyset resumes on
|
|
110
|
+
// `(purchasedAt, id)`, and `purchasedAt` leading is what makes the page a range scan the LIMIT can
|
|
111
|
+
// genuinely stop.
|
|
112
|
+
await db.schema
|
|
113
|
+
.createIndex("pithyPaymentsPurchasesPurchasedIdx")
|
|
114
|
+
.on("pithyPaymentsPurchases")
|
|
115
|
+
.columns(["purchasedAt", "id"])
|
|
116
|
+
.execute();
|
|
117
|
+
|
|
118
|
+
// The subscriptions listing: `GET {base}/admin/subscriptions`. `type` is an equality and
|
|
119
|
+
// `purchasedAt` is the ordering column, so the filtered page is a range scan of its own rather than a
|
|
120
|
+
// scan of the whole log looking for the rows that renew. A project selling mostly consumables is
|
|
121
|
+
// exactly the one where the difference is large.
|
|
122
|
+
await db.schema
|
|
123
|
+
.createIndex("pithyPaymentsPurchasesTypePurchasedIdx")
|
|
124
|
+
.on("pithyPaymentsPurchases")
|
|
125
|
+
.columns(["type", "purchasedAt", "id"])
|
|
126
|
+
.execute();
|
|
127
|
+
|
|
128
|
+
await db.schema
|
|
129
|
+
.createTable("pithyPaymentsEntitlements")
|
|
130
|
+
.addColumn("id", "text", (c) => c.primaryKey())
|
|
131
|
+
// The holder, as a pair, exactly as the purchase carries it.
|
|
132
|
+
.addColumn("subjectType", "text", (c) => c.notNull())
|
|
133
|
+
.addColumn("subjectId", "text", (c) => c.notNull())
|
|
134
|
+
.addColumn("entitlement", "text", (c) => c.notNull())
|
|
135
|
+
.addColumn("active", "integer", (c) => c.notNull().defaultTo(0))
|
|
136
|
+
.addColumn("expiresAt", "integer")
|
|
137
|
+
.addColumn("sourcePurchaseId", "text")
|
|
138
|
+
// A human's decision, held against the projection. Every other row here is derived from the purchases
|
|
139
|
+
// table on every write that touches its key, which is what keeps the read model honest — and what would
|
|
140
|
+
// otherwise erase a support comp the moment the user's next renewal arrived.
|
|
141
|
+
.addColumn("manual", "integer", (c) => c.notNull().defaultTo(0))
|
|
142
|
+
.addColumn("createdAt", "integer", (c) => c.notNull())
|
|
143
|
+
.addColumn("updatedAt", "integer", (c) => c.notNull())
|
|
144
|
+
// One row per subject per entitlement — the upsert conflict target that makes this a read model.
|
|
145
|
+
// Both subject columns lead the key: a user and an organization may hold the same key at once, and
|
|
146
|
+
// they are two rows, while one subject holding it twice is the thing this refuses.
|
|
147
|
+
.addUniqueConstraint("pithyPaymentsEntitlementsOwnerIdx", ["subjectType", "subjectId", "entitlement"])
|
|
148
|
+
.addCheckConstraint("pithyPaymentsEntitlementsActive", sql`active in (0, 1)`)
|
|
149
|
+
.addCheckConstraint("pithyPaymentsEntitlementsManual", sql`manual in (0, 1)`)
|
|
150
|
+
.addCheckConstraint("pithyPaymentsEntitlementsSubjectType", sql`subject_type in ('user', 'organization')`)
|
|
151
|
+
.execute();
|
|
152
|
+
|
|
153
|
+
// The entitlement listing: `GET {base}/admin/entitlements`. `createdAt` rather than `updatedAt`,
|
|
154
|
+
// because the projection re-derives every affected row on every purchase write — ordering on
|
|
155
|
+
// `updatedAt` would shuffle rows under a reader for reasons that have nothing to do with the grant.
|
|
156
|
+
// The per-holder read needs no index of its own: `UNIQUE (subjectType, subjectId, entitlement)`
|
|
157
|
+
// already serves it, because the subject columns lead.
|
|
158
|
+
await db.schema
|
|
159
|
+
.createIndex("pithyPaymentsEntitlementsCreatedIdx")
|
|
160
|
+
.on("pithyPaymentsEntitlements")
|
|
161
|
+
.columns(["createdAt", "id"])
|
|
162
|
+
.execute();
|
|
163
|
+
|
|
164
|
+
await db.schema
|
|
165
|
+
.createTable("pithyPaymentsProviderAccounts")
|
|
166
|
+
.addColumn("id", "text", (c) => c.primaryKey())
|
|
167
|
+
.addColumn("rail", "text", (c) => c.notNull())
|
|
168
|
+
.addColumn("providerAccountId", "text", (c) => c.notNull())
|
|
169
|
+
// What the identity maps back to: the subject pair, not a user id.
|
|
170
|
+
.addColumn("subjectType", "text", (c) => c.notNull())
|
|
171
|
+
.addColumn("subjectId", "text", (c) => c.notNull())
|
|
172
|
+
.addColumn("createdAt", "integer", (c) => c.notNull())
|
|
173
|
+
// A webhook arrives carrying `cus_123`, and names no holder of ours. This is the only mapping back, so it
|
|
174
|
+
// must be one-to-one per rail.
|
|
175
|
+
//
|
|
176
|
+
// **Deliberately not widened by the subject.** Adding the pair to this key would make a rebind a
|
|
177
|
+
// legal insert, and the second row would collect the first subject's renewals. One provider
|
|
178
|
+
// identity, one holder, until somebody deletes the row on purpose.
|
|
179
|
+
.addUniqueConstraint("pithyPaymentsProviderAccountsIdx", ["rail", "providerAccountId"])
|
|
180
|
+
.addCheckConstraint("pithyPaymentsProviderAccountsSubjectType", sql`subject_type in ('user', 'organization')`)
|
|
181
|
+
.execute();
|
|
182
|
+
|
|
183
|
+
await db.schema
|
|
184
|
+
.createTable("pithyPaymentsWebhookEvents")
|
|
185
|
+
.addColumn("id", "text", (c) => c.primaryKey())
|
|
186
|
+
.addColumn("rail", "text", (c) => c.notNull())
|
|
187
|
+
.addColumn("providerEventId", "text", (c) => c.notNull())
|
|
188
|
+
.addColumn("payload", "text", (c) => c.notNull())
|
|
189
|
+
.addColumn("receivedAt", "integer", (c) => c.notNull())
|
|
190
|
+
// When this delivery was **finished with** — projected, or deliberately nothing to project. The one
|
|
191
|
+
// column the webhook guard short-circuits on, so it says "we have finished this" and never "we have
|
|
192
|
+
// seen this". Three writers once disagreed about that and two of them stopped purchases from ever
|
|
193
|
+
// being projected (#337).
|
|
194
|
+
.addColumn("processedAt", "integer")
|
|
195
|
+
// When a repair pass gave up on this event so its stream could advance.
|
|
196
|
+
//
|
|
197
|
+
// **A second timestamp rather than a status column, and beside `processedAt` rather than replacing
|
|
198
|
+
// it.** Abandoning is the sweep's decision about its own progress; finishing is a fact about the
|
|
199
|
+
// purchase. Collapsing them into one column is what #337 was. A `status` enum would have made the
|
|
200
|
+
// writers exhaustive and left every reader free to spell out its own predicate — which is where the
|
|
201
|
+
// defect actually lived — so the state is derived in `data/webhookEvent.ts` and asked for by name,
|
|
202
|
+
// and the storage stays timestamps: they answer *when*, which is what this table is read for, and
|
|
203
|
+
// they cannot contradict each other the way an enum can contradict the timestamp beside it.
|
|
204
|
+
.addColumn("abandonedAt", "integer")
|
|
205
|
+
.addColumn("error", "text")
|
|
206
|
+
// How many times a repair pass has tried this event and failed.
|
|
207
|
+
//
|
|
208
|
+
// **Here rather than in a table of its own, because the count is a fact about this row's handling.**
|
|
209
|
+
// `processedAt` and `error` are already the other two, and the three are only ever read together:
|
|
210
|
+
// "it arrived, it was tried N times, here is why it still has not gone through". A side table would
|
|
211
|
+
// add a second row per event, a join for the one question anybody asks, and a way for the two to
|
|
212
|
+
// disagree about an event that exists in one and not the other. KV would add a second store with no
|
|
213
|
+
// transactional relationship to the row it counts. The count also has to survive exactly as long as
|
|
214
|
+
// the row does and no longer, which is what a column gets for free and every alternative has to
|
|
215
|
+
// arrange.
|
|
216
|
+
//
|
|
217
|
+
// Defaulted, so the webhook path — which does not retry and does not count — inserts as it always
|
|
218
|
+
// did.
|
|
219
|
+
.addColumn("attempts", "integer", (c) => c.notNull().defaultTo(0))
|
|
220
|
+
.addColumn("createdAt", "integer", (c) => c.notNull())
|
|
221
|
+
// Every provider delivers at-least-once and retries, so a redelivery is expected and must be
|
|
222
|
+
// recognized rather than reprocessed.
|
|
223
|
+
.addUniqueConstraint("pithyPaymentsWebhookEventsIdx", ["rail", "providerEventId"])
|
|
224
|
+
.execute();
|
|
225
|
+
|
|
226
|
+
// The "why didn't this renew" read: everything not yet finished with, oldest first — pending, failed,
|
|
227
|
+
// and abandoned alike, because all three are deliveries whose purchase has not been projected. That
|
|
228
|
+
// an abandoned event appears here is deliberate: it is exactly the row an operator has to be able to
|
|
229
|
+
// find, and `abandonedAt` beside it says which of the three it is.
|
|
230
|
+
await db.schema
|
|
231
|
+
.createIndex("pithyPaymentsWebhookEventsPendingIdx")
|
|
232
|
+
.on("pithyPaymentsWebhookEvents")
|
|
233
|
+
.columns(["processedAt", "receivedAt"])
|
|
234
|
+
.execute();
|
|
235
|
+
|
|
236
|
+
// One row per reconciliation pass. Counts, timestamps and enums — there is deliberately no column a
|
|
237
|
+
// provider payload could be written into, which is a stronger control than never selecting one.
|
|
238
|
+
await db.schema
|
|
239
|
+
.createTable("pithyPaymentsReconcileRuns")
|
|
240
|
+
.addColumn("id", "text", (c) => c.primaryKey())
|
|
241
|
+
.addColumn("startedAt", "integer", (c) => c.notNull())
|
|
242
|
+
.addColumn("finishedAt", "integer", (c) => c.notNull())
|
|
243
|
+
.addColumn("environment", "text", (c) => c.notNull())
|
|
244
|
+
// Null is the scheduled behavior — every enabled rail. A value means somebody narrowed the pass.
|
|
245
|
+
.addColumn("rail", "text")
|
|
246
|
+
.addColumn("pages", "integer", (c) => c.notNull().defaultTo(0))
|
|
247
|
+
.addColumn("scanned", "integer", (c) => c.notNull().defaultTo(0))
|
|
248
|
+
.addColumn("unchanged", "integer", (c) => c.notNull().defaultTo(0))
|
|
249
|
+
.addColumn("drifted", "integer", (c) => c.notNull().defaultTo(0))
|
|
250
|
+
.addColumn("superseded", "integer", (c) => c.notNull().defaultTo(0))
|
|
251
|
+
.addColumn("skipped", "integer", (c) => c.notNull().defaultTo(0))
|
|
252
|
+
.addColumn("failed", "integer", (c) => c.notNull().defaultTo(0))
|
|
253
|
+
.addColumn("truncated", "integer", (c) => c.notNull().defaultTo(0))
|
|
254
|
+
.addColumn("dryRun", "integer", (c) => c.notNull().defaultTo(0))
|
|
255
|
+
.addColumn("createdAt", "integer", (c) => c.notNull())
|
|
256
|
+
// Same rule the purchases table states: a sandbox pass and a production pass are facts about
|
|
257
|
+
// different money, and the database is where that is constrained rather than trusted.
|
|
258
|
+
.addCheckConstraint("pithyPaymentsReconcileRunsEnvironment", sql`environment in ('production', 'sandbox')`)
|
|
259
|
+
.addCheckConstraint("pithyPaymentsReconcileRunsTruncated", sql`truncated in (0, 1)`)
|
|
260
|
+
.addCheckConstraint("pithyPaymentsReconcileRunsDryRun", sql`dry_run in (0, 1)`)
|
|
261
|
+
// A tally cannot be negative, and a run that reported one is a bug in the pass rather than a fact.
|
|
262
|
+
.addCheckConstraint(
|
|
263
|
+
"pithyPaymentsReconcileRunsCounts",
|
|
264
|
+
sql`pages >= 0 and scanned >= 0 and unchanged >= 0 and drifted >= 0 and superseded >= 0 and skipped >= 0 and failed >= 0`,
|
|
265
|
+
)
|
|
266
|
+
.execute();
|
|
267
|
+
|
|
268
|
+
// The runs listing: `GET {base}/admin/reconcile-runs`, newest first, and the retention prune's own
|
|
269
|
+
// range delete. Both read `startedAt` leading; the id is the keyset tiebreak, as everywhere else here.
|
|
270
|
+
await db.schema
|
|
271
|
+
.createIndex("pithyPaymentsReconcileRunsStartedIdx")
|
|
272
|
+
.on("pithyPaymentsReconcileRuns")
|
|
273
|
+
.columns(["startedAt", "id"])
|
|
274
|
+
.execute();
|
|
275
|
+
|
|
276
|
+
// Where a resumable sweep left off. One row per `(rail, name)`, because a rail may sweep more than
|
|
277
|
+
// one stream and a name is what tells them apart.
|
|
278
|
+
//
|
|
279
|
+
// This is **not** a copy of a provider's data and is not a cache of one: it is a single opaque
|
|
280
|
+
// pointer into somebody else's stream, which is the minimum state a resumable read can have. The
|
|
281
|
+
// alternative to storing it is re-reading ninety days of events on every run.
|
|
282
|
+
await db.schema
|
|
283
|
+
.createTable("pithyPaymentsSyncCursors")
|
|
284
|
+
.addColumn("id", "text", (c) => c.primaryKey())
|
|
285
|
+
.addColumn("rail", "text", (c) => c.notNull())
|
|
286
|
+
.addColumn("name", "text", (c) => c.notNull())
|
|
287
|
+
// The provider's own resume token — Paddle's `evt_…`. Nullable, because a cursor that has never
|
|
288
|
+
// advanced is a real state: it means "start at the oldest event this provider still retains".
|
|
289
|
+
.addColumn("cursor", "text")
|
|
290
|
+
.addColumn("updatedAt", "integer", (c) => c.notNull())
|
|
291
|
+
.addColumn("createdAt", "integer", (c) => c.notNull())
|
|
292
|
+
// One cursor per stream. Two rows for one stream would let two runs each believe they were
|
|
293
|
+
// authoritative, and the sweep would silently skip whatever fell between them.
|
|
294
|
+
.addUniqueConstraint("pithyPaymentsSyncCursorsIdx", ["rail", "name"])
|
|
295
|
+
.execute();
|
|
296
|
+
},
|
|
297
|
+
down: async (db: Kysely<unknown>): Promise<void> => {
|
|
298
|
+
await db.schema.dropTable("pithyPaymentsSyncCursors").execute();
|
|
299
|
+
await db.schema.dropIndex("pithyPaymentsReconcileRunsStartedIdx").execute();
|
|
300
|
+
await db.schema.dropTable("pithyPaymentsReconcileRuns").execute();
|
|
301
|
+
await db.schema.dropIndex("pithyPaymentsWebhookEventsPendingIdx").execute();
|
|
302
|
+
await db.schema.dropTable("pithyPaymentsWebhookEvents").execute();
|
|
303
|
+
await db.schema.dropTable("pithyPaymentsProviderAccounts").execute();
|
|
304
|
+
await db.schema.dropIndex("pithyPaymentsEntitlementsCreatedIdx").execute();
|
|
305
|
+
await db.schema.dropTable("pithyPaymentsEntitlements").execute();
|
|
306
|
+
await db.schema.dropIndex("pithyPaymentsPurchasesTypePurchasedIdx").execute();
|
|
307
|
+
await db.schema.dropIndex("pithyPaymentsPurchasesPurchasedIdx").execute();
|
|
308
|
+
await db.schema.dropIndex("pithyPaymentsPurchasesExpiryIdx").execute();
|
|
309
|
+
await db.schema.dropIndex("pithyPaymentsPurchasesOwnerIdx").execute();
|
|
310
|
+
await db.schema.dropTable("pithyPaymentsPurchases").execute();
|
|
311
|
+
},
|
|
312
|
+
};
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Where a price quote's location comes from, decided in one place.
|
|
6
|
+
*
|
|
7
|
+
* **A customer is charged from their billing address.** Paddle settles tax on the transaction's address,
|
|
8
|
+
* not on where the browser happened to be — so the browser's IP is a *provisional estimate* and the
|
|
9
|
+
* address on file is the authority. Both are legitimate answers; which one a screen got is a fact the
|
|
10
|
+
* screen has to be able to state, because one of them is what the card will be charged and the other is
|
|
11
|
+
* a guess that resolves at checkout.
|
|
12
|
+
*
|
|
13
|
+
* That is the whole of this module. It takes what a caller knows about a visitor, picks the best source
|
|
14
|
+
* available, and says which one it picked. Nothing here fetches, renders, or formats.
|
|
15
|
+
*
|
|
16
|
+
* **One resolver, not a decision at each call site.** A pricing screen, a paywall and a cart all have to
|
|
17
|
+
* answer "where does this person live", and three answers is how a page quotes from an address the
|
|
18
|
+
* checkout does not charge from. Every site that derives a location for a Paddle price quote calls
|
|
19
|
+
* {@link resolvePriceLocation}, and the honesty label comes off the same object.
|
|
20
|
+
*
|
|
21
|
+
* **No imports, deliberately.** This compiles inside the Worker's program *and* inside an adopter's
|
|
22
|
+
* DOM-typed browser program — `../client/api.ts` explains at length why those are two programs — so it
|
|
23
|
+
* names its own shapes rather than reaching for Zod, for core's codecs, or for Paddle's types. The
|
|
24
|
+
* scaffolded pricing screen hands {@link priceQueryFor}'s answer straight to `usePricePreview`, so
|
|
25
|
+
* TypeScript pins the shape against the real `PaddlePriceQuery` at the one place it matters.
|
|
26
|
+
*
|
|
27
|
+
* **No price, no currency, no country table.** Figures come from Paddle, rendered by Paddle. Nothing in
|
|
28
|
+
* this file knows what anything costs, and nothing in it may learn: a table of amounts here would be
|
|
29
|
+
* wrong in every country it did not list and wrong silently in the ones it did.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Where a quote's location came from, in order of authority.
|
|
34
|
+
*
|
|
35
|
+
* - `customer` — a Paddle customer id. Paddle prices from the address it holds for them, which is the
|
|
36
|
+
* address the checkout charges. The best answer there is, and the only one that is not a guess.
|
|
37
|
+
* - `address` — a billing address the caller holds but Paddle does not yet. Better than the network,
|
|
38
|
+
* still not proof of what the checkout will settle on, because the buyer may enter another.
|
|
39
|
+
* - `ip` — nobody said. Paddle resolves the country from the browser's own IP. Right for a marketing
|
|
40
|
+
* page a stranger is reading, and an estimate every time.
|
|
41
|
+
*/
|
|
42
|
+
export type PriceLocationSource = "customer" | "address" | "ip";
|
|
43
|
+
|
|
44
|
+
/** A billing address, as far as a price quote cares about one. */
|
|
45
|
+
export interface PriceAddress {
|
|
46
|
+
/** ISO 3166-1 alpha-2 — `"US"`, `"GB"`, `"JP"`. Paddle takes it verbatim. */
|
|
47
|
+
countryCode: string;
|
|
48
|
+
/**
|
|
49
|
+
* The postal code, where the caller has one.
|
|
50
|
+
*
|
|
51
|
+
* Load-bearing rather than decorative. United States tax resolves below the country — 15% in Chicago,
|
|
52
|
+
* 8.875% in New York, 0% in Oregon — and Paddle answers a country-only request with 0% rather than
|
|
53
|
+
* with an error, so a quote without one can be short of what the card is charged.
|
|
54
|
+
*/
|
|
55
|
+
postalCode?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* What the caller knows about this visitor. Every field optional, because "nothing" is a real answer.
|
|
60
|
+
*
|
|
61
|
+
* A stranger on a marketing page is `null`. A signed-in visitor whose account has never bought anything
|
|
62
|
+
* carries an address and no customer. A returning customer carries the `ctm_…` the webhook path recorded
|
|
63
|
+
* against them, which is the same value `POST /payments/checkout` hands Paddle as `customer_id`.
|
|
64
|
+
*/
|
|
65
|
+
export interface PriceVisitor {
|
|
66
|
+
/** The Paddle customer this visitor is, or null. An identifier, not a credential. */
|
|
67
|
+
customerId?: string | null;
|
|
68
|
+
/** A billing address the caller holds, or null. */
|
|
69
|
+
address?: PriceAddress | null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The decision: which source was used, and what to send.
|
|
74
|
+
*
|
|
75
|
+
* Both halves matter to a screen. The `customerId`/`address` pair is what goes to Paddle; `source` is
|
|
76
|
+
* what the sentence under the figure has to be true about.
|
|
77
|
+
*/
|
|
78
|
+
export interface PriceLocation {
|
|
79
|
+
/** Which source won. */
|
|
80
|
+
source: PriceLocationSource;
|
|
81
|
+
/** The Paddle customer to price as, or null. */
|
|
82
|
+
customerId: string | null;
|
|
83
|
+
/** The address to price at, or null. */
|
|
84
|
+
address: PriceAddress | null;
|
|
85
|
+
/**
|
|
86
|
+
* Whether this location is a guess about where the buyer lives rather than the address they are
|
|
87
|
+
* charged from.
|
|
88
|
+
*
|
|
89
|
+
* True exactly when `source` is `ip`. Kept as a field rather than left to every reader to derive,
|
|
90
|
+
* because "is this provisional" is the question, and re-deriving it from the union is how a fourth
|
|
91
|
+
* source arrives one day and half the readers keep answering for three.
|
|
92
|
+
*/
|
|
93
|
+
provisional: boolean;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Pick the best location available, and say which one it is.
|
|
98
|
+
*
|
|
99
|
+
* Precedence is authority, not convenience: a Paddle customer beats an address the caller holds, because
|
|
100
|
+
* Paddle prices the customer from the address it will actually charge — and if the two disagree, a
|
|
101
|
+
* traveler or a VPN, the billing address wins for what they are charged, so it wins for what they are
|
|
102
|
+
* shown.
|
|
103
|
+
*
|
|
104
|
+
* **Never a silent default.** `null`, `undefined`, an empty customer id and an address with no country
|
|
105
|
+
* all resolve to `ip` *and say so*, so a caller that meant to pass something and passed nothing gets an
|
|
106
|
+
* estimate labeled as one rather than a guess wearing the authority of an address.
|
|
107
|
+
*/
|
|
108
|
+
export function resolvePriceLocation(visitor: PriceVisitor | null | undefined): PriceLocation {
|
|
109
|
+
const customerId = visitor?.customerId;
|
|
110
|
+
if (typeof customerId === "string" && customerId.length > 0) {
|
|
111
|
+
return { source: "customer", customerId, address: null, provisional: false };
|
|
112
|
+
}
|
|
113
|
+
const address = visitor?.address;
|
|
114
|
+
if (address && typeof address.countryCode === "string" && address.countryCode.length > 0) {
|
|
115
|
+
// Rebuilt rather than passed through, so an empty postal code — the shape an unfilled form field
|
|
116
|
+
// arrives in — does not reach Paddle as though somebody had typed one.
|
|
117
|
+
const postalCode =
|
|
118
|
+
typeof address.postalCode === "string" && address.postalCode.length > 0 ? address.postalCode : undefined;
|
|
119
|
+
return {
|
|
120
|
+
source: "address",
|
|
121
|
+
customerId: null,
|
|
122
|
+
address:
|
|
123
|
+
postalCode === undefined
|
|
124
|
+
? { countryCode: address.countryCode }
|
|
125
|
+
: { countryCode: address.countryCode, postalCode },
|
|
126
|
+
provisional: false,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
return { source: "ip", customerId: null, address: null, provisional: true };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** One line of a price request: what to quote, and how many. */
|
|
133
|
+
export interface PriceQuoteItem {
|
|
134
|
+
/** The Paddle price — `pri_…`. */
|
|
135
|
+
priceId: string;
|
|
136
|
+
/** How many units. */
|
|
137
|
+
quantity: number;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* What to ask Paddle, for these items at this location.
|
|
142
|
+
*
|
|
143
|
+
* Structurally a `PaddlePriceQuery`, and deliberately not typed as one — see this module's header. The
|
|
144
|
+
* absent fields are absent rather than `undefined`-valued: `priceQueryKey` serializes the query into an
|
|
145
|
+
* effect dependency, and a key that changed shape between "no address" and "an address of undefined"
|
|
146
|
+
* would re-quote a page that asked the same question twice.
|
|
147
|
+
*/
|
|
148
|
+
export function priceQueryFor(
|
|
149
|
+
items: readonly PriceQuoteItem[],
|
|
150
|
+
location: PriceLocation,
|
|
151
|
+
): { items: readonly PriceQuoteItem[]; customerId?: string; address?: PriceAddress } {
|
|
152
|
+
if (location.customerId !== null) return { items, customerId: location.customerId };
|
|
153
|
+
if (location.address !== null) return { items, address: location.address };
|
|
154
|
+
return { items };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Whether the figure has to be labeled an estimate.
|
|
159
|
+
*
|
|
160
|
+
* Two independent reasons, and either one is enough:
|
|
161
|
+
*
|
|
162
|
+
* **The location is provisional.** An IP says where a browser connected from, not where a card is
|
|
163
|
+
* registered. The buyer enters a billing address at the card form and the total settles then — which is
|
|
164
|
+
* how commerce works everywhere, and is expected rather than a broken promise, *provided the figure said
|
|
165
|
+
* it was an estimate first*.
|
|
166
|
+
*
|
|
167
|
+
* **The tax is not fully resolved.** `priceSummary().estimated` is true when Paddle resolved no postal
|
|
168
|
+
* code, and United States tax lives below the country. Pass it in; this is where the two facts meet.
|
|
169
|
+
*
|
|
170
|
+
* Deriving the label from the postal code alone made it right by accident — Paddle resolves no postal
|
|
171
|
+
* code from an IP today — and an accident is not a rule. This states the rule.
|
|
172
|
+
*/
|
|
173
|
+
export function quoteIsEstimated(location: PriceLocation, taxUnresolved: boolean): boolean {
|
|
174
|
+
return location.provisional || taxUnresolved;
|
|
175
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { callPayments, type PaymentsClientOptions } from "../client/api";
|
|
5
|
+
import type { PriceVisitor } from "./location";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Where a signed-in visitor's Paddle customer comes from: `GET {basePath}/pricing`, asked of this
|
|
9
|
+
* project's own Worker.
|
|
10
|
+
*
|
|
11
|
+
* **The same value the checkout charges, from the same row.** That route reads the provider-account map
|
|
12
|
+
* keyed on the subject the seam resolved — `providerAccountForSubject(db, "paddle", subject)` — and so does
|
|
13
|
+
* `POST /payments/checkout`, which hands it to Paddle as `customer_id`. One reader, one row, so the
|
|
14
|
+
* figure quoted and the figure charged cannot resolve location differently. A screen that derived a
|
|
15
|
+
* customer id any other way would move this problem rather than solve it.
|
|
16
|
+
*
|
|
17
|
+
* **`ctm_…` is an identifier, not a credential.** It names a Paddle customer; it authorizes nothing.
|
|
18
|
+
* Paddle's `PricePreview` reads a price with it and the publishable client token, which is the pair
|
|
19
|
+
* Paddle publishes for exactly this. Nothing here widens what reaches a browser: the route is
|
|
20
|
+
* `requireAuth()` and answers only about its own caller, so a visitor learns their own customer id and
|
|
21
|
+
* no one else's.
|
|
22
|
+
*
|
|
23
|
+
* **Hand-written guard, no schema library, no absolute URL.** For the reason `../client/api.ts` gives at
|
|
24
|
+
* length: this compiles into an adopter's browser program, and dragging the Worker's Zod graph in behind
|
|
25
|
+
* it would break their build. The server's half of this response *is* a Zod object — `http/responses.ts`
|
|
26
|
+
* — so the boundary is validated on both sides, each in the vocabulary its own program can carry.
|
|
27
|
+
*
|
|
28
|
+
* **The request itself is not written here.** `callPayments` is the one producer of a same-origin,
|
|
29
|
+
* cookie-bearing call to this Worker, and this module routes through it like every other reader. It used
|
|
30
|
+
* to own a second fetch — its own base-path default, its own `credentials: "include"`, its own three
|
|
31
|
+
* failure directions — which is the drift this kit keeps paying for (#346).
|
|
32
|
+
*
|
|
33
|
+
* **A failure is not an answer, and this one fails honest.** Unreachable, refused, or unreadable all
|
|
34
|
+
* return `null`, which resolves to the IP location and renders the figure labeled `Estimated.` A guess
|
|
35
|
+
* that says it is a guess is the safe direction; the unsafe one would be treating a failed read as
|
|
36
|
+
* proof there is no address on file.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Where to ask, and what to ask with — the same options every other call on this side of the wire takes.
|
|
41
|
+
*
|
|
42
|
+
* An alias rather than a second declaration. The two shapes agreed by hand until this read started going
|
|
43
|
+
* through `callPayments`; now there is one of them, and a name a scaffolded screen already imports.
|
|
44
|
+
*/
|
|
45
|
+
export type PriceVisitorOptions = PaymentsClientOptions;
|
|
46
|
+
|
|
47
|
+
/** Whether a value is a plain record — the first step of the guard. */
|
|
48
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
49
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The Paddle customer on a `GET /pricing` body, or null.
|
|
54
|
+
*
|
|
55
|
+
* Total, and refusing rather than coercing. `quotedFrom` is absent on an older Worker than this bundle,
|
|
56
|
+
* null for a caller who has never bought anything, and a record naming a rail otherwise — three shapes
|
|
57
|
+
* that all mean "no Paddle customer to price as" unless the third names `paddle` and carries a string.
|
|
58
|
+
*/
|
|
59
|
+
export function readPaddleCustomer(body: unknown): string | null {
|
|
60
|
+
if (!isRecord(body)) return null;
|
|
61
|
+
const quotedFrom = body.quotedFrom;
|
|
62
|
+
if (!isRecord(quotedFrom)) return null;
|
|
63
|
+
if (quotedFrom.rail !== "paddle") return null;
|
|
64
|
+
const providerAccountId = quotedFrom.providerAccountId;
|
|
65
|
+
return typeof providerAccountId === "string" && providerAccountId.length > 0 ? providerAccountId : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Ask this project's Worker who Paddle prices the signed-in caller as.
|
|
70
|
+
*
|
|
71
|
+
* Never throws, and never reports a failure: the caller's only sensible response to one is to quote from
|
|
72
|
+
* the IP and say so, which is what `null` already means.
|
|
73
|
+
*/
|
|
74
|
+
export async function fetchPriceVisitor(options?: PriceVisitorOptions): Promise<PriceVisitor | null> {
|
|
75
|
+
// `isRecord` is the whole guard, and deliberately so: `readPaddleCustomer` is total over anything, so
|
|
76
|
+
// narrowing harder here would only move the same refusal a line earlier. What matters is that a
|
|
77
|
+
// refusal, an unreachable Worker and an unreadable body all land in the same `!ok` branch.
|
|
78
|
+
const result = await callPayments("/pricing", {}, options, isRecord);
|
|
79
|
+
if (!result.ok) return null;
|
|
80
|
+
const customerId = readPaddleCustomer(result.value);
|
|
81
|
+
return customerId === null ? null : { customerId };
|
|
82
|
+
}
|