@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,101 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { PurchaseEnvironment, PurchaseRole } from "../data/purchase";
|
|
6
|
+
import { PaymentsRail } from "../data/rail";
|
|
7
|
+
import { PurchaseStatus } from "../data/status";
|
|
8
|
+
import { PaymentsSubject } from "../data/subject";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A provider event, normalized. This is the writer's whole input, and the seam between "how a rail talks"
|
|
12
|
+
* and "what the projection stores": a rail module verifies a receipt or parses a notification and produces
|
|
13
|
+
* one of these, and nothing past this point knows which store it came from.
|
|
14
|
+
*
|
|
15
|
+
* The shape is deliberately a *state*, not a *delta*. A rail reports what the transaction is now, and the
|
|
16
|
+
* writer projects it — so a renewal, a refund, a grace period, and a revocation are all the same code path
|
|
17
|
+
* rather than four handlers that must each be kept correct.
|
|
18
|
+
*
|
|
19
|
+
* `providerEventAt` is the field the whole design leans on. It is the provider's own timestamp, not ours,
|
|
20
|
+
* because ours would record delivery order and delivery order is exactly what providers do not guarantee.
|
|
21
|
+
*
|
|
22
|
+
* **This is the owner-bound half of a two-part shape.** A rail produces an `UnboundProviderEvent` — this
|
|
23
|
+
* object with the subject pair omitted, declared in `rails/contract.ts` — and the route binds an owner onto
|
|
24
|
+
* it to make one of these. The split is the whole reason a store's bytes cannot decide who is entitled: a
|
|
25
|
+
* rail parses a payload and knows the transaction completely and the holder not at all, so the field it
|
|
26
|
+
* would have to fill in does not exist for it to fill in wrongly.
|
|
27
|
+
*/
|
|
28
|
+
export const ProviderEvent = z
|
|
29
|
+
.object({
|
|
30
|
+
rail: PaymentsRail.describe("Which store the event came from."),
|
|
31
|
+
providerTransactionId: z
|
|
32
|
+
.string()
|
|
33
|
+
.min(1)
|
|
34
|
+
.describe(
|
|
35
|
+
"The rail's own transaction id. Together with `rail` this is the row's identity forever — one provider transaction is one purchase row.",
|
|
36
|
+
),
|
|
37
|
+
providerProductId: z
|
|
38
|
+
.string()
|
|
39
|
+
.min(1)
|
|
40
|
+
.describe("The rail's own SKU or price id. The writer resolves the logical product from it."),
|
|
41
|
+
subjectType: PaymentsSubject.shape.subjectType.describe(
|
|
42
|
+
"Whether `subjectId` names a user or an organization. Half the owner, and never separable from the other half — the route binds both together or neither.",
|
|
43
|
+
),
|
|
44
|
+
subjectId: PaymentsSubject.shape.subjectId.describe(
|
|
45
|
+
"The subject the transaction belongs to. A client submission takes it from the authenticated caller through the configured subject seam; a webhook resolves it through the provider-account map or a decoded account reference.",
|
|
46
|
+
),
|
|
47
|
+
status: PurchaseStatus.describe("The transaction's current normalized state, as the rail reports it."),
|
|
48
|
+
role: PurchaseRole.default("charge").describe(
|
|
49
|
+
"Whether this event records money moving or a subscription's standing. Omitted by every rail but Lemon Squeezy, which reports the two separately because its store does.",
|
|
50
|
+
),
|
|
51
|
+
environment: PurchaseEnvironment.describe(
|
|
52
|
+
"Which store environment the transaction happened in. Checked against the deployment's own before anything is written.",
|
|
53
|
+
),
|
|
54
|
+
purchasedAt: z.date().describe("When the store recorded the purchase."),
|
|
55
|
+
expiresAt: z
|
|
56
|
+
.date()
|
|
57
|
+
.nullable()
|
|
58
|
+
.default(null)
|
|
59
|
+
.describe("When access lapses, or null for a purchase that never expires. Null for a one-time purchase."),
|
|
60
|
+
revokedAt: z
|
|
61
|
+
.date()
|
|
62
|
+
.nullable()
|
|
63
|
+
.default(null)
|
|
64
|
+
.describe("When the purchase was refunded or revoked, or null while it stands."),
|
|
65
|
+
resumesAt: z
|
|
66
|
+
.date()
|
|
67
|
+
.nullable()
|
|
68
|
+
.default(null)
|
|
69
|
+
.describe(
|
|
70
|
+
"When a paused subscription resumes, as the provider stated it — never computed, and never set on a status other than `paused`. Null on a paused event is the provider reporting an indefinite pause. Produced only by `pauseResumesAt` in `data/pause.ts`, which is where each rail's source for it is declared.",
|
|
71
|
+
),
|
|
72
|
+
originalTransactionId: z
|
|
73
|
+
.string()
|
|
74
|
+
.nullable()
|
|
75
|
+
.default(null)
|
|
76
|
+
.describe("The transaction that started the subscription, chaining renewals back to it. Null for a one-off."),
|
|
77
|
+
amountMinor: z
|
|
78
|
+
.number()
|
|
79
|
+
.int()
|
|
80
|
+
.nullable()
|
|
81
|
+
.default(null)
|
|
82
|
+
.describe("The amount charged in the currency's minor unit, or null when the rail did not report one."),
|
|
83
|
+
currency: z
|
|
84
|
+
.string()
|
|
85
|
+
.nullable()
|
|
86
|
+
.default(null)
|
|
87
|
+
.describe("The ISO currency of `amountMinor`, or null when the rail did not report one."),
|
|
88
|
+
providerEventAt: z
|
|
89
|
+
.date()
|
|
90
|
+
.describe(
|
|
91
|
+
"The provider's own timestamp for this event. The monotonic write rule compares against it, so an event staler than the row it would update changes nothing.",
|
|
92
|
+
),
|
|
93
|
+
payload: z
|
|
94
|
+
.record(z.string(), z.unknown())
|
|
95
|
+
.describe("The verified provider payload, stored as received. The reconciliation and audit record."),
|
|
96
|
+
})
|
|
97
|
+
.describe(
|
|
98
|
+
"One provider event, normalized and bound to its owner — the projection writer's input. A rail produces this shape less the subject pair; the route adds it.",
|
|
99
|
+
);
|
|
100
|
+
export type ProviderEvent = z.output<typeof ProviderEvent>;
|
|
101
|
+
export type ProviderEventInput = z.input<typeof ProviderEvent>;
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { D1Database } from "@cloudflare/workers-types";
|
|
5
|
+
import { withD1Retry } from "@pithy-sh/core/src/data/withD1Retry";
|
|
6
|
+
import type { PaymentsConfig } from "../config/config";
|
|
7
|
+
import type { PurchaseEnvironment } from "../data/purchase";
|
|
8
|
+
import type { PaymentsRail } from "../data/rail";
|
|
9
|
+
import { PAYMENTS_WEBHOOK_EVENTS_TABLE, paymentsDatabase } from "../data/tables";
|
|
10
|
+
import { PaymentsWebhookEvent, WEBHOOK_EVENT_ORPHANED, webhookEventAwaitsOwner } from "../data/webhookEvent";
|
|
11
|
+
import type { VerifiedNotification } from "../rails/contract";
|
|
12
|
+
import { resolveNotificationOwner } from "./owner";
|
|
13
|
+
import { type PurchaseProjection, projectPurchase } from "./writer";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The repair that runs when an account links: project the purchases that were waiting for exactly that.
|
|
17
|
+
*
|
|
18
|
+
* ## The gap this closes (#341)
|
|
19
|
+
*
|
|
20
|
+
* An orphan is an authentic notification carrying a real purchase and nothing that says whose it is. Both
|
|
21
|
+
* paths that meet one leave the row unfinished on purpose — the webhook handler records the reason and
|
|
22
|
+
* withholds `processedAt`, the sweep stamps `abandonedAt` so its stream can advance — and both then rely on
|
|
23
|
+
* **a later delivery of the same event** to repair it. That is the right bet when the store redelivers. It
|
|
24
|
+
* is no bet at all when the only future event is the account linking: nothing redelivers on that signal,
|
|
25
|
+
* every store's retry window closes within days, and the sweep's cursor is long past.
|
|
26
|
+
*
|
|
27
|
+
* Reproduced on real D1 before this existed: sweep an unstamped `subscription.activated` for an unlinked
|
|
28
|
+
* customer, link the account, sweep ten more times. The purchase is never projected. Not once, ever — the
|
|
29
|
+
* orphan gets exactly one owner resolution in its life, at the moment nobody could answer it.
|
|
30
|
+
*
|
|
31
|
+
* ## Why here and not in the sweep
|
|
32
|
+
*
|
|
33
|
+
* The two halves of the question live in different places. The link path knows *which* customer just became
|
|
34
|
+
* resolvable; the sweep knows *which events* are outstanding. One of them has to ask the other, and asking
|
|
35
|
+
* from the link side is the cheaper direction by a wide margin: it runs once per link rather than once per
|
|
36
|
+
* sweep, the set it looks at is bounded by {@link ORPHAN_REPAIR_LIMIT}, and — decisively — it works on the
|
|
37
|
+
* four rails that have no sweep at all. A rail without a repair pass is exactly where a lost orphan stays
|
|
38
|
+
* lost.
|
|
39
|
+
*
|
|
40
|
+
* ## Which rows, and why not simply "unfinished"
|
|
41
|
+
*
|
|
42
|
+
* {@link webhookEventAwaitsOwner}: unfinished, and carrying the {@link WEBHOOK_EVENT_ORPHANED} marker. An
|
|
43
|
+
* account linking repairs one thing — a missing owner. A quarantined event, a failed projection, an unmapped
|
|
44
|
+
* SKU are all unfinished too, and re-running them on this signal would be an unbounded retry loop triggered
|
|
45
|
+
* by unrelated traffic. The marker is what makes the set the *right* set rather than merely a small one.
|
|
46
|
+
*
|
|
47
|
+
* ## Why a payload replay rather than a stored event
|
|
48
|
+
*
|
|
49
|
+
* The row already holds the notification whole — that is what `payload` is for, and the reason it is stored
|
|
50
|
+
* rather than summarized. So the purchase is recoverable without a second column, a migration, or a shape
|
|
51
|
+
* that could disagree with the payload it was derived from. What it costs is a rail able to re-read its own
|
|
52
|
+
* recorded body, which is {@link PaymentsRailProvider.replay} and is optional for the two rails whose stored
|
|
53
|
+
* body is a signed blob.
|
|
54
|
+
*
|
|
55
|
+
* ## What it deliberately does not do
|
|
56
|
+
*
|
|
57
|
+
* It never *invents* an owner. Every row goes back through {@link resolveNotificationOwner}, against the same
|
|
58
|
+
* table in the same trust order, so a link that resolves nothing changes nothing — and a row for a different
|
|
59
|
+
* holder that happens to be in the same page is left exactly as it stood. Passing the freshly linked subject
|
|
60
|
+
* straight to the writer would be the shortcut, and it would project one customer's purchase onto another's
|
|
61
|
+
* account the first time two orphans shared a page.
|
|
62
|
+
*
|
|
63
|
+
* **Per row, and both halves per row.** The subject is resolved inside the loop, from the row's own hints, so
|
|
64
|
+
* a page of orphans is a page of independent questions rather than one answer applied to all of them. There
|
|
65
|
+
* is no subject computed before the loop for the same reason there is no `subjectType` taken from config: a
|
|
66
|
+
* kind that outlived the row it came from is how an organization's purchase lands on a user with the same id.
|
|
67
|
+
*/
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* How many orphaned rows one link may repair.
|
|
71
|
+
*
|
|
72
|
+
* A bound rather than "all of them", because this runs inside a webhook handler that a store is timing. A
|
|
73
|
+
* link arriving after a long outage could face hundreds of orphans, and a handler that walks them all is a
|
|
74
|
+
* handler the store times out and redelivers — which produces the same walk again, from the top.
|
|
75
|
+
*
|
|
76
|
+
* Small enough to be invisible in a request, and the leftovers are not lost: they still carry the marker, and
|
|
77
|
+
* the next link on that rail takes the next page. The rows are taken oldest first so the queue drains in
|
|
78
|
+
* order rather than starving its own tail.
|
|
79
|
+
*/
|
|
80
|
+
export const ORPHAN_REPAIR_LIMIT = 25;
|
|
81
|
+
|
|
82
|
+
/** What the repair needs to project what it finds: the catalog, the environment it is writing for, the clock. */
|
|
83
|
+
export interface RepairOrphansOptions {
|
|
84
|
+
/** The resolved catalog, handed to the writer unchanged. */
|
|
85
|
+
config: PaymentsConfig;
|
|
86
|
+
/** This deployment's store environment. The writer refuses a row that disagrees. */
|
|
87
|
+
environment: PurchaseEnvironment;
|
|
88
|
+
/** The clock. */
|
|
89
|
+
now: Date;
|
|
90
|
+
/**
|
|
91
|
+
* Re-read one recorded payload — {@link PaymentsRailProvider.replay}, bound to its rail.
|
|
92
|
+
*
|
|
93
|
+
* Passed in rather than resolved here because building a rail provider needs credentials, and this module
|
|
94
|
+
* has no business reading a secret. A caller whose rail cannot replay passes nothing and the repair is a
|
|
95
|
+
* no-op, which is the honest answer for it.
|
|
96
|
+
*/
|
|
97
|
+
replay?: (payload: Record<string, unknown>) => Promise<VerifiedNotification | undefined>;
|
|
98
|
+
/** What to do with each repaired projection — the same fulfillment the delivery would have performed. */
|
|
99
|
+
fulfill?: (projection: PurchaseProjection) => Promise<void>;
|
|
100
|
+
/** How many rows to take. Defaults to {@link ORPHAN_REPAIR_LIMIT}. */
|
|
101
|
+
limit?: number;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** What one repair pass did. Returned so a caller can log it and a test can assert on it. */
|
|
105
|
+
export interface RepairedOrphans {
|
|
106
|
+
/** Rows examined — orphan-marked and unfinished, on this rail. */
|
|
107
|
+
readonly examined: number;
|
|
108
|
+
/** Rows whose purchase is now projected and whose event is now finished. */
|
|
109
|
+
readonly projected: readonly string[];
|
|
110
|
+
/** Rows still waiting: no owner yet, no replay, or nothing to project. Left exactly as they stood. */
|
|
111
|
+
readonly waiting: number;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Project the orphaned events on one rail that an owner can now be resolved for.
|
|
116
|
+
*
|
|
117
|
+
* Called after a link is written, on every path that writes one. Never throws for a row it cannot repair: a
|
|
118
|
+
* replay that cannot read an old payload, an owner that still does not resolve, a projection the catalog
|
|
119
|
+
* refuses — each leaves that row untouched and the pass moves to the next. The caller is a webhook handler
|
|
120
|
+
* answering a store, and a repair failing must not turn a delivery that succeeded into a non-2xx.
|
|
121
|
+
*
|
|
122
|
+
* A row it *does* project is finished the same way the original delivery would have finished it, so the
|
|
123
|
+
* store's own redelivery of it is a duplicate from then on.
|
|
124
|
+
*/
|
|
125
|
+
export async function repairOrphanedEvents(
|
|
126
|
+
d1: D1Database,
|
|
127
|
+
rail: PaymentsRail,
|
|
128
|
+
options: RepairOrphansOptions,
|
|
129
|
+
): Promise<RepairedOrphans> {
|
|
130
|
+
const projected: string[] = [];
|
|
131
|
+
let examined = 0;
|
|
132
|
+
let waiting = 0;
|
|
133
|
+
|
|
134
|
+
if (options.replay === undefined) return { examined, projected, waiting };
|
|
135
|
+
|
|
136
|
+
const db = paymentsDatabase(d1);
|
|
137
|
+
const rows = await db
|
|
138
|
+
.selectFrom(PAYMENTS_WEBHOOK_EVENTS_TABLE)
|
|
139
|
+
.selectAll()
|
|
140
|
+
.where("rail", "=", rail)
|
|
141
|
+
// Unfinished. The marker check below is the narrow half, but this one is what a `processedAt` index can
|
|
142
|
+
// serve, and it keeps a store's whole finished history out of the scan.
|
|
143
|
+
.where("processedAt", "is", null)
|
|
144
|
+
.where("error", "like", `${WEBHOOK_EVENT_ORPHANED}%`)
|
|
145
|
+
// Oldest first: the queue drains in the order the purchases were made, not the order D1 happens to scan.
|
|
146
|
+
.orderBy("receivedAt")
|
|
147
|
+
.limit(options.limit ?? ORPHAN_REPAIR_LIMIT)
|
|
148
|
+
.execute();
|
|
149
|
+
|
|
150
|
+
for (const stored of rows) {
|
|
151
|
+
const row = PaymentsWebhookEvent.parse(stored);
|
|
152
|
+
// The predicate again, on the decoded row. The `where` above is the query this scan can afford; this is
|
|
153
|
+
// the question being asked. They agree today, and a reader must not have to prove that to trust the set.
|
|
154
|
+
if (!webhookEventAwaitsOwner(stored)) continue;
|
|
155
|
+
examined += 1;
|
|
156
|
+
|
|
157
|
+
const notification = await options.replay(row.payload);
|
|
158
|
+
if (notification?.event === undefined || notification.event === null) {
|
|
159
|
+
waiting += 1;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// This row's own subject, resolved from this row's own hints. Both halves come back together or neither
|
|
164
|
+
// does — `projection/owner.ts` reads them from one row for exactly this reason.
|
|
165
|
+
const subject = await resolveNotificationOwner(db, rail, {
|
|
166
|
+
providerAccountId: notification.providerAccountId,
|
|
167
|
+
providerTransactionId: notification.event.providerTransactionId,
|
|
168
|
+
originalTransactionId: notification.event.originalTransactionId,
|
|
169
|
+
});
|
|
170
|
+
if (subject === undefined) {
|
|
171
|
+
// Still nobody. The link that just landed was somebody else's, and this row waits for its own.
|
|
172
|
+
waiting += 1;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
let projection: PurchaseProjection;
|
|
177
|
+
try {
|
|
178
|
+
projection = await projectPurchase(
|
|
179
|
+
d1,
|
|
180
|
+
{ ...notification.event, ...subject },
|
|
181
|
+
{ config: options.config, environment: options.environment, now: options.now },
|
|
182
|
+
);
|
|
183
|
+
if (notification.stateEvent) {
|
|
184
|
+
await projectPurchase(
|
|
185
|
+
d1,
|
|
186
|
+
{ ...notification.stateEvent, ...subject },
|
|
187
|
+
{ config: options.config, environment: options.environment, now: options.now },
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
await options.fulfill?.(projection);
|
|
191
|
+
} catch {
|
|
192
|
+
// An unmapped SKU, a sandbox row in a production database, a ledger that would not assemble. None of
|
|
193
|
+
// them is this link's fault and none is repaired by trying again here, so the row keeps its marker and
|
|
194
|
+
// its reason and this pass moves on. The delivery that called us still succeeds.
|
|
195
|
+
waiting += 1;
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
await finish(d1, row.id, options.now);
|
|
200
|
+
projected.push(row.providerEventId);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return { examined, projected, waiting };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Mark a repaired row finished, and clear the reason it carried.
|
|
208
|
+
*
|
|
209
|
+
* `processedAt` because it now genuinely is finished — the purchase is projected, and a redelivery has
|
|
210
|
+
* nothing left to add. The `error` goes with it: it said "no subject could be resolved", which stopped
|
|
211
|
+
* being true, and a stale reason beside a finished timestamp is the contradiction `data/webhookEvent.ts`
|
|
212
|
+
* refuses to store.
|
|
213
|
+
*
|
|
214
|
+
* `abandonedAt` is left exactly where it was. A sweep having given up on this row is a fact about that pass
|
|
215
|
+
* and a record of how close the purchase came to being lost; `finished` already wins over it by construction.
|
|
216
|
+
*/
|
|
217
|
+
async function finish(d1: D1Database, id: string, now: Date): Promise<void> {
|
|
218
|
+
await withD1Retry(() =>
|
|
219
|
+
paymentsDatabase(d1)
|
|
220
|
+
.updateTable(PAYMENTS_WEBHOOK_EVENTS_TABLE)
|
|
221
|
+
// biome-ignore lint/suspicious/noExplicitAny: encoded column values, not the app shape.
|
|
222
|
+
.set({ processedAt: now.getTime(), error: null } as any)
|
|
223
|
+
.where("id", "=", id)
|
|
224
|
+
.execute(),
|
|
225
|
+
);
|
|
226
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { D1Database } from "@cloudflare/workers-types";
|
|
5
|
+
import { withD1Retry } from "@pithy-sh/core/src/data/withD1Retry";
|
|
6
|
+
import { PaymentsProviderAccount } from "../data/providerAccount";
|
|
7
|
+
import type { PaymentsRail } from "../data/rail";
|
|
8
|
+
import { PaymentsSubject } from "../data/subject";
|
|
9
|
+
import {
|
|
10
|
+
PAYMENTS_PROVIDER_ACCOUNTS_TABLE,
|
|
11
|
+
PAYMENTS_PURCHASES_TABLE,
|
|
12
|
+
type PaymentsDatabase,
|
|
13
|
+
paymentsDatabase,
|
|
14
|
+
} from "../data/tables";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Who a purchase belongs to — the question a webhook cannot answer for itself.
|
|
18
|
+
*
|
|
19
|
+
* A notification arrives carrying the store's own identifiers and no Pithy identity at all. The projection
|
|
20
|
+
* writer takes an owner as an input and refuses to infer one, deliberately: a writer that could derive a
|
|
21
|
+
* holder from a payload could be talked into deriving the wrong one. So resolution happens here, once,
|
|
22
|
+
* against rows the server itself wrote.
|
|
23
|
+
*
|
|
24
|
+
* **The answer is a subject, not a user id** — the pair, and always both halves. Under organization billing
|
|
25
|
+
* the person who clicked is not the holder, and `data/subject.ts` states why an id on its own identifies
|
|
26
|
+
* nobody: nothing keeps an organization id from equalling some user's, so a resolution that returned an id
|
|
27
|
+
* alone would let one collect the other's renewals. Every lookup below therefore selects both columns **from
|
|
28
|
+
* the same row**, and the pair is never assembled from two places — not a kind from config beside an id from
|
|
29
|
+
* a row, not a kind from one table beside an id from another.
|
|
30
|
+
*
|
|
31
|
+
* **Three sources, and the order they are consulted in is a trust order.**
|
|
32
|
+
*
|
|
33
|
+
* 1. **The same transaction, already projected.** A redelivery of a notification we handled is never
|
|
34
|
+
* orphaned, whatever the app set.
|
|
35
|
+
* 2. **The transaction that started the subscription.** This is what makes renewals work for an app that set
|
|
36
|
+
* no account token at all: the first purchase was submitted by its owner, and every renewal chains back to
|
|
37
|
+
* it through `originalTransactionId`.
|
|
38
|
+
* 3. **The account link** — `pithy_payments_provider_accounts`, written when the purchase was submitted,
|
|
39
|
+
* through the hook every store provides: Apple's `appAccountToken`, Google's `obfuscatedAccountId`,
|
|
40
|
+
* Stripe's `client_reference_id`.
|
|
41
|
+
*
|
|
42
|
+
* The first two are facts this server established; the third is a value a client chose. That is the whole
|
|
43
|
+
* ordering, and {@link resolveNotificationOwner} spells out the attack it prevents.
|
|
44
|
+
*
|
|
45
|
+
* When nothing answers, the answer is undefined — an orphan. The notification is still recorded, so it is
|
|
46
|
+
* visible and replayable once a link arrives. Guessing would be the alternative, and a guess here grants
|
|
47
|
+
* somebody else's subscription.
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
/** What a notification offers as identification. Every field is optional; a rail supplies what it carries. */
|
|
51
|
+
export interface OwnerHints {
|
|
52
|
+
/** The store's account identifier, if the purchase carried one. */
|
|
53
|
+
providerAccountId?: string | null;
|
|
54
|
+
/** This transaction's own id. Matches a purchase already projected. */
|
|
55
|
+
providerTransactionId?: string | null;
|
|
56
|
+
/** The transaction that started the subscription. Matches the purchase a renewal descends from. */
|
|
57
|
+
originalTransactionId?: string | null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Options for the account link. The clock is injected so a link's `createdAt` is deterministic in tests. */
|
|
61
|
+
export interface LinkProviderAccountOptions {
|
|
62
|
+
/** The clock. */
|
|
63
|
+
now?: Date;
|
|
64
|
+
/** The id minter. */
|
|
65
|
+
newId?: () => string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The pair, read off the one row it was found in.
|
|
70
|
+
*
|
|
71
|
+
* A function rather than an object literal at each lookup, because the literal is exactly where the two
|
|
72
|
+
* halves could come from two places: a `subjectType` taken from config beside a `subjectId` taken from a row
|
|
73
|
+
* typechecks perfectly and names a holder nobody meant. Taking a row and reading both columns off it leaves
|
|
74
|
+
* nowhere to pass a mismatched pair. The parse is the D1 boundary check every other decoded row in this
|
|
75
|
+
* package gets, over a value the table's own CHECK constraint already closes.
|
|
76
|
+
*/
|
|
77
|
+
function subjectOf(row: { subjectType: string; subjectId: string }): PaymentsSubject {
|
|
78
|
+
return PaymentsSubject.parse(row);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Bind a store account identifier to a subject, and return the subject it is bound to.
|
|
83
|
+
*
|
|
84
|
+
* Idempotent, and **never rebinding**: `UNIQUE (rail, providerAccountId)` plus `ON CONFLICT DO NOTHING`
|
|
85
|
+
* means the first binding wins and a later one is a no-op. That matters because a client re-submits its
|
|
86
|
+
* receipt on every launch, and because rebinding would let a second holder capture the first one's renewal
|
|
87
|
+
* notifications simply by submitting a receipt from the same store account.
|
|
88
|
+
*
|
|
89
|
+
* **The key is deliberately not widened by the subject**, and the kind is no escape from it either: an
|
|
90
|
+
* organization whose id equals a user's does not get its own link, because `organization:acme` and
|
|
91
|
+
* `user:acme` would still be two rows competing for one `(rail, providerAccountId)`. One provider identity
|
|
92
|
+
* resolves to one holder, and which holder is decided once.
|
|
93
|
+
*
|
|
94
|
+
* The returned subject is the one actually bound, which is not always the one passed in. A caller that cares
|
|
95
|
+
* about the difference compares them with `sameSubject`; the purchase-level owner check in the writer is
|
|
96
|
+
* what refuses a transaction outright.
|
|
97
|
+
*
|
|
98
|
+
* **A link is also a repair signal, and this function does not act on it.** Purchases that arrived before
|
|
99
|
+
* their owner was knowable are sitting in `pithy_payments_webhook_events` waiting for exactly this row —
|
|
100
|
+
* see `projection/orphans.ts`, and #341 for what it cost while nothing looked. Acting on it here would mean
|
|
101
|
+
* this function taking a catalog, an environment and a rail able to replay its own payloads, which is three
|
|
102
|
+
* arguments a link has no business knowing about; so every call site calls `repairOrphanedEvents` beside
|
|
103
|
+
* this one instead. **A fourth call site owes the same call.**
|
|
104
|
+
*/
|
|
105
|
+
export async function linkProviderAccount(
|
|
106
|
+
d1: D1Database,
|
|
107
|
+
rail: PaymentsRail,
|
|
108
|
+
providerAccountId: string,
|
|
109
|
+
subject: PaymentsSubject,
|
|
110
|
+
options: LinkProviderAccountOptions = {},
|
|
111
|
+
): Promise<PaymentsSubject> {
|
|
112
|
+
const db = paymentsDatabase(d1);
|
|
113
|
+
const row = PaymentsProviderAccount.encode({
|
|
114
|
+
id: options.newId?.() ?? crypto.randomUUID(),
|
|
115
|
+
rail,
|
|
116
|
+
providerAccountId,
|
|
117
|
+
subjectType: subject.subjectType,
|
|
118
|
+
subjectId: subject.subjectId,
|
|
119
|
+
createdAt: options.now ?? new Date(),
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
await withD1Retry(() =>
|
|
123
|
+
db
|
|
124
|
+
.insertInto(PAYMENTS_PROVIDER_ACCOUNTS_TABLE)
|
|
125
|
+
// biome-ignore lint/suspicious/noExplicitAny: an encoded row; Kysely's insert type derives from z.input.
|
|
126
|
+
.values(row as any)
|
|
127
|
+
.onConflict((oc) => oc.columns(["rail", "providerAccountId"]).doNothing())
|
|
128
|
+
.execute(),
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
const bound = await db
|
|
132
|
+
.selectFrom(PAYMENTS_PROVIDER_ACCOUNTS_TABLE)
|
|
133
|
+
.select(["subjectType", "subjectId"])
|
|
134
|
+
.where("rail", "=", rail)
|
|
135
|
+
.where("providerAccountId", "=", providerAccountId)
|
|
136
|
+
.executeTakeFirst();
|
|
137
|
+
// Absent only if the row vanished between the insert and this read, which nothing in this package does.
|
|
138
|
+
return bound === undefined ? subject : subjectOf(bound);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The store account this subject already has on this rail, or undefined.
|
|
143
|
+
*
|
|
144
|
+
* The reverse of {@link resolveNotificationOwner}, and it exists for the two hosted Stripe flows. `/checkout`
|
|
145
|
+
* passes it so a returning buyer keeps one Stripe customer instead of minting a new one per purchase — without
|
|
146
|
+
* that, the billing portal their first purchase created would not show their second. `/portal` needs it because
|
|
147
|
+
* a portal session is *about* a customer, and taking that from a request would let any caller open somebody
|
|
148
|
+
* else's billing.
|
|
149
|
+
*
|
|
150
|
+
* **Both halves filter, and that is what the portal rests on.** An id-only filter would hand `organization:acme`
|
|
151
|
+
* the portal session belonging to `user:acme` — a live billing session over somebody else's card, invoices and
|
|
152
|
+
* cancellation, reachable by anybody who can act for a subject whose id collides.
|
|
153
|
+
*
|
|
154
|
+
* The oldest link wins. A subject with two accounts on one rail is already an anomaly — the link is never
|
|
155
|
+
* rebound, so it takes two purchases made under two store accounts — and the first is the one every earlier
|
|
156
|
+
* purchase is filed under.
|
|
157
|
+
*/
|
|
158
|
+
export async function providerAccountForSubject(
|
|
159
|
+
db: PaymentsDatabase,
|
|
160
|
+
rail: PaymentsRail,
|
|
161
|
+
subject: PaymentsSubject,
|
|
162
|
+
): Promise<string | undefined> {
|
|
163
|
+
const row = await db
|
|
164
|
+
.selectFrom(PAYMENTS_PROVIDER_ACCOUNTS_TABLE)
|
|
165
|
+
.select("providerAccountId")
|
|
166
|
+
.where("rail", "=", rail)
|
|
167
|
+
.where("subjectType", "=", subject.subjectType)
|
|
168
|
+
.where("subjectId", "=", subject.subjectId)
|
|
169
|
+
.orderBy("createdAt")
|
|
170
|
+
.executeTakeFirst();
|
|
171
|
+
return row?.providerAccountId;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The subject a notification belongs to, or undefined when nothing identifies one.
|
|
176
|
+
*
|
|
177
|
+
* **The order is a trust order, not a convenience order.** The two transaction lookups ask a question this
|
|
178
|
+
* server already answered: who owned the purchase we projected for an authenticated caller. The account link
|
|
179
|
+
* asks a question the *app* answered, because on Apple and Google `providerAccountId` is `appAccountToken` /
|
|
180
|
+
* `obfuscatedAccountId` — values a client puts in a purchase and may put anything in. `contract.ts` says as
|
|
181
|
+
* much about `accountReference`, and the same reasoning applies one hop further out: a value a client chose
|
|
182
|
+
* must never outrank a fact this server established.
|
|
183
|
+
*
|
|
184
|
+
* Consulting the link first is a cross-subject entitlement hijack. An attacker who can guess a victim's token
|
|
185
|
+
* value makes one purchase carrying it, submits it as themselves, and owns the link; the victim's own
|
|
186
|
+
* notifications then resolve to the attacker, and because a renewal carries a fresh transaction id the
|
|
187
|
+
* writer's owner check never fires. Last is where a client's claim belongs.
|
|
188
|
+
*
|
|
189
|
+
* It still belongs *somewhere*: on Stripe the link is written from `client_reference_id`, which `/checkout`
|
|
190
|
+
* set from the authenticated buyer, so it is server-established and it is the only thing that can attribute
|
|
191
|
+
* a first invoice. Hence last rather than gone.
|
|
192
|
+
*
|
|
193
|
+
* Each source answers with the whole pair or with nothing. A source that matched a row contributes that
|
|
194
|
+
* row's `subjectType` **and** its `subjectId`, so a purchase filed against an organization can never resolve
|
|
195
|
+
* to the user with the same id — see {@link subjectOf}.
|
|
196
|
+
*/
|
|
197
|
+
export async function resolveNotificationOwner(
|
|
198
|
+
db: PaymentsDatabase,
|
|
199
|
+
rail: PaymentsRail,
|
|
200
|
+
hints: OwnerHints,
|
|
201
|
+
): Promise<PaymentsSubject | undefined> {
|
|
202
|
+
// A falsy hint is skipped rather than passed to the query: `where(column, "=", undefined)` in Kysely
|
|
203
|
+
// compares against null, which on a nullable column matches every unrelated one-off purchase.
|
|
204
|
+
if (hints.providerTransactionId) {
|
|
205
|
+
const owner = await db
|
|
206
|
+
.selectFrom(PAYMENTS_PURCHASES_TABLE)
|
|
207
|
+
.select(["subjectType", "subjectId"])
|
|
208
|
+
.where("rail", "=", rail)
|
|
209
|
+
.where("providerTransactionId", "=", hints.providerTransactionId)
|
|
210
|
+
.executeTakeFirst();
|
|
211
|
+
if (owner) return subjectOf(owner);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (hints.originalTransactionId) {
|
|
215
|
+
// The whole subscription family, matched on either column. A renewal's `originalTransactionId` names the
|
|
216
|
+
// *first transaction's own id*, so the purchase that started the subscription is found on
|
|
217
|
+
// `providerTransactionId` while its siblings are found on `originalTransactionId`. Matching only the
|
|
218
|
+
// latter would miss the one row that is guaranteed to exist — the one somebody actually bought.
|
|
219
|
+
const owner = await db
|
|
220
|
+
.selectFrom(PAYMENTS_PURCHASES_TABLE)
|
|
221
|
+
.select(["subjectType", "subjectId"])
|
|
222
|
+
.where("rail", "=", rail)
|
|
223
|
+
.where((eb) =>
|
|
224
|
+
eb.or([
|
|
225
|
+
eb("providerTransactionId", "=", hints.originalTransactionId as string),
|
|
226
|
+
eb("originalTransactionId", "=", hints.originalTransactionId as string),
|
|
227
|
+
]),
|
|
228
|
+
)
|
|
229
|
+
// Oldest first: the transaction that started the subscription is the one whose owner is least disputable.
|
|
230
|
+
.orderBy("purchasedAt")
|
|
231
|
+
.executeTakeFirst();
|
|
232
|
+
if (owner) return subjectOf(owner);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Last, and only when nothing this server established answers. See the trust order above.
|
|
236
|
+
if (hints.providerAccountId) {
|
|
237
|
+
const linked = await db
|
|
238
|
+
.selectFrom(PAYMENTS_PROVIDER_ACCOUNTS_TABLE)
|
|
239
|
+
.select(["subjectType", "subjectId"])
|
|
240
|
+
.where("rail", "=", rail)
|
|
241
|
+
.where("providerAccountId", "=", hints.providerAccountId)
|
|
242
|
+
.executeTakeFirst();
|
|
243
|
+
if (linked) return subjectOf(linked);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return undefined;
|
|
247
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { Entitlement } from "@pithy-sh/core/src/entitlement/entitlement";
|
|
5
|
+
import { PaymentsEntitlement } from "../data/entitlement";
|
|
6
|
+
import type { PaymentsSubject } from "../data/subject";
|
|
7
|
+
import { PAYMENTS_ENTITLEMENTS_TABLE, type PaymentsDatabase } from "../data/tables";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The read path: every entitlement a subject holds, as the core seam's shape.
|
|
11
|
+
*
|
|
12
|
+
* **A read never writes.** Repairing a stale row is the reconciliation Workflow's job, which is what keeps
|
|
13
|
+
* this the hot path it needs to be — one indexed lookup per request, no KV cache, no token claim, so a
|
|
14
|
+
* revocation is immediate and the truth has one home (issue #79 decision 5).
|
|
15
|
+
*
|
|
16
|
+
* The recheck is why the read exists as its own function rather than a `selectAll`. A subscription can lapse
|
|
17
|
+
* with **no notification arriving at all** — the store simply stops renewing, and nothing tells us. So the
|
|
18
|
+
* stored `active` flag is an optimization and `expiresAt` is the truth, and the read applies the timestamp
|
|
19
|
+
* itself rather than trusting the flag. A row that says `active = 1` with an expiry in the past does not
|
|
20
|
+
* grant, and it does not need a write to stop granting.
|
|
21
|
+
*
|
|
22
|
+
* Lapsed rows are returned rather than filtered out, with `active` false. A paywall wants to say "your Pro
|
|
23
|
+
* ended on the 4th", and it can only do that if the row and its date survive the read.
|
|
24
|
+
*
|
|
25
|
+
* **Both halves of the subject are in the `where`, and neither is decorative.** Nothing in the kit makes the
|
|
26
|
+
* user id space and the adopter's organization id space disjoint — they are minted by different things, and
|
|
27
|
+
* neither knows the other exists — so a read filtered on the id alone would hand `user:acme` whatever
|
|
28
|
+
* `organization:acme` bought. The pair arrives as one object for exactly that reason: it is read from and
|
|
29
|
+
* compared as a unit, and no call site here pairs a kind from config with an id from somewhere else.
|
|
30
|
+
*
|
|
31
|
+
* Both columns lead `UNIQUE (subjectType, subjectId, entitlement)`, so this stays the one indexed lookup it
|
|
32
|
+
* has to be — the index answers the whole predicate rather than filtering a kind out afterwards.
|
|
33
|
+
*/
|
|
34
|
+
export async function resolveEntitlements(
|
|
35
|
+
db: PaymentsDatabase,
|
|
36
|
+
subject: PaymentsSubject,
|
|
37
|
+
now: Date,
|
|
38
|
+
): Promise<readonly Entitlement[]> {
|
|
39
|
+
const rows = await db
|
|
40
|
+
.selectFrom(PAYMENTS_ENTITLEMENTS_TABLE)
|
|
41
|
+
.selectAll()
|
|
42
|
+
.where("subjectType", "=", subject.subjectType)
|
|
43
|
+
.where("subjectId", "=", subject.subjectId)
|
|
44
|
+
.orderBy("entitlement")
|
|
45
|
+
.execute();
|
|
46
|
+
|
|
47
|
+
return rows.map((row) => {
|
|
48
|
+
const entitlement = PaymentsEntitlement.parse(row);
|
|
49
|
+
const lapsed = entitlement.expiresAt !== null && entitlement.expiresAt.getTime() <= now.getTime();
|
|
50
|
+
return {
|
|
51
|
+
key: entitlement.entitlement,
|
|
52
|
+
active: entitlement.active && !lapsed,
|
|
53
|
+
expiresAt: entitlement.expiresAt,
|
|
54
|
+
source: entitlement.sourcePurchaseId,
|
|
55
|
+
};
|
|
56
|
+
});
|
|
57
|
+
}
|