@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,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "payments",
|
|
3
|
+
"package": "@pithy-sh/payments",
|
|
4
|
+
"requiredBindings": [
|
|
5
|
+
{ "type": "d1", "name": "DB" },
|
|
6
|
+
{
|
|
7
|
+
"type": "workflow",
|
|
8
|
+
"name": "PAYMENTS_RECONCILE",
|
|
9
|
+
"job": "reconcile",
|
|
10
|
+
"className": "PaymentsReconcileWorkflow",
|
|
11
|
+
"optional": true
|
|
12
|
+
}
|
|
13
|
+
],
|
|
14
|
+
"peerCapabilities": ["secrets"],
|
|
15
|
+
"optionalCapabilities": ["auth", "controlplane", "ledger"],
|
|
16
|
+
"migrationNamespace": "payments",
|
|
17
|
+
"secrets": [
|
|
18
|
+
{
|
|
19
|
+
"name": "payments-provider-credentials",
|
|
20
|
+
"origin": {
|
|
21
|
+
"kind": "obtained",
|
|
22
|
+
"issuer": "other",
|
|
23
|
+
"documentation": "https://github.com/pithy-sh/pithy/blob/main/docs/commands/payments.md#where-each-rails-credentials-come-from"
|
|
24
|
+
},
|
|
25
|
+
"rotation": {
|
|
26
|
+
"kind": "manual",
|
|
27
|
+
"issuer": "other",
|
|
28
|
+
"documentation": "https://github.com/pithy-sh/pithy/blob/main/docs/commands/payments.md#where-each-rails-credentials-come-from"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
],
|
|
32
|
+
"whenToEnable": "Four payment rails — Apple, Google, Stripe, Lemon Squeezy — resolving to one cross-rail entitlement, in your own Worker and your own D1. Buy Pro on iOS, be entitled on the web, with no hosted data plane holding your purchase history. A product is not an entitlement: `pro_monthly` and `pro_annual`, across four stores' catalogs, grant one key — `pro` — and gating code names the key, never a SKU. Every write converges on one idempotent projection keyed on (rail, provider transaction id), so a client submission, a provider webhook, and a reconciliation pass produce the identical row: a dropped client call costs nothing and a replayed webhook changes nothing. The projection is monotonic on the provider's own event time, because providers do not guarantee delivery order and a stale `expired` arriving after the `renewed` that superseded it would silently revoke a paying subscriber. Sandbox purchases are tracked as sandbox and never grant a production entitlement. A cron Workflow re-verifies what the webhooks missed, because webhook-only systems rot silently. Lemon Squeezy is the merchant of record — it owns the sales tax, the EU VAT registration, the invoicing and the chargebacks, which is what a solo developer reaches for when the alternative is registering for VAT in twenty-seven countries. Add auth — every route belongs to an authenticated purchaser or a machine proving authenticity, and there are no public routes. Add secrets: the four rails' credentials are read through it, so payments will not compose without it. Ledger fulfillment is opt-in per product; most products never touch a balance.",
|
|
33
|
+
"scaffold": [
|
|
34
|
+
"Add a `payments({ rails: {...}, products: {...} })` block to pithy.config.ts. The catalog lives in config, not in D1 — a product's entitlement mapping is policy, so it belongs in git rather than in a mutable table. A new SKU needs a deploy, which is the correct trade.",
|
|
35
|
+
"Give every product a `type` (`consumable`, `non_consumable`, or `subscription`), a display `name`, the `entitlements` it grants, and one block per rail it ships on. Omit a rail to ship without it — `remove_ads` on mobile only is expressed by omission, not a flag.",
|
|
36
|
+
"Bind a D1 database named DB in wrangler.jsonc — the same app database your other capabilities use.",
|
|
37
|
+
"Run `pithy migrate` to create pithy_payments_purchases, pithy_payments_entitlements, pithy_payments_provider_accounts, pithy_payments_webhook_events, and pithy_payments_reconcile_runs.",
|
|
38
|
+
"Add `@pithy-sh/secrets` if it is not already installed, then supply each rail's credentials through it: Apple's App Store Connect key with its key and issuer ids, Google's service account, Stripe's secret key and webhook signing secret. Apple's root certificates are public and ship with the package.",
|
|
39
|
+
"Add `@pithy-sh/auth` if it is not already installed. Purchases belong to an authenticated user; with no auth capability composed every route denies.",
|
|
40
|
+
"Comping and revoking entitlements by hand is a `control-plane` surface, not an admin user, so /payments/entitlements/grant and /payments/entitlements/revoke are reached with a signed management credential rather than a session. A grant must name an entitlement key this project defines — one a product grants, or one declared in `manualEntitlements` — so a console cannot comp `pr` for `pro` and leave a customer locked out; `payments:catalog:read` is the separately-granted read that lets a console offer a list instead of a text box, and it discloses only each product's id, kind, name and keys. Add `controlplane()` to this Worker and run `pithy dashboard connect`, then grant the connection `payments:catalog:read`, `payments:entitlements:grant`, `payments:entitlements:revoke`, or only the one its tooling needs — a refund tool that cannot comp cannot be turned into one, and a comp tool cannot take paid access away from a live customer. With the seam not composed both routes answer `controlplane/not_connected`, and no app session opens either of them whatever scopes it carries.",
|
|
41
|
+
"Selling through Stripe also needs a `stripe` block naming where its hosted pages return to — `successUrl`, `cancelUrl`, `portalReturnUrl`. Required whenever the rail is on, and config rather than request input: a client that could name a return URL could send a paying customer to a page it controls. Put `{CHECKOUT_SESSION_ID}` in the success URL's query and the thank-you page can post it to /payments/purchases for an entitlement at once.",
|
|
42
|
+
"Set the account link on every purchase your app starts — Apple's appAccountToken and Google's obfuscatedAccountId, in the app's own purchase flow. A webhook arrives carrying the provider's identifier and names no holder of ours, so a purchase made without one arrives orphaned. Stripe needs nothing from you here: /payments/checkout sets `client_reference_id` from the subject the caller acts for, which is the one hook a client must never be allowed to name.",
|
|
43
|
+
"Run `pithy payments provision` to deploy the reconciliation Workflow and write its binding. Every pass it runs leaves a row in pithy_payments_reconcile_runs — started, finished, rail, environment and tally, kept ninety days — and `GET /payments/admin/reconcile-runs` reads them behind `payments:reconcile:read`, so an adopter can tell a healthy integration from one whose cron stopped firing without granting anybody the purchase log. `pithy add` touches no Cloudflare account; provisioning is the separate, explicit step. Skipping it costs nothing up front — the binding is optional and every route works without it — but webhook-only systems rot silently, so the nightly pass is what turns a dropped delivery into a repaired row rather than a support ticket.",
|
|
44
|
+
"Gate a paid route with `requireEntitlement(\"pro\")` from @pithy-sh/core. The gate lives in core, so a Worker with no payments capability composed denies rather than opens."
|
|
45
|
+
],
|
|
46
|
+
"configOptions": [
|
|
47
|
+
{
|
|
48
|
+
"key": "basePath",
|
|
49
|
+
"default": "/payments",
|
|
50
|
+
"describe": "Where the payments routes mount, webhooks included. Edit the `rails` and `products` objects by hand — the catalog maps each store's SKU to one logical product and the entitlements it grants, and it is the whole point of the capability."
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"key": "billingSubject",
|
|
54
|
+
"choices": ["user", "organization"],
|
|
55
|
+
"describe": "Who holds a subscription in this project. user — one person buys, one person is entitled. organization — a company buys, and everybody in it is entitled. It keys the entitlement column and its UNIQUE index, so changing it once anybody has subscribed is a migration, not an edit."
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
"key": "manualEntitlements",
|
|
59
|
+
"default": [],
|
|
60
|
+
"describe": "Entitlement keys the control plane may grant that no product sells — a beta flag, an internal tier, a key that exists only to be comped. Declared, because the alternative to declaring is not checking: with this empty, a manual grant of any key outside the catalog is refused. Only grants are constrained; a revoke of a key the catalog has since dropped stays legal."
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"key": "graceGrantsAccess",
|
|
64
|
+
"default": true,
|
|
65
|
+
"describe": "Whether a subscription in its billing-retry grace period still grants its entitlements. True, because that is the point of grace — a failed card should not lock a paying subscriber out mid-period. Once grace is exhausted the purchase moves to on_hold, which never grants."
|
|
66
|
+
}
|
|
67
|
+
]
|
|
68
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { ControlPlaneScope } from "@pithy-sh/core/src/controlPlane/scope/scope";
|
|
5
|
+
import type { PaymentsTables } from "../data/tables";
|
|
6
|
+
import {
|
|
7
|
+
PAYMENTS_ENTITLEMENTS_READ_SCOPE,
|
|
8
|
+
PAYMENTS_PURCHASES_READ_SCOPE,
|
|
9
|
+
PAYMENTS_RECONCILE_READ_SCOPE,
|
|
10
|
+
PAYMENTS_SUBSCRIPTIONS_READ_SCOPE,
|
|
11
|
+
} from "../http/scopes";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* What a management client may learn from each table payments owns.
|
|
15
|
+
*
|
|
16
|
+
* **This is a decision, taken once, where the tables are.** Issue #247 was not a missing route so much
|
|
17
|
+
* as a missing question: payments stored a customer's whole purchase history and their entitlement read
|
|
18
|
+
* model, shipped two control-plane writes against the second, and nobody ever asked whether a management
|
|
19
|
+
* client could *see* either. Three panes in the first adopter computed `absent` and dropped out of the
|
|
20
|
+
* rail. Absent, not blocked — no grant restores a route that does not exist.
|
|
21
|
+
*
|
|
22
|
+
* So every table declares which side of the line it is on, and the type is
|
|
23
|
+
* `Record<keyof PaymentsTables, …>` rather than a partial map: **a sixth table does not compile until
|
|
24
|
+
* somebody decides what a management client may see of it.** That is the structural half of the gate.
|
|
25
|
+
* `coverage.test.ts` is the other half — it checks that a table declaring a read has a control-plane
|
|
26
|
+
* `GET` demanding exactly that scope, and that every resource this capability can *write* it can also
|
|
27
|
+
* read.
|
|
28
|
+
*
|
|
29
|
+
* A withheld table states its reason here rather than in a test's exclusion list, because the reason is
|
|
30
|
+
* a property of the table and belongs next to it. Neither reason below is "no pane asked for it".
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** One table's disclosure decision: the reads that return its rows, or why there are none. */
|
|
34
|
+
export type PaymentsDisclosure =
|
|
35
|
+
| {
|
|
36
|
+
/**
|
|
37
|
+
* Every control-plane scope that returns rows from this table. Non-empty by construction, so
|
|
38
|
+
* "readable" cannot degrade into "readable by nothing" through an edit.
|
|
39
|
+
*/
|
|
40
|
+
readonly reads: readonly [ControlPlaneScope, ...ControlPlaneScope[]];
|
|
41
|
+
}
|
|
42
|
+
| {
|
|
43
|
+
/** Why no management client may read this table. A property of the table, not an exemption. */
|
|
44
|
+
readonly withheld: string;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** Every payments table, and what a management client may learn from it. */
|
|
48
|
+
export const PAYMENTS_TABLE_DISCLOSURE: Record<keyof PaymentsTables, PaymentsDisclosure> = {
|
|
49
|
+
/**
|
|
50
|
+
* The purchase log, read two ways. `payments:purchases:read` is the whole projection of provider
|
|
51
|
+
* truth; `payments:subscriptions:read` is the same rows narrowed to the ones that renew, so a tool
|
|
52
|
+
* watching renewals can be granted the forward-looking half without the customer's entire order
|
|
53
|
+
* history. Neither read ever selects `payload` — see `read.ts`.
|
|
54
|
+
*/
|
|
55
|
+
pithyPaymentsPurchases: { reads: [PAYMENTS_PURCHASES_READ_SCOPE, PAYMENTS_SUBSCRIPTIONS_READ_SCOPE] },
|
|
56
|
+
/** The entitlement read model — what a subject holds right now, and why. */
|
|
57
|
+
pithyPaymentsEntitlements: { reads: [PAYMENTS_ENTITLEMENTS_READ_SCOPE] },
|
|
58
|
+
/**
|
|
59
|
+
* Withheld, and the argument is stronger now that the far side is a subject rather than a user. Every row
|
|
60
|
+
* is a *provider-side* identity — `cus_PithyAda`, an Apple `appAccountToken` — paired with the subject
|
|
61
|
+
* that holds it: a Pithy user, or an organization whose id came out of the adopter's own membership
|
|
62
|
+
* model. Listing it in bulk hands a management client a cross-reference from a store account to the
|
|
63
|
+
* adopter's internal id space, and under organization billing that maps a company to its billing
|
|
64
|
+
* identity at every store it sells through.
|
|
65
|
+
*
|
|
66
|
+
* There is nothing an operator can do with the page that a purchase does not already answer: a purchase
|
|
67
|
+
* names its own subject and its own rail. The one legitimate question it settles — "which Stripe customer
|
|
68
|
+
* is this holder" — belongs to a route that answers it for one named subject, if it is ever wanted, and
|
|
69
|
+
* not to a page of a thousand of them.
|
|
70
|
+
*/
|
|
71
|
+
pithyPaymentsProviderAccounts: {
|
|
72
|
+
withheld:
|
|
73
|
+
"Every row pairs a provider-side account identifier with a subject — a Pithy user, or an organization from the adopter's own membership model. In bulk that is a cross-reference from a store's id space into the adopter's, and nothing on this surface needs it: a purchase already names its subject and its rail.",
|
|
74
|
+
},
|
|
75
|
+
/**
|
|
76
|
+
* Withheld. Each row holds the raw verified provider notification exactly as it arrived, and that
|
|
77
|
+
* payload is a bearer artifact — the same thing `responses.ts` refuses to put on a purchase view, for
|
|
78
|
+
* the same reason. There is no projection of a delivery log worth a read scope either: what an operator
|
|
79
|
+
* wants from it is "why did this purchase not update", and that question is answered by the purchase's
|
|
80
|
+
* own status and the audit trail's `payments/webhook_received` events, neither of which is a receipt.
|
|
81
|
+
*/
|
|
82
|
+
pithyPaymentsWebhookEvents: {
|
|
83
|
+
withheld:
|
|
84
|
+
"Each row is a raw verified provider payload — a bearer artifact. The question a delivery log is read for is answered by the purchase's status and the audit trail, without handing anyone a receipt.",
|
|
85
|
+
},
|
|
86
|
+
/**
|
|
87
|
+
* The reconciliation run log — operational state, on its own scope.
|
|
88
|
+
*
|
|
89
|
+
* Readable, and readable *separately*, because it is the one table here that is not about a customer. A
|
|
90
|
+
* run names no account, no transaction and no amount; it says whether the compensating control for a
|
|
91
|
+
* delivery mechanism that is known to fail has been firing. A health monitor should be able to hold
|
|
92
|
+
* exactly this, and an adopter granting it should not thereby disclose what anybody bought.
|
|
93
|
+
*/
|
|
94
|
+
pithyPaymentsReconcileRuns: { reads: [PAYMENTS_RECONCILE_READ_SCOPE] },
|
|
95
|
+
/**
|
|
96
|
+
* Withheld. One opaque resume token per stream, and nothing else — no amount, no customer, no event.
|
|
97
|
+
*
|
|
98
|
+
* Withheld because there is nothing here a management client could act on and one thing it could break:
|
|
99
|
+
* a pane that could read a cursor invites a pane that could write one, and a cursor moved forward skips
|
|
100
|
+
* every event between the old value and the new, silently and unrepairably. The sweep's own state is
|
|
101
|
+
* the sweep's; what it *found* is in the purchases and webhook-event tables, which are readable.
|
|
102
|
+
*/
|
|
103
|
+
pithyPaymentsSyncCursors: {
|
|
104
|
+
withheld:
|
|
105
|
+
"A sweep's resume token is internal bookkeeping, not a fact about a customer. Reading it answers nothing a management client asks, and the write it invites would silently skip every event between two cursor values.",
|
|
106
|
+
},
|
|
107
|
+
};
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { D1Database } from "@cloudflare/workers-types";
|
|
5
|
+
import { decodeCursor, type PageCursor, pageLimit, toPage } from "@pithy-sh/core/src/data/cursor";
|
|
6
|
+
import type { z } from "zod";
|
|
7
|
+
import { PaymentsEntitlement } from "../data/entitlement";
|
|
8
|
+
import { PaymentsPurchase, type PurchaseEnvironment } from "../data/purchase";
|
|
9
|
+
import type { PaymentsRail } from "../data/rail";
|
|
10
|
+
import { PaymentsReconcileRun } from "../data/reconcileRun";
|
|
11
|
+
import type { PurchaseStatus } from "../data/status";
|
|
12
|
+
import type { PaymentsSubject } from "../data/subject";
|
|
13
|
+
import {
|
|
14
|
+
PAYMENTS_ENTITLEMENTS_TABLE,
|
|
15
|
+
PAYMENTS_PURCHASES_TABLE,
|
|
16
|
+
PAYMENTS_RECONCILE_RUNS_TABLE,
|
|
17
|
+
paymentsDatabase,
|
|
18
|
+
} from "../data/tables";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The management read model — the queries behind the control-plane reads, and nothing else.
|
|
22
|
+
*
|
|
23
|
+
* **It lives beside the projection rather than inside it.** Everything in `projection/` writes or reads
|
|
24
|
+
* one subject's own rows; these read across *every* subject, which is a different operation with a
|
|
25
|
+
* different blast radius. Keeping it out of the projection means no in-process caller reaches it by
|
|
26
|
+
* accident, and it is the same arrangement `@pithy-sh/ledger`'s `admin/read.ts` makes for the same
|
|
27
|
+
* reason.
|
|
28
|
+
*
|
|
29
|
+
* ## The payload is not projected, and it is not selected either
|
|
30
|
+
*
|
|
31
|
+
* `pithy_payments_purchases.payload` is the whole verified provider response: a bearer artifact on Apple
|
|
32
|
+
* and Google, and on Stripe a document carrying the buyer's email address, name and billing details.
|
|
33
|
+
* Every query below names its columns, and that column is not among them — so the receipt does not reach
|
|
34
|
+
* the Worker's memory on a management read at all, let alone the response. A projection that dropped it
|
|
35
|
+
* afterwards would be one refactor away from leaking it; a `SELECT` that never asks for it is not.
|
|
36
|
+
*
|
|
37
|
+
* That is also the honest answer to the masking question `@pithy-sh/email` answers by masking a
|
|
38
|
+
* recipient. Payments stores **no** direct personal identifier of its own — the only identity on either
|
|
39
|
+
* table is the **subject pair**, `(subjectType, subjectId)`, opaque ids the adopter's auth capability and
|
|
40
|
+
* the adopter's own membership model issued, and already the address a management client must name to ask
|
|
41
|
+
* about a holder. The single field that would have carried an email address into a purchases list is
|
|
42
|
+
* `payload`, and the control is stronger than a mask: it is never read.
|
|
43
|
+
*
|
|
44
|
+
* ## A subject filter is the pair or it is nothing
|
|
45
|
+
*
|
|
46
|
+
* Every query below takes its owner filter as one {@link PaymentsSubject}, never as a loose id. Nothing in
|
|
47
|
+
* the kit keeps an organization id from equalling some user's id, so a `WHERE subject_id = ?` alone would
|
|
48
|
+
* hand a management client asking about a person the rows of an organization that happens to share the id.
|
|
49
|
+
* The object is what makes the two halves inseparable at every call site — `data/subject.ts` states the
|
|
50
|
+
* argument once.
|
|
51
|
+
*
|
|
52
|
+
* ## Keyset, never offset
|
|
53
|
+
*
|
|
54
|
+
* Every listing pages on `(sort, id)` descending through core's shared cursor helper. `OFFSET` shifts
|
|
55
|
+
* under a reader whenever a row is inserted, and a purchase table is written to by three paths at once —
|
|
56
|
+
* a client submission, three providers' webhooks, and the nightly reconciliation pass. A page 2 fetched
|
|
57
|
+
* a second later would repeat rows it already showed and skip rows it never did.
|
|
58
|
+
*
|
|
59
|
+
* The primary key here is a **text UUID**, deliberately (sequential ids would leak order volume), so it
|
|
60
|
+
* is no use as a sort: it is unique but it is not monotonic. Purchases therefore sort on `purchasedAt`
|
|
61
|
+
* with the id as the tiebreak, and entitlements on `createdAt` with the same. Both columns are indexed
|
|
62
|
+
* for exactly this by `payments_0001_purchases` — without those indexes a purchases pane would sort a
|
|
63
|
+
* customer's entire order history on every page load.
|
|
64
|
+
*/
|
|
65
|
+
|
|
66
|
+
/** One page of a keyset listing, and where the next one resumes. */
|
|
67
|
+
export interface PaymentsPage<T> {
|
|
68
|
+
/** The rows, newest first. */
|
|
69
|
+
items: T[];
|
|
70
|
+
/** The cursor for the next page, or null at the end of the list. Opaque; hand it back verbatim. */
|
|
71
|
+
nextCursor: string | null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* A purchase as a management read sees it — every column but the stored provider payload, the provider
|
|
76
|
+
* SKU, and the bookkeeping timestamps the projection keeps for itself.
|
|
77
|
+
*
|
|
78
|
+
* Derived from {@link PaymentsPurchase} with `.pick`, never restated, so the row schema stays the one
|
|
79
|
+
* definition of the table and the codecs still decode dates on the way out of D1. `providerProductId`
|
|
80
|
+
* is left behind because `productId` and `rail` already name what was bought and the SKU is the
|
|
81
|
+
* adopter's own config; `providerEventAt` and `createdAt` because they describe when *we* processed
|
|
82
|
+
* something, which is a question for the audit trail rather than for a purchases pane.
|
|
83
|
+
*/
|
|
84
|
+
export const PaymentsPurchaseRecord = PaymentsPurchase.pick({
|
|
85
|
+
id: true,
|
|
86
|
+
subjectType: true,
|
|
87
|
+
subjectId: true,
|
|
88
|
+
rail: true,
|
|
89
|
+
providerTransactionId: true,
|
|
90
|
+
originalTransactionId: true,
|
|
91
|
+
productId: true,
|
|
92
|
+
type: true,
|
|
93
|
+
status: true,
|
|
94
|
+
environment: true,
|
|
95
|
+
amountMinor: true,
|
|
96
|
+
currency: true,
|
|
97
|
+
purchasedAt: true,
|
|
98
|
+
expiresAt: true,
|
|
99
|
+
revokedAt: true,
|
|
100
|
+
resumesAt: true,
|
|
101
|
+
updatedAt: true,
|
|
102
|
+
}).describe("One purchase as a management read selects it — never the stored provider payload.");
|
|
103
|
+
export type PaymentsPurchaseRecord = z.output<typeof PaymentsPurchaseRecord>;
|
|
104
|
+
|
|
105
|
+
/** The columns {@link PaymentsPurchaseRecord} covers, as Kysely needs them. One list, derived from the schema. */
|
|
106
|
+
const PURCHASE_COLUMNS = Object.keys(PaymentsPurchaseRecord.shape) as (keyof PaymentsPurchaseRecord)[];
|
|
107
|
+
|
|
108
|
+
/** What {@link listPurchases} accepts. Every filter is optional; together they are an AND. */
|
|
109
|
+
export interface PurchasesQuery {
|
|
110
|
+
/** Restrict to one subject's purchases — the pair, so an id shared across kinds cannot cross. */
|
|
111
|
+
subject?: PaymentsSubject;
|
|
112
|
+
/** Restrict to one store. */
|
|
113
|
+
rail?: PaymentsRail;
|
|
114
|
+
/** Restrict to one normalized status. */
|
|
115
|
+
status?: PurchaseStatus;
|
|
116
|
+
/** Restrict to one store environment — the filter that separates real money from test transactions. */
|
|
117
|
+
environment?: PurchaseEnvironment;
|
|
118
|
+
/** Where to resume, from a previous page's `nextCursor`. A malformed one is a first page. */
|
|
119
|
+
cursor?: string;
|
|
120
|
+
/** How many rows to return, clamped by `pageLimit`. */
|
|
121
|
+
limit?: number;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** What {@link listSubscriptions} accepts. No `rail`: a subscription is read forwards, not by store. */
|
|
125
|
+
export interface SubscriptionsQuery {
|
|
126
|
+
/** Restrict to one subject's subscriptions — the pair, never an id on its own. */
|
|
127
|
+
subject?: PaymentsSubject;
|
|
128
|
+
/** Restrict to one normalized status — `active`, `in_grace`, `canceled`. */
|
|
129
|
+
status?: PurchaseStatus;
|
|
130
|
+
/** Where to resume, from a previous page's `nextCursor`. A malformed one is a first page. */
|
|
131
|
+
cursor?: string;
|
|
132
|
+
/** How many rows to return, clamped by `pageLimit`. */
|
|
133
|
+
limit?: number;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** What {@link listEntitlements} accepts. */
|
|
137
|
+
export interface EntitlementsQuery {
|
|
138
|
+
/** Restrict to one subject — the pair, never an id on its own. */
|
|
139
|
+
subject?: PaymentsSubject;
|
|
140
|
+
/** Restrict to one entitlement key — "who holds `pro`". */
|
|
141
|
+
entitlement?: string;
|
|
142
|
+
/** Where to resume, from a previous page's `nextCursor`. A malformed one is a first page. */
|
|
143
|
+
cursor?: string;
|
|
144
|
+
/** How many rows to return, clamped by `pageLimit`. */
|
|
145
|
+
limit?: number;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* A cursor this table can resume from, or undefined.
|
|
150
|
+
*
|
|
151
|
+
* `PageCursor.sort` is a union because Better Auth stores ISO text where Pithy's own tables store
|
|
152
|
+
* ms-epoch numbers, and comparing a string against an integer column in SQLite silently orders by
|
|
153
|
+
* something nobody meant. Both tables here store a number, so anything else is treated exactly as a
|
|
154
|
+
* malformed cursor is: undefined, and the caller gets a first page rather than an error it could probe.
|
|
155
|
+
*/
|
|
156
|
+
function resume(raw: string | undefined): { sort: number; id: string } | undefined {
|
|
157
|
+
const cursor: PageCursor | undefined = decodeCursor(raw);
|
|
158
|
+
if (!cursor || typeof cursor.sort !== "number") return undefined;
|
|
159
|
+
return { sort: cursor.sort, id: String(cursor.id) };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The purchase log, newest first, filtered.
|
|
164
|
+
*
|
|
165
|
+
* Ordered on `purchasedAt` — when the *store* recorded the sale — rather than on when we first heard of
|
|
166
|
+
* it. A reconciliation pass backfilling a week-old purchase must land in the week-old position, or a
|
|
167
|
+
* purchases pane becomes a log of our own processing rather than of the customer's commerce.
|
|
168
|
+
*/
|
|
169
|
+
export async function listPurchases(
|
|
170
|
+
d1: D1Database,
|
|
171
|
+
query: PurchasesQuery = {},
|
|
172
|
+
): Promise<PaymentsPage<PaymentsPurchaseRecord>> {
|
|
173
|
+
return await purchasePage(d1, query, undefined);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The purchases that renew, newest first.
|
|
178
|
+
*
|
|
179
|
+
* A separate function rather than a `type` filter {@link listPurchases} would accept, because a caller
|
|
180
|
+
* that could name the type could hold `payments:subscriptions:read` and read the whole purchase log
|
|
181
|
+
* through it. The narrowing is applied here, where no request reaches it.
|
|
182
|
+
*/
|
|
183
|
+
export async function listSubscriptions(
|
|
184
|
+
d1: D1Database,
|
|
185
|
+
query: SubscriptionsQuery = {},
|
|
186
|
+
): Promise<PaymentsPage<PaymentsPurchaseRecord>> {
|
|
187
|
+
return await purchasePage(d1, query, "subscription");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** One page of the purchases table, with the product type fixed by the caller rather than by a request. */
|
|
191
|
+
async function purchasePage(
|
|
192
|
+
d1: D1Database,
|
|
193
|
+
query: PurchasesQuery,
|
|
194
|
+
type: "subscription" | undefined,
|
|
195
|
+
): Promise<PaymentsPage<PaymentsPurchaseRecord>> {
|
|
196
|
+
const limit = pageLimit(query.limit);
|
|
197
|
+
const after = resume(query.cursor);
|
|
198
|
+
|
|
199
|
+
let selection = paymentsDatabase(d1)
|
|
200
|
+
.selectFrom(PAYMENTS_PURCHASES_TABLE)
|
|
201
|
+
// Named columns, never `selectAll`. `payload` is a bearer artifact and is not among them, so a
|
|
202
|
+
// management read never loads one — see the file comment.
|
|
203
|
+
.select(PURCHASE_COLUMNS)
|
|
204
|
+
.orderBy("purchasedAt", "desc")
|
|
205
|
+
.orderBy("id", "desc")
|
|
206
|
+
// One more than asked for: the extra row is how "is there another page" is answered without a COUNT,
|
|
207
|
+
// which on a purchase table is a full scan on every page load.
|
|
208
|
+
.limit(limit + 1);
|
|
209
|
+
|
|
210
|
+
if (type !== undefined) selection = selection.where("type", "=", type);
|
|
211
|
+
if (query.subject !== undefined) {
|
|
212
|
+
// Both halves or neither. One column of the pair is not a narrower filter, it is a different question.
|
|
213
|
+
selection = selection
|
|
214
|
+
.where("subjectType", "=", query.subject.subjectType)
|
|
215
|
+
.where("subjectId", "=", query.subject.subjectId);
|
|
216
|
+
}
|
|
217
|
+
if (query.rail !== undefined) selection = selection.where("rail", "=", query.rail);
|
|
218
|
+
if (query.status !== undefined) selection = selection.where("status", "=", query.status);
|
|
219
|
+
if (query.environment !== undefined) selection = selection.where("environment", "=", query.environment);
|
|
220
|
+
if (after) {
|
|
221
|
+
selection = selection.where((eb) =>
|
|
222
|
+
eb.or([
|
|
223
|
+
eb("purchasedAt", "<", after.sort),
|
|
224
|
+
eb.and([eb("purchasedAt", "=", after.sort), eb("id", "<", after.id)]),
|
|
225
|
+
]),
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const rows = await selection.execute();
|
|
230
|
+
return toPage(
|
|
231
|
+
rows.map((row) => PaymentsPurchaseRecord.parse(row)),
|
|
232
|
+
limit,
|
|
233
|
+
(purchase) => ({ sort: purchase.purchasedAt.getTime(), id: purchase.id }),
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The entitlement model, newest first, filtered.
|
|
239
|
+
*
|
|
240
|
+
* Sorted on `createdAt` rather than `updatedAt`, and the difference matters on this table: the
|
|
241
|
+
* projection re-derives every affected row on every purchase write, so `updatedAt` moves constantly for
|
|
242
|
+
* reasons that have nothing to do with the grant. Ordering on it would shuffle rows under a reader. When
|
|
243
|
+
* a key first appeared for a subject never moves.
|
|
244
|
+
*
|
|
245
|
+
* A lapsed row is returned rather than filtered out. Whether it still grants is the projection's question
|
|
246
|
+
* in `http/view.ts`, resolved against `expiresAt` at read time exactly as the hot path resolves it — and an
|
|
247
|
+
* operator answering "when did their Pro end" needs the row and its date to survive the read.
|
|
248
|
+
*/
|
|
249
|
+
export async function listEntitlements(
|
|
250
|
+
d1: D1Database,
|
|
251
|
+
query: EntitlementsQuery = {},
|
|
252
|
+
): Promise<PaymentsPage<PaymentsEntitlement>> {
|
|
253
|
+
const limit = pageLimit(query.limit);
|
|
254
|
+
const after = resume(query.cursor);
|
|
255
|
+
|
|
256
|
+
let selection = paymentsDatabase(d1)
|
|
257
|
+
.selectFrom(PAYMENTS_ENTITLEMENTS_TABLE)
|
|
258
|
+
.selectAll()
|
|
259
|
+
.orderBy("createdAt", "desc")
|
|
260
|
+
.orderBy("id", "desc")
|
|
261
|
+
.limit(limit + 1);
|
|
262
|
+
|
|
263
|
+
if (query.subject !== undefined) {
|
|
264
|
+
selection = selection
|
|
265
|
+
.where("subjectType", "=", query.subject.subjectType)
|
|
266
|
+
.where("subjectId", "=", query.subject.subjectId);
|
|
267
|
+
}
|
|
268
|
+
if (query.entitlement !== undefined) selection = selection.where("entitlement", "=", query.entitlement);
|
|
269
|
+
if (after) {
|
|
270
|
+
selection = selection.where((eb) =>
|
|
271
|
+
eb.or([eb("createdAt", "<", after.sort), eb.and([eb("createdAt", "=", after.sort), eb("id", "<", after.id)])]),
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const rows = await selection.execute();
|
|
276
|
+
return toPage(
|
|
277
|
+
rows.map((row) => PaymentsEntitlement.parse(row)),
|
|
278
|
+
limit,
|
|
279
|
+
(entitlement) => ({ sort: entitlement.createdAt.getTime(), id: entitlement.id }),
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Every entitlement one subject holds, ordered by key.
|
|
285
|
+
*
|
|
286
|
+
* Unpaginated on purpose, and for the reason `@pithy-sh/ledger`'s `readAccounts` is: the table is keyed
|
|
287
|
+
* `UNIQUE (subjectType, subjectId, entitlement)`, so this returns at most one row per key the catalog
|
|
288
|
+
* sells plus whatever has been comped by hand. A cursor over a list that short would be ceremony.
|
|
289
|
+
*
|
|
290
|
+
* The subject is one argument because it is one fact. Two positional strings would typecheck transposed,
|
|
291
|
+
* and the pair leads the unique index in that order — so this read is a covering prefix scan rather than
|
|
292
|
+
* a filter over every holder of the key.
|
|
293
|
+
*
|
|
294
|
+
* A subject holding nothing is an empty list, not a 404. An entitlement row appears with the first
|
|
295
|
+
* purchase that grants one, so its absence is not a missing holder — and answering 404 would make this
|
|
296
|
+
* surface an existence oracle for user and organization ids alike.
|
|
297
|
+
*/
|
|
298
|
+
export async function readEntitlements(d1: D1Database, subject: PaymentsSubject): Promise<PaymentsEntitlement[]> {
|
|
299
|
+
const rows = await paymentsDatabase(d1)
|
|
300
|
+
.selectFrom(PAYMENTS_ENTITLEMENTS_TABLE)
|
|
301
|
+
.selectAll()
|
|
302
|
+
.where("subjectType", "=", subject.subjectType)
|
|
303
|
+
.where("subjectId", "=", subject.subjectId)
|
|
304
|
+
.orderBy("entitlement", "asc")
|
|
305
|
+
.execute();
|
|
306
|
+
return rows.map((row) => PaymentsEntitlement.parse(row));
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** What {@link listReconcileRuns} accepts. */
|
|
310
|
+
export interface ReconcileRunsQuery {
|
|
311
|
+
/** Restrict to the passes narrowed to one store. Never matches a pass that ran against every rail. */
|
|
312
|
+
rail?: PaymentsRail;
|
|
313
|
+
/** Restrict to one store environment. */
|
|
314
|
+
environment?: PurchaseEnvironment;
|
|
315
|
+
/** Where to resume, from a previous page's `nextCursor`. A malformed one is a first page. */
|
|
316
|
+
cursor?: string;
|
|
317
|
+
/** How many rows to return, clamped by `pageLimit`. */
|
|
318
|
+
limit?: number;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* The reconciliation passes this deployment has run, newest first.
|
|
323
|
+
*
|
|
324
|
+
* Ordered on `startedAt`, not `finishedAt`, and the difference is the health question: a pass that began and
|
|
325
|
+
* never ended is the failure worth seeing, and ordering on the timestamp it never wrote would hide it behind
|
|
326
|
+
* every pass that did. The id is the tiebreak, on the index the migration creates for exactly this.
|
|
327
|
+
*
|
|
328
|
+
* `selectAll` here where the purchase read names its columns, and the reason is the point of the table: there
|
|
329
|
+
* is no column to withhold. Every field is a count, a timestamp or an enum, so there is no bearer artifact to
|
|
330
|
+
* leave behind and no personal identifier to mask — see `data/reconcileRun.ts`.
|
|
331
|
+
*/
|
|
332
|
+
export async function listReconcileRuns(
|
|
333
|
+
d1: D1Database,
|
|
334
|
+
query: ReconcileRunsQuery = {},
|
|
335
|
+
): Promise<PaymentsPage<PaymentsReconcileRun>> {
|
|
336
|
+
const limit = pageLimit(query.limit);
|
|
337
|
+
const after = resume(query.cursor);
|
|
338
|
+
|
|
339
|
+
let selection = paymentsDatabase(d1)
|
|
340
|
+
.selectFrom(PAYMENTS_RECONCILE_RUNS_TABLE)
|
|
341
|
+
.selectAll()
|
|
342
|
+
.orderBy("startedAt", "desc")
|
|
343
|
+
.orderBy("id", "desc")
|
|
344
|
+
.limit(limit + 1);
|
|
345
|
+
|
|
346
|
+
if (query.rail !== undefined) selection = selection.where("rail", "=", query.rail);
|
|
347
|
+
if (query.environment !== undefined) selection = selection.where("environment", "=", query.environment);
|
|
348
|
+
if (after) {
|
|
349
|
+
selection = selection.where((eb) =>
|
|
350
|
+
eb.or([eb("startedAt", "<", after.sort), eb.and([eb("startedAt", "=", after.sort), eb("id", "<", after.id)])]),
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const rows = await selection.execute();
|
|
355
|
+
return toPage(
|
|
356
|
+
rows.map((row) => PaymentsReconcileRun.parse(row)),
|
|
357
|
+
limit,
|
|
358
|
+
(run) => ({ sort: run.startedAt.getTime(), id: run.id }),
|
|
359
|
+
);
|
|
360
|
+
}
|