@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,717 @@
|
|
|
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 { PithyError } from "@pithy-sh/core/src/error/pithyError";
|
|
7
|
+
import type { PaymentsConfig } from "../config/config";
|
|
8
|
+
import { railEnabled } from "../config/config";
|
|
9
|
+
import type { PurchaseEnvironment } from "../data/purchase";
|
|
10
|
+
import { decodeSubjectReference } from "../data/subject";
|
|
11
|
+
import { PADDLE_EVENTS_CURSOR, PaymentsSyncCursor } from "../data/syncCursor";
|
|
12
|
+
import { PAYMENTS_SYNC_CURSORS_TABLE, PAYMENTS_WEBHOOK_EVENTS_TABLE, paymentsDatabase } from "../data/tables";
|
|
13
|
+
import {
|
|
14
|
+
isWebhookEventOutstanding,
|
|
15
|
+
PaymentsWebhookEvent,
|
|
16
|
+
WEBHOOK_EVENT_ORPHANED,
|
|
17
|
+
webhookEventState,
|
|
18
|
+
} from "../data/webhookEvent";
|
|
19
|
+
import { fulfillPurchase } from "../grants/apply";
|
|
20
|
+
import { repairOrphanedEvents } from "../projection/orphans";
|
|
21
|
+
import { linkProviderAccount, resolveNotificationOwner } from "../projection/owner";
|
|
22
|
+
import { projectPurchase } from "../projection/writer";
|
|
23
|
+
import { noteIsRepairable, noteText } from "../rails/contract";
|
|
24
|
+
import { type PaddleEnvironment, type PaddleHttpFetch, paddleHttpFetch } from "../rails/paddle/api";
|
|
25
|
+
import { PADDLE_EVENT_RETENTION_DAYS, sweepPaddleEvents } from "../rails/paddle/events";
|
|
26
|
+
import { PaddleEvent } from "../rails/paddle/objects";
|
|
27
|
+
import { PADDLE_ADJUSTMENTS_INCLUDE, readTransaction } from "../rails/paddle/read";
|
|
28
|
+
import { readPaddleEvent } from "../rails/paddle/webhook";
|
|
29
|
+
import type { PaymentsPaddleCredentials } from "../secret/registry";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The Paddle events sweep, as a reconciliation step.
|
|
33
|
+
*
|
|
34
|
+
* `refresh` re-reads rows this deployment already holds. A purchase whose webhook was never delivered and
|
|
35
|
+
* which no client submitted has **no row**, so it is invisible to `refresh` forever. Paddle publishes an
|
|
36
|
+
* account-wide event stream, so this rail can find those — and no other rail in this package can.
|
|
37
|
+
*
|
|
38
|
+
* ## Why it writes through the webhook table rather than around it
|
|
39
|
+
*
|
|
40
|
+
* A swept event and its webhook are the *same event*: Paddle's stream carries `event_id` and no
|
|
41
|
+
* `notification_id`, so the id here is the id the delivery would have recorded. Writing through
|
|
42
|
+
* `pithy_payments_webhook_events` with the same `UNIQUE (rail, providerEventId)` is therefore what makes
|
|
43
|
+
* the sweep idempotent against the webhook path *and* against itself — a sweep of an event already
|
|
44
|
+
* delivered inserts nothing and projects nothing.
|
|
45
|
+
*
|
|
46
|
+
* It also keeps one answer to "did we hear about this": an operator reading that table sees every event
|
|
47
|
+
* this deployment acted on, whichever door it came through.
|
|
48
|
+
*
|
|
49
|
+
* ## What a failed event costs, and for how long
|
|
50
|
+
*
|
|
51
|
+
* Two failures are available here and they are mirror images, so neither rule on its own is right.
|
|
52
|
+
*
|
|
53
|
+
* **Always skip.** Advancing past a failure turns a transient D1 fault into a permanently skipped
|
|
54
|
+
* purchase. There is no second pass that would find it — that is the whole reason this sweep exists.
|
|
55
|
+
*
|
|
56
|
+
* **Always halt.** Leaving the cursor in front of the failure and retrying forever is correct for a
|
|
57
|
+
* transient fault and wrong for a permanent one. A malformed event, a deleted transaction, a shape Paddle
|
|
58
|
+
* changed — none of them clears on the next attempt, and the sweep then stands in front of that event for
|
|
59
|
+
* good, so *every later purchase* becomes the one nothing ever finds. That is the same defect with the
|
|
60
|
+
* blame moved.
|
|
61
|
+
*
|
|
62
|
+
* So this halts a **bounded** number of times and then quarantines: see {@link PADDLE_SWEEP_MAX_ATTEMPTS},
|
|
63
|
+
* {@link fail} and {@link quarantine}. Under the bound, both timestamps stay null and the cursor stops in
|
|
64
|
+
* front of the event, so the next run tries it again. At the bound, `abandonedAt` is stamped with an error
|
|
65
|
+
* naming the quarantine and the attempts that earned it, the run's report names the event id, and the
|
|
66
|
+
* cursor moves on. A row nobody can find is the same defect wearing the other hat, so the quarantine is
|
|
67
|
+
* written in words on the row rather than being the absence of one.
|
|
68
|
+
*
|
|
69
|
+
* ## A quarantine bounds a stall; it does not end an event
|
|
70
|
+
*
|
|
71
|
+
* `abandonedAt` and `processedAt` are separate columns for one reason (#337). "This pass has given up" and
|
|
72
|
+
* "this event is finished with" are different claims, and the webhook guard short-circuits on the second.
|
|
73
|
+
* While the quarantine wrote `processedAt`, it also answered Paddle's ordinary retry — and an operator's
|
|
74
|
+
* replay, which reuses the same `event_id` — with `duplicate`, so a quarantined purchase could never be
|
|
75
|
+
* projected even after somebody fixed the cause. A bounded retry that is silently terminal is just the
|
|
76
|
+
* "always skip" rule with extra steps.
|
|
77
|
+
*
|
|
78
|
+
* So an abandoned row is invisible to *this* sweep, which is what stops the attempt count restarting, and
|
|
79
|
+
* fully visible to the webhook path, which is what repairs it. What this sweep repairs is a webhook that
|
|
80
|
+
* was never delivered at all; what repairs a delivery that arrived and failed is the next delivery, and
|
|
81
|
+
* `completeWebhook` no longer writes `processedAt` beside a repairable error.
|
|
82
|
+
*
|
|
83
|
+
* ## An orphan is abandoned, not finished (#339)
|
|
84
|
+
*
|
|
85
|
+
* An orphan is an event whose `custom_data` carries no usable `pithy_user` stamp, for a customer with no
|
|
86
|
+
* `provider_accounts` row. "No usable stamp" now includes a stamp that does not **decode**: the value is an
|
|
87
|
+
* encoded subject reference (`user:ada`, `organization:acme`), and a bare id — the shape every pre-subject
|
|
88
|
+
* checkout wrote — names nobody however well it is proven. This sweep used to call {@link complete} on an
|
|
89
|
+
* orphan — the same `processedAt` that means *finished with*.
|
|
90
|
+
*
|
|
91
|
+
* That was wrong, and it was wrong in the one way that costs a purchase. The webhook path treats its own
|
|
92
|
+
* orphans as outstanding, and both paths write the **same row** under `UNIQUE (rail, providerEventId)`, so
|
|
93
|
+
* whichever ran second decided. When the sweep ran first, the link then arrived and Paddle redelivered, the
|
|
94
|
+
* guard read `processedAt`, answered `duplicate`, and the purchase was never projected. An orphan is the
|
|
95
|
+
* textbook repairable delivery: nothing about the event is wrong, the world is simply missing a row that a
|
|
96
|
+
* checkout, a client submission or an operator will add.
|
|
97
|
+
*
|
|
98
|
+
* The tension is real, though, and it is why `complete` looked reasonable. Leaving an orphan *outstanding*
|
|
99
|
+
* makes {@link record} count it fresh every run, and {@link fail} halts the cursor in front of it — so one
|
|
100
|
+
* customer who never links stalls every event behind them, daily, for as long as the deployment runs.
|
|
101
|
+
*
|
|
102
|
+
* `abandonedAt` is the state that already resolves exactly this, so it is used rather than a fourth one
|
|
103
|
+
* invented for the occasion: abandoned is **invisible to this sweep**, so the cursor advances and nothing
|
|
104
|
+
* stalls, and **not finished to the webhook guard**, so the redelivery that follows the link still repairs
|
|
105
|
+
* it. See {@link abandon}. Unlike a quarantine it costs no attempts, because an orphan is not a failure
|
|
106
|
+
* being retried — no number of sweeps conjures a link, so the first look is as informed as the tenth.
|
|
107
|
+
*/
|
|
108
|
+
|
|
109
|
+
/** What the sweep needs. Every seam is explicit, so a test drives it without stubbing a module. */
|
|
110
|
+
export interface PaddleSweepDeps {
|
|
111
|
+
/** The app database the `pithy_payments_*` tables live in. */
|
|
112
|
+
d1: D1Database;
|
|
113
|
+
/** The resolved catalog — the writer's product lookup comes from it. */
|
|
114
|
+
config: PaymentsConfig;
|
|
115
|
+
/** This deployment's store environment. A swept sandbox purchase is refused in production, as ever. */
|
|
116
|
+
environment: PurchaseEnvironment;
|
|
117
|
+
/** Paddle's credentials, resolved through the secrets store by the caller. */
|
|
118
|
+
credentials: PaymentsPaddleCredentials;
|
|
119
|
+
/** Which Paddle account to sweep. */
|
|
120
|
+
paddleEnvironment: PaddleEnvironment;
|
|
121
|
+
/** This deployment's `ENVIRONMENT`, for the shared-sandbox fence and the ownership proof. */
|
|
122
|
+
deployment?: string;
|
|
123
|
+
/** The clock. */
|
|
124
|
+
now(): Date;
|
|
125
|
+
/** The row-id minter, injected so a test is deterministic. */
|
|
126
|
+
newId?: () => string;
|
|
127
|
+
/** The HTTP seam. */
|
|
128
|
+
transport?: PaddleHttpFetch;
|
|
129
|
+
/** Fulfillment for a swept purchase. Defaults to {@link fulfillPurchase}, and is safe to repeat. */
|
|
130
|
+
fulfill?: (d1: D1Database, projection: Parameters<typeof fulfillPurchase>[1]) => Promise<unknown>;
|
|
131
|
+
/** How many pages one sweep may walk. A bound on the work, so a first pass finishes. */
|
|
132
|
+
maxPages?: number;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** What one sweep did. Returned through a `step.do`, so a replay restores it exactly. */
|
|
136
|
+
export interface PaddleSweepReport {
|
|
137
|
+
/** Events read from the stream. */
|
|
138
|
+
read: number;
|
|
139
|
+
/** Events that produced a purchase row. */
|
|
140
|
+
projected: number;
|
|
141
|
+
/** Events that were authentic and projected nothing — a fenced delivery, a type the map ignores. */
|
|
142
|
+
ignored: number;
|
|
143
|
+
/**
|
|
144
|
+
* Events with a purchase in them and nobody to project it against, walked past and left repairable.
|
|
145
|
+
*
|
|
146
|
+
* **Paddle's own `evt_…` ids, and its own field rather than a share of `ignored`.** An ignored event is
|
|
147
|
+
* one this build was never going to act on; an orphan is a real purchase waiting on a link that has not
|
|
148
|
+
* arrived. Counting the second as the first is how {@link complete} came to be called on one — see the
|
|
149
|
+
* module doc — and a number an operator reads as healthy is the wrong place to hide a stuck sale.
|
|
150
|
+
*
|
|
151
|
+
* Each entry is also a row carrying `abandonedAt` and its reason, repairable by any delivery of the same
|
|
152
|
+
* event id once the link exists. **Named here because this sweep will not look again**, so the report is
|
|
153
|
+
* where an operator learns there is a sale to chase — `refresh` cannot find it either, since an orphan
|
|
154
|
+
* has no purchase row to re-read.
|
|
155
|
+
*/
|
|
156
|
+
orphaned: string[];
|
|
157
|
+
/** Events already recorded, so the sweep found nothing new. This is the healthy number. */
|
|
158
|
+
duplicate: number;
|
|
159
|
+
/** Events that could not be projected this run. Advancement stops at the first one that is not quarantined. */
|
|
160
|
+
failed: number;
|
|
161
|
+
/**
|
|
162
|
+
* Events given up on this run — tried {@link PADDLE_SWEEP_MAX_ATTEMPTS} times and walked past.
|
|
163
|
+
*
|
|
164
|
+
* **Paddle's own `evt_…` ids, not a count.** A count says an operator has something to read and then
|
|
165
|
+
* makes them go and find it; the id is what they replay, what they grep the table for, and what they
|
|
166
|
+
* paste into Paddle's replay endpoint. `length` is still the count for a log line.
|
|
167
|
+
*
|
|
168
|
+
* Never empty quietly: each entry is also a row carrying `abandonedAt` and its reason. A run reporting
|
|
169
|
+
* quarantines is a run that moved a purchase out of *its own* repair path, which is a decision somebody
|
|
170
|
+
* should see rather than infer.
|
|
171
|
+
*/
|
|
172
|
+
quarantined: string[];
|
|
173
|
+
/** Where the cursor now stands, or null when it has never advanced. */
|
|
174
|
+
cursor: string | null;
|
|
175
|
+
/** A gap this run cannot close — a cursor past Paddle's retention — or null. */
|
|
176
|
+
gap: string | null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** How many pages one sweep walks by default. Two hundred events a page, so this is 2,000 events. */
|
|
180
|
+
const DEFAULT_MAX_PAGES = 10;
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* How many sweeps may try one event before it is quarantined and the stream moves on.
|
|
184
|
+
*
|
|
185
|
+
* **The unit is a run, and this sweep runs on a daily cron** — so three attempts is three days. That is
|
|
186
|
+
* long enough for the faults a retry actually fixes: a D1 blip, a Paddle outage, a price somebody has to
|
|
187
|
+
* add to the catalog after being paged. It is short enough that a fault no retry will ever fix costs the
|
|
188
|
+
* stream three days rather than the rest of its life.
|
|
189
|
+
*
|
|
190
|
+
* One would be "always skip" with a counter bolted on. A hundred would be "always halt" with the same.
|
|
191
|
+
*/
|
|
192
|
+
export const PADDLE_SWEEP_MAX_ATTEMPTS = 3;
|
|
193
|
+
|
|
194
|
+
/** Read the stored cursor for Paddle's event stream, or null when it has never advanced. */
|
|
195
|
+
async function readCursor(d1: D1Database): Promise<string | null> {
|
|
196
|
+
const row = await paymentsDatabase(d1)
|
|
197
|
+
.selectFrom(PAYMENTS_SYNC_CURSORS_TABLE)
|
|
198
|
+
.select(["cursor"])
|
|
199
|
+
.where("rail", "=", "paddle")
|
|
200
|
+
.where("name", "=", PADDLE_EVENTS_CURSOR)
|
|
201
|
+
.executeTakeFirst();
|
|
202
|
+
return typeof row?.cursor === "string" ? row.cursor : null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Write the cursor back, creating the row on the first sweep. */
|
|
206
|
+
async function writeCursor(d1: D1Database, cursor: string, now: Date, newId: () => string): Promise<void> {
|
|
207
|
+
const db = paymentsDatabase(d1);
|
|
208
|
+
const row = PaymentsSyncCursor.encode({
|
|
209
|
+
id: newId(),
|
|
210
|
+
rail: "paddle",
|
|
211
|
+
name: PADDLE_EVENTS_CURSOR,
|
|
212
|
+
cursor,
|
|
213
|
+
updatedAt: now,
|
|
214
|
+
createdAt: now,
|
|
215
|
+
});
|
|
216
|
+
await withD1Retry(() =>
|
|
217
|
+
db
|
|
218
|
+
.insertInto(PAYMENTS_SYNC_CURSORS_TABLE)
|
|
219
|
+
// biome-ignore lint/suspicious/noExplicitAny: an encoded row; Kysely's insert type derives from z.input.
|
|
220
|
+
.values(row as any)
|
|
221
|
+
// One cursor per stream: a second row would let two runs each believe they were authoritative.
|
|
222
|
+
.onConflict((oc) => oc.columns(["rail", "name"]).doUpdateSet({ cursor, updatedAt: now.getTime() } as never))
|
|
223
|
+
.execute(),
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Record one swept event, or report that it was already recorded.
|
|
229
|
+
*
|
|
230
|
+
* The same `UNIQUE (rail, providerEventId)` insert the webhook guard makes, against the same table and the
|
|
231
|
+
* same key. That is what makes a sweep of an already-delivered event a no-op rather than a second write.
|
|
232
|
+
*
|
|
233
|
+
* **`fresh` means "this pass still has work to do here", which is not the same as "not yet seen".** It asks
|
|
234
|
+
* {@link isWebhookEventOutstanding}, so an event this sweep recorded and then failed to project — left
|
|
235
|
+
* `pending` or `failed` by {@link fail} — comes back fresh and is tried again, while one that was
|
|
236
|
+
* projected, fenced out, or is a type that projects nothing is finished and does not.
|
|
237
|
+
*
|
|
238
|
+
* **An orphan is not fresh either, and it is not finished — see {@link orphan}.** A row the *webhook* left
|
|
239
|
+
* outstanding as an orphan still is, so the sweep picks it up, re-resolves the owner against a table that
|
|
240
|
+
* may have gained the link since, and either projects it or abandons it. That re-resolution is the one
|
|
241
|
+
* thing a second look at an orphan is worth, and it happens once.
|
|
242
|
+
*
|
|
243
|
+
* **A quarantined event is not fresh either, and that is a different reason.** It is still outstanding to a
|
|
244
|
+
* webhook delivery, which must be allowed to repair it; it is not outstanding to *this* pass, because the
|
|
245
|
+
* bound exists so one unprojectable event cannot hold the stream up for ever, and picking its own
|
|
246
|
+
* quarantines back up would restart the count and stall again. Two readers, two questions — see
|
|
247
|
+
* `data/webhookEvent.ts`.
|
|
248
|
+
*
|
|
249
|
+
* `attempts` comes back with it, because how many times this event has already been tried is what decides
|
|
250
|
+
* whether the next failure halts the stream or gives up on it.
|
|
251
|
+
*/
|
|
252
|
+
async function record(
|
|
253
|
+
d1: D1Database,
|
|
254
|
+
eventId: string,
|
|
255
|
+
payload: Record<string, unknown>,
|
|
256
|
+
now: Date,
|
|
257
|
+
newId: () => string,
|
|
258
|
+
): Promise<{ id: string; fresh: boolean; attempts: number }> {
|
|
259
|
+
const db = paymentsDatabase(d1);
|
|
260
|
+
const row = PaymentsWebhookEvent.encode({
|
|
261
|
+
id: newId(),
|
|
262
|
+
rail: "paddle",
|
|
263
|
+
providerEventId: eventId,
|
|
264
|
+
payload,
|
|
265
|
+
receivedAt: now,
|
|
266
|
+
processedAt: null,
|
|
267
|
+
abandonedAt: null,
|
|
268
|
+
error: null,
|
|
269
|
+
attempts: 0,
|
|
270
|
+
createdAt: now,
|
|
271
|
+
});
|
|
272
|
+
await withD1Retry(() =>
|
|
273
|
+
db
|
|
274
|
+
.insertInto(PAYMENTS_WEBHOOK_EVENTS_TABLE)
|
|
275
|
+
// biome-ignore lint/suspicious/noExplicitAny: an encoded row; Kysely's insert type derives from z.input.
|
|
276
|
+
.values(row as any)
|
|
277
|
+
// `DO NOTHING`: the first record is the one worth keeping, and overwriting would erase the `error`
|
|
278
|
+
// explaining why the first attempt failed.
|
|
279
|
+
.onConflict((oc) => oc.columns(["rail", "providerEventId"]).doNothing())
|
|
280
|
+
.execute(),
|
|
281
|
+
);
|
|
282
|
+
const stored = await db
|
|
283
|
+
.selectFrom(PAYMENTS_WEBHOOK_EVENTS_TABLE)
|
|
284
|
+
.select(["id", "processedAt", "abandonedAt", "error", "attempts"])
|
|
285
|
+
.where("rail", "=", "paddle")
|
|
286
|
+
.where("providerEventId", "=", eventId)
|
|
287
|
+
.executeTakeFirst();
|
|
288
|
+
return {
|
|
289
|
+
id: stored?.id ?? row.id,
|
|
290
|
+
fresh: stored === undefined || isWebhookEventOutstanding(webhookEventState(stored)),
|
|
291
|
+
// The column is `NOT NULL DEFAULT 0`; the coalesce covers the row this call just inserted being read
|
|
292
|
+
// back before the default is materialised, and a row the webhook path wrote before the column existed.
|
|
293
|
+
attempts: stored?.attempts ?? 0,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Mark a recorded event **handled**, with the note when it was handled by deciding to do nothing.
|
|
299
|
+
*
|
|
300
|
+
* `processedAt` is the column the webhook guard short-circuits on, so it is set only where the event is
|
|
301
|
+
* genuinely finished with: projected, fenced out, or a type that projects nothing. A failure is not one of
|
|
302
|
+
* those — see {@link fail} — and neither is a quarantine, see {@link quarantine}.
|
|
303
|
+
*
|
|
304
|
+
* **Nor is an orphan, which is the whole of #339.** It reads like one — the sweep can do nothing more with
|
|
305
|
+
* it, so "handled" is tempting — but the guard reads this column, and the event's repair is precisely the
|
|
306
|
+
* redelivery that follows the missing link. See {@link abandon} and the module doc.
|
|
307
|
+
*/
|
|
308
|
+
async function complete(d1: D1Database, id: string, now: Date, note?: string): Promise<void> {
|
|
309
|
+
await withD1Retry(() =>
|
|
310
|
+
paymentsDatabase(d1)
|
|
311
|
+
.updateTable(PAYMENTS_WEBHOOK_EVENTS_TABLE)
|
|
312
|
+
// biome-ignore lint/suspicious/noExplicitAny: encoded column values, not the app shape.
|
|
313
|
+
.set({ processedAt: now.getTime(), error: note ?? null } as any)
|
|
314
|
+
.where("id", "=", id)
|
|
315
|
+
.execute(),
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Record why an event could not be projected, count the attempt, and **leave both timestamps null**.
|
|
321
|
+
*
|
|
322
|
+
* The nulls are the whole of it. A `processedAt` beside the error would say "finished", and an
|
|
323
|
+
* `abandonedAt` would say "given up on"; either would stop {@link record} counting the event outstanding,
|
|
324
|
+
* the next sweep would advance the cursor past it, and no pass would ever look at it again — a transient
|
|
325
|
+
* D1 fault turned into a permanently lost purchase. Two nulls with a non-null `error` is the honest
|
|
326
|
+
* triple: it arrived, it was tried, it failed, and it is still outstanding to everyone.
|
|
327
|
+
*
|
|
328
|
+
* `receivedAt` beside a null `processedAt` is already the package's documented drift signal, so this
|
|
329
|
+
* reports through a channel operators read rather than inventing a second one.
|
|
330
|
+
*/
|
|
331
|
+
async function fail(d1: D1Database, id: string, attempt: number, error: string): Promise<void> {
|
|
332
|
+
await withD1Retry(() =>
|
|
333
|
+
paymentsDatabase(d1)
|
|
334
|
+
.updateTable(PAYMENTS_WEBHOOK_EVENTS_TABLE)
|
|
335
|
+
// biome-ignore lint/suspicious/noExplicitAny: encoded column values, not the app shape.
|
|
336
|
+
.set({ processedAt: null, error, attempts: attempt } as any)
|
|
337
|
+
.where("id", "=", id)
|
|
338
|
+
.execute(),
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** The word an operator greps `error` for. One string, so the writer and a runbook cannot drift apart. */
|
|
343
|
+
const QUARANTINED = "quarantined";
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* The word for the other reason this sweep walks away from an event, likewise greppable.
|
|
347
|
+
*
|
|
348
|
+
* Shared with the webhook handler through `data/webhookEvent.ts` rather than spelled here, because the relink
|
|
349
|
+
* repair queries on it: two writers with two sentences for one condition is a set that cannot be selected.
|
|
350
|
+
*/
|
|
351
|
+
const ORPHANED = WEBHOOK_EVENT_ORPHANED;
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Stamp `abandonedAt` and say in the row why — the one writer for *this pass has walked away from this*.
|
|
355
|
+
*
|
|
356
|
+
* **The state that is neither finished nor outstanding, and the reason there are two columns.**
|
|
357
|
+
* {@link isWebhookEventOutstanding} reads it false, so this sweep's next run does not pick the event back
|
|
358
|
+
* up and its cursor is free to advance. {@link isWebhookEventFinished} reads it false, so a later delivery
|
|
359
|
+
* of the same event — the provider's retry, or an operator's replay, both reusing the original event id —
|
|
360
|
+
* still runs the handler and can project the purchase.
|
|
361
|
+
*
|
|
362
|
+
* Two callers reach it for two different reasons and both want exactly that pair of answers: a quarantine,
|
|
363
|
+
* which is a bounded retry giving up, and an orphan, which never had anything to retry. `attempts` is
|
|
364
|
+
* written only when a caller counted any, so an orphan's row does not claim a failure it never had.
|
|
365
|
+
*/
|
|
366
|
+
async function abandon(d1: D1Database, id: string, now: Date, error: string, attempts?: number): Promise<void> {
|
|
367
|
+
await withD1Retry(() =>
|
|
368
|
+
paymentsDatabase(d1)
|
|
369
|
+
.updateTable(PAYMENTS_WEBHOOK_EVENTS_TABLE)
|
|
370
|
+
.set({
|
|
371
|
+
abandonedAt: now.getTime(),
|
|
372
|
+
error,
|
|
373
|
+
...(attempts === undefined ? {} : { attempts }),
|
|
374
|
+
// biome-ignore lint/suspicious/noExplicitAny: encoded column values, not the app shape.
|
|
375
|
+
} as any)
|
|
376
|
+
.where("id", "=", id)
|
|
377
|
+
.execute(),
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Walk past an event with a purchase in it and nobody to project it against, and leave it repairable.
|
|
383
|
+
*
|
|
384
|
+
* **Not {@link complete}, and that reversal is #339.** Writing `processedAt` here told the webhook guard the
|
|
385
|
+
* event was finished, and the two paths share one row — so a sweep that reached an orphan first made the
|
|
386
|
+
* redelivery that arrives *after* the account links answer `duplicate`, and the purchase was never
|
|
387
|
+
* projected. The one repair path for an orphan is the very delivery that write silenced.
|
|
388
|
+
*
|
|
389
|
+
* Not {@link fail} either, and that is the other half. A failure halts the cursor so the next run retries,
|
|
390
|
+
* which is right for a fault that might clear; an orphan clears when a *link* appears, and no amount of
|
|
391
|
+
* re-reading this event produces one. Halting would stall every event behind one customer, daily, for good.
|
|
392
|
+
*
|
|
393
|
+
* So: abandoned. Invisible to this sweep, still repairable by a delivery. The row says which of the two
|
|
394
|
+
* reasons put it there, and names the command for an operator who would rather not wait for a redelivery.
|
|
395
|
+
*/
|
|
396
|
+
async function orphan(d1: D1Database, id: string, now: Date): Promise<void> {
|
|
397
|
+
await abandon(
|
|
398
|
+
d1,
|
|
399
|
+
id,
|
|
400
|
+
now,
|
|
401
|
+
`${ORPHANED} no subject could be resolved for this swept event, so the sweep has moved past it. It is not finished — the account linking re-examines it (see \`projection/orphans.ts\`), and any later delivery of this event id projects it too, including a replay from Paddle.`,
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Give up on an event: stamp `abandonedAt` so this pass moves past it, and say in the row why.
|
|
407
|
+
*
|
|
408
|
+
* **`abandonedAt`, not `processedAt`, and the two columns are the whole fix for #337.** What this sweep
|
|
409
|
+
* needs is for its own next run to stop counting the event fresh and restarting the attempt count — that is
|
|
410
|
+
* a fact about *this pass*, and {@link record} reads it through {@link isWebhookEventOutstanding}. What it
|
|
411
|
+
* emphatically must not do is tell the webhook guard the event is finished: writing `processedAt` here made
|
|
412
|
+
* a quarantine answer Paddle's ordinary retry, and an operator's replay, with `duplicate` — so a
|
|
413
|
+
* quarantined purchase could never be projected even once its cause was fixed. A quarantine bounds a
|
|
414
|
+
* stall; it was never meant to be terminal.
|
|
415
|
+
*
|
|
416
|
+
* The row still says so in words. `error` names {@link QUARANTINED}, the attempt count, and the last
|
|
417
|
+
* failure that earned it, so an operator reading the table has the reason and not only a timestamp.
|
|
418
|
+
*/
|
|
419
|
+
async function quarantine(d1: D1Database, id: string, attempt: number, now: Date, error: string): Promise<void> {
|
|
420
|
+
await abandon(
|
|
421
|
+
d1,
|
|
422
|
+
id,
|
|
423
|
+
now,
|
|
424
|
+
`${QUARANTINED}: after ${attempt} attempts; the sweep has moved past it. Last failure: ${error}`,
|
|
425
|
+
attempt,
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Count one failed attempt against an event, and say whether the sweep may walk past it.
|
|
431
|
+
*
|
|
432
|
+
* `true` means quarantined — the caller advances its cursor. `false` means the bound has not been reached,
|
|
433
|
+
* so the caller halts in front of the event and the next run tries again.
|
|
434
|
+
*/
|
|
435
|
+
async function attemptFailed(
|
|
436
|
+
d1: D1Database,
|
|
437
|
+
id: string,
|
|
438
|
+
attempts: number,
|
|
439
|
+
now: Date,
|
|
440
|
+
reason: string,
|
|
441
|
+
): Promise<boolean> {
|
|
442
|
+
const attempt = attempts + 1;
|
|
443
|
+
if (attempt < PADDLE_SWEEP_MAX_ATTEMPTS) {
|
|
444
|
+
await fail(d1, id, attempt, reason);
|
|
445
|
+
return false;
|
|
446
|
+
}
|
|
447
|
+
await quarantine(d1, id, attempt, now, reason);
|
|
448
|
+
return true;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Project the orphans this link just made resolvable, and never let that fail the sweep.
|
|
453
|
+
*
|
|
454
|
+
* The events are gone from this pass's point of view — the cursor moved past them the run they were
|
|
455
|
+
* abandoned — so an account linking is the only signal left that anything changed. The repair replays each
|
|
456
|
+
* row's own recorded payload through the same map this sweep already uses, resolves the owner against the
|
|
457
|
+
* table the link just landed in, and finishes only what it actually projected. See `projection/orphans.ts`.
|
|
458
|
+
*
|
|
459
|
+
* Swallowing here rather than in the repair: a sweep is a Workflow step, and a step that throws is retried
|
|
460
|
+
* from the top — re-reading a page of Paddle events to repair a row that was never this page's business.
|
|
461
|
+
*/
|
|
462
|
+
async function repairOrphans(deps: PaddleSweepDeps, at: Date): Promise<void> {
|
|
463
|
+
const base = {
|
|
464
|
+
credentials: deps.credentials,
|
|
465
|
+
environment: deps.paddleEnvironment,
|
|
466
|
+
transport: deps.transport ?? paddleHttpFetch,
|
|
467
|
+
};
|
|
468
|
+
await repairOrphanedEvents(deps.d1, "paddle", {
|
|
469
|
+
config: deps.config,
|
|
470
|
+
environment: deps.environment,
|
|
471
|
+
now: at,
|
|
472
|
+
replay: async (payload) => {
|
|
473
|
+
const parsed = PaddleEvent.safeParse(payload);
|
|
474
|
+
if (!parsed.success) return undefined;
|
|
475
|
+
return await readPaddleEvent(parsed.data, {
|
|
476
|
+
credentials: deps.credentials,
|
|
477
|
+
environment: deps.paddleEnvironment,
|
|
478
|
+
now: at,
|
|
479
|
+
deployment: deps.deployment,
|
|
480
|
+
readTransaction: (id) => readTransaction(id, base, PADDLE_ADJUSTMENTS_INCLUDE),
|
|
481
|
+
});
|
|
482
|
+
},
|
|
483
|
+
fulfill: async (projection) => {
|
|
484
|
+
const fulfill =
|
|
485
|
+
deps.fulfill ?? ((d1, value) => fulfillPurchase(d1, value, { config: deps.config, now: () => at.getTime() }));
|
|
486
|
+
await fulfill(deps.d1, projection);
|
|
487
|
+
},
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** What to write in `error` for a thrown cause. A `PithyError`'s `detail` is throw-site context, not client text. */
|
|
492
|
+
function reasonFor(cause: unknown): string {
|
|
493
|
+
if (cause instanceof PithyError) return `${cause.payload.code}: ${cause.payload.detail ?? cause.payload.message}`;
|
|
494
|
+
return "projection failed";
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Sweep Paddle's event stream and project what it finds.
|
|
499
|
+
*
|
|
500
|
+
* Returns a report rather than throwing on a projection failure: one unprojectable event must not lose the
|
|
501
|
+
* run's whole progress, and the cursor stopping in front of it is what makes it repairable. A store that
|
|
502
|
+
* cannot be *reached* still throws, so the step fails and retries rather than recording a sweep that never
|
|
503
|
+
* happened.
|
|
504
|
+
*/
|
|
505
|
+
export async function sweepPaddle(deps: PaddleSweepDeps): Promise<PaddleSweepReport> {
|
|
506
|
+
const newId = deps.newId ?? (() => crypto.randomUUID());
|
|
507
|
+
const report: PaddleSweepReport = {
|
|
508
|
+
read: 0,
|
|
509
|
+
projected: 0,
|
|
510
|
+
ignored: 0,
|
|
511
|
+
orphaned: [],
|
|
512
|
+
duplicate: 0,
|
|
513
|
+
failed: 0,
|
|
514
|
+
quarantined: [],
|
|
515
|
+
cursor: null,
|
|
516
|
+
gap: null,
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
if (!railEnabled(deps.config, "paddle")) return report;
|
|
520
|
+
|
|
521
|
+
let cursor = await readCursor(deps.d1);
|
|
522
|
+
report.cursor = cursor;
|
|
523
|
+
const maxPages = deps.maxPages ?? DEFAULT_MAX_PAGES;
|
|
524
|
+
|
|
525
|
+
for (let page = 0; page < maxPages; page += 1) {
|
|
526
|
+
const now = deps.now();
|
|
527
|
+
const swept = await sweepPaddleEvents({
|
|
528
|
+
credentials: deps.credentials,
|
|
529
|
+
environment: deps.paddleEnvironment,
|
|
530
|
+
now,
|
|
531
|
+
deployment: deps.deployment,
|
|
532
|
+
cursor: cursor ?? undefined,
|
|
533
|
+
transport: deps.transport,
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
if (swept.gap !== null) {
|
|
537
|
+
// A cursor Paddle no longer knows: the retention cliff. Reported, and the cursor is left exactly
|
|
538
|
+
// where it stands — restarting would re-project ninety days, and silence would lose the gap.
|
|
539
|
+
report.gap = swept.gap;
|
|
540
|
+
return report;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
if (swept.events.length === 0) return report;
|
|
544
|
+
|
|
545
|
+
for (const event of swept.events) {
|
|
546
|
+
report.read += 1;
|
|
547
|
+
const at = deps.now();
|
|
548
|
+
|
|
549
|
+
// **Before the allowlist check, because both leave `notification` null and they mean opposite
|
|
550
|
+
// things.** A withheld type is walked past on purpose; an unreadable one is a failure that must be
|
|
551
|
+
// recorded and counted. Reading them in the other order files every unreadable event as "ignored"
|
|
552
|
+
// and advances the cursor over it — losing exactly the purchase this sweep exists to find.
|
|
553
|
+
if (event.failure !== null) {
|
|
554
|
+
// The event read cleanly out of the stream and then its *second* read — the transaction an
|
|
555
|
+
// adjustment names — failed. Recorded so it has a row, a reason and an attempt count, then handled
|
|
556
|
+
// by exactly the same bound as a projection failure: there is no reason one kind of unrepairable
|
|
557
|
+
// event should hold the stream up forever and the other should not.
|
|
558
|
+
const { id, fresh, attempts } = await record(deps.d1, event.eventId, event.failure.payload, at, newId);
|
|
559
|
+
if (!fresh) {
|
|
560
|
+
report.duplicate += 1;
|
|
561
|
+
cursor = event.eventId;
|
|
562
|
+
continue;
|
|
563
|
+
}
|
|
564
|
+
report.failed += 1;
|
|
565
|
+
if (await attemptFailed(deps.d1, id, attempts, at, reasonFor(event.failure.cause))) {
|
|
566
|
+
report.quarantined.push(event.eventId);
|
|
567
|
+
cursor = event.eventId;
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
report.cursor = cursor;
|
|
571
|
+
if (cursor !== null) await writeCursor(deps.d1, cursor, at, newId);
|
|
572
|
+
return report;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
if (event.notification === null) {
|
|
576
|
+
// A type the query asked Paddle to withhold and Paddle returned anyway. Not recorded — that is the
|
|
577
|
+
// point of the allowlist, and `client_token.created` carries a live token. The cursor still
|
|
578
|
+
// advances, because an event nothing here will ever act on is behind us the moment it is read.
|
|
579
|
+
report.ignored += 1;
|
|
580
|
+
cursor = event.eventId;
|
|
581
|
+
continue;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const { notification } = event;
|
|
585
|
+
const { id, fresh, attempts } = await record(deps.d1, event.eventId, notification.payload, at, newId);
|
|
586
|
+
if (!fresh) {
|
|
587
|
+
// Already recorded and already handled — the healthy case, and the one that makes two consecutive
|
|
588
|
+
// sweeps idempotent. The cursor still advances past it: it is behind us either way.
|
|
589
|
+
report.duplicate += 1;
|
|
590
|
+
cursor = event.eventId;
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* The pairing is worth keeping even for an event that projects nothing, and it is proven — the rail
|
|
596
|
+
* returns a reference only when a MAC this deployment's secret produced sits beside it.
|
|
597
|
+
*
|
|
598
|
+
* **Decoded, never split by hand, and never read as a bare id.** `accountReference` is the one
|
|
599
|
+
* single-field slot Paddle gives us, so it carries the subject as one string, and
|
|
600
|
+
* `decodeSubjectReference` is the only reader of that format. It answers `undefined` for anything
|
|
601
|
+
* that is not exactly the encoding — including `ada`, the shape every pre-subject checkout stamped —
|
|
602
|
+
* and `undefined` here means no link is written at all. That is the safe direction: a permanent
|
|
603
|
+
* binding is what `linkProviderAccount` writes and it never rebinds, so a guessed holder is a
|
|
604
|
+
* customer's renewals delivered to a stranger for as long as the deployment lives.
|
|
605
|
+
*/
|
|
606
|
+
const referenced = notification.accountReference
|
|
607
|
+
? decodeSubjectReference(notification.accountReference)
|
|
608
|
+
: undefined;
|
|
609
|
+
if (notification.providerAccountId && referenced !== undefined) {
|
|
610
|
+
await linkProviderAccount(deps.d1, "paddle", notification.providerAccountId, referenced, { now: at });
|
|
611
|
+
await repairOrphans(deps, at);
|
|
612
|
+
// The link is what an orphan was waiting for, and this sweep will not look at those events again —
|
|
613
|
+
// its cursor is past them. So the repair runs on the signal rather than on the next pass. #341.
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
if (notification.event === null) {
|
|
617
|
+
// **A note a provider read produced is not a completion, and that is #341 on this path.** The rail
|
|
618
|
+
// reads a transaction to tell a full refund from a partial one, and a read answering "no such
|
|
619
|
+
// transaction" is exactly as repairable as one that threw — a rotated key, a shared sandbox, an
|
|
620
|
+
// adjustment swept ahead of its own transaction. So it goes through the same bound the thrown case
|
|
621
|
+
// above uses, rather than stamping `processedAt` and making Paddle's redelivery a duplicate.
|
|
622
|
+
if (noteIsRepairable(notification.note)) {
|
|
623
|
+
report.failed += 1;
|
|
624
|
+
if (await attemptFailed(deps.d1, id, attempts, at, noteText(notification.note) ?? "unreadable")) {
|
|
625
|
+
report.quarantined.push(event.eventId);
|
|
626
|
+
cursor = event.eventId;
|
|
627
|
+
continue;
|
|
628
|
+
}
|
|
629
|
+
report.cursor = cursor;
|
|
630
|
+
if (cursor !== null) await writeCursor(deps.d1, cursor, at, newId);
|
|
631
|
+
return report;
|
|
632
|
+
}
|
|
633
|
+
report.ignored += 1;
|
|
634
|
+
await complete(deps.d1, id, at, noteText(notification.note));
|
|
635
|
+
cursor = event.eventId;
|
|
636
|
+
continue;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Who holds it — **resolved from rows, because a Workflow has nothing else**.
|
|
641
|
+
*
|
|
642
|
+
* There is no request here, so no `c.var.auth` and no adopter `resolveSubject`; see
|
|
643
|
+
* `workflows/worker.ts`. Every source `resolveNotificationOwner` consults is a row this server wrote
|
|
644
|
+
* or a reference it stamped and Paddle returned, and it answers with the **pair** read off one row.
|
|
645
|
+
*/
|
|
646
|
+
const subject = await resolveNotificationOwner(paymentsDatabase(deps.d1), "paddle", {
|
|
647
|
+
providerAccountId: notification.providerAccountId,
|
|
648
|
+
providerTransactionId: notification.event.providerTransactionId,
|
|
649
|
+
originalTransactionId: notification.event.originalTransactionId,
|
|
650
|
+
});
|
|
651
|
+
if (subject === undefined) {
|
|
652
|
+
// Orphaned. No number of sweeps will conjure a link, so the cursor advances — retrying it forever
|
|
653
|
+
// would stall every event behind one customer. But the event is **not finished**: it is a real
|
|
654
|
+
// purchase waiting on a link, and the delivery that follows that link is what projects it. So
|
|
655
|
+
// `abandonedAt`, which advances this pass and leaves the guard's short-circuit shut. See #339.
|
|
656
|
+
report.orphaned.push(event.eventId);
|
|
657
|
+
await orphan(deps.d1, id, at);
|
|
658
|
+
cursor = event.eventId;
|
|
659
|
+
continue;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
try {
|
|
663
|
+
// Both halves, spread from the one object they were read into. Two events from one notification bind
|
|
664
|
+
// the identical subject for the same reason a pair is never assembled twice: a charge row and a
|
|
665
|
+
// standing row that disagreed about their holder would be one purchase owned by two accounts.
|
|
666
|
+
const projection = await projectPurchase(
|
|
667
|
+
deps.d1,
|
|
668
|
+
{ ...notification.event, ...subject },
|
|
669
|
+
{ config: deps.config, environment: deps.environment, now: at },
|
|
670
|
+
);
|
|
671
|
+
if (notification.stateEvent) {
|
|
672
|
+
await projectPurchase(
|
|
673
|
+
deps.d1,
|
|
674
|
+
{ ...notification.stateEvent, ...subject },
|
|
675
|
+
{ config: deps.config, environment: deps.environment, now: at },
|
|
676
|
+
);
|
|
677
|
+
}
|
|
678
|
+
// The same fulfillment the webhook path performs. A sweep that repaired a renewal and did not
|
|
679
|
+
// credit its coins would leave the entitlement right and the balance wrong, and nothing else would
|
|
680
|
+
// ever fix it — the renewal this sweep discovered is the one the webhook lost.
|
|
681
|
+
const fulfill =
|
|
682
|
+
deps.fulfill ?? ((d1, value) => fulfillPurchase(d1, value, { config: deps.config, now: () => at.getTime() }));
|
|
683
|
+
await fulfill(deps.d1, projection);
|
|
684
|
+
await complete(deps.d1, id, at);
|
|
685
|
+
report.projected += 1;
|
|
686
|
+
cursor = event.eventId;
|
|
687
|
+
} catch (cause) {
|
|
688
|
+
// **Advancement stops here for a bounded number of runs, and the row stays unprocessed.** Every
|
|
689
|
+
// event behind this one is unswept, which is correct while there is any prospect of the fault
|
|
690
|
+
// clearing: skipping straight past would turn a transient fault into a purchase nothing ever
|
|
691
|
+
// finds. So the row is left with a null `processedAt` and the next sweep sees it fresh.
|
|
692
|
+
//
|
|
693
|
+
// Once the attempt count reaches {@link PADDLE_SWEEP_MAX_ATTEMPTS}, the prospect is gone and
|
|
694
|
+
// holding the stream is the more expensive answer — so the event is quarantined in words on its
|
|
695
|
+
// own row and the cursor moves on. See the module doc: neither rule alone is right.
|
|
696
|
+
report.failed += 1;
|
|
697
|
+
if (await attemptFailed(deps.d1, id, attempts, at, reasonFor(cause))) {
|
|
698
|
+
report.quarantined.push(event.eventId);
|
|
699
|
+
cursor = event.eventId;
|
|
700
|
+
continue;
|
|
701
|
+
}
|
|
702
|
+
report.cursor = cursor;
|
|
703
|
+
if (cursor !== null) await writeCursor(deps.d1, cursor, at, newId);
|
|
704
|
+
return report;
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
report.cursor = cursor;
|
|
709
|
+
if (cursor !== null) await writeCursor(deps.d1, cursor, deps.now(), newId);
|
|
710
|
+
if (!swept.hasMore) return report;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
return report;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/** How long Paddle keeps an event, re-exported so a caller reporting a gap need not import two modules. */
|
|
717
|
+
export { PADDLE_EVENT_RETENTION_DAYS };
|