@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,2911 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { D1Database } from "@cloudflare/workers-types";
|
|
5
|
+
import { zValidator } from "@hono/zod-validator";
|
|
6
|
+
import type { PithyHonoEnv } from "@pithy-sh/core/src/capability/capability";
|
|
7
|
+
import { safeEmit } from "@pithy-sh/core/src/controlPlane/audit/actions";
|
|
8
|
+
import type { ControlPlaneContext } from "@pithy-sh/core/src/controlPlane/context";
|
|
9
|
+
import { requireControlPlane } from "@pithy-sh/core/src/controlPlane/http/guard";
|
|
10
|
+
import { InternalError, NotFoundError, PithyError, ValidationError } from "@pithy-sh/core/src/error/pithyError";
|
|
11
|
+
import { validationHook } from "@pithy-sh/core/src/http/validation";
|
|
12
|
+
import { resolveWorkflowBinding } from "@pithy-sh/core/src/workflow/dispatch";
|
|
13
|
+
import type { SecretsStoreEnv } from "@pithy-sh/secrets/src/env/bindings";
|
|
14
|
+
import { sharedSecretsStore } from "@pithy-sh/secrets/src/sharedSecretsStore";
|
|
15
|
+
import type { Context, Hono } from "hono";
|
|
16
|
+
import { listEntitlements, listPurchases, listReconcileRuns, listSubscriptions, readEntitlements } from "../admin/read";
|
|
17
|
+
import { type PaymentsAuditAction, PaymentsAuditActions } from "../audit/actions";
|
|
18
|
+
import {
|
|
19
|
+
type PaymentsCatalogEntry,
|
|
20
|
+
type PaymentsConfig,
|
|
21
|
+
type PaymentsStripeSettings,
|
|
22
|
+
providerProductId,
|
|
23
|
+
railEnabled,
|
|
24
|
+
resolveProduct,
|
|
25
|
+
} from "../config/config";
|
|
26
|
+
import type { PaymentsEntitlement } from "../data/entitlement";
|
|
27
|
+
import type { PurchaseEnvironment } from "../data/purchase";
|
|
28
|
+
import { PaymentsPurchase } from "../data/purchase";
|
|
29
|
+
import { PAYMENTS_HOSTED_RAILS, type PaymentsRail } from "../data/rail";
|
|
30
|
+
import type { PurchaseStatus } from "../data/status";
|
|
31
|
+
import {
|
|
32
|
+
decodeSubjectReference,
|
|
33
|
+
encodeSubjectReference,
|
|
34
|
+
type PaymentsSubject,
|
|
35
|
+
type PaymentsSubjectType,
|
|
36
|
+
sameSubject,
|
|
37
|
+
} from "../data/subject";
|
|
38
|
+
import {
|
|
39
|
+
nextSubscriptionEvent,
|
|
40
|
+
type RefundRequest,
|
|
41
|
+
type SubscriptionChangeQuote,
|
|
42
|
+
type SubscriptionStanding,
|
|
43
|
+
} from "../data/subscription";
|
|
44
|
+
import { PAYMENTS_PURCHASES_TABLE, paymentsDatabase } from "../data/tables";
|
|
45
|
+
import { WEBHOOK_EVENT_ORPHANED } from "../data/webhookEvent";
|
|
46
|
+
import { grantEntitlement, revokeEntitlement } from "../entitlement/manual";
|
|
47
|
+
import { type PaymentsSubjectSeam, requirePaymentsSubject, resolvePaymentsSubject } from "../entitlement/subjectSeam";
|
|
48
|
+
import {
|
|
49
|
+
PaymentsEntitlementNotInCatalogError,
|
|
50
|
+
PaymentsProductNotFoundError,
|
|
51
|
+
PaymentsRailNotConfiguredError,
|
|
52
|
+
PaymentsReceiptAlreadyOwnedError,
|
|
53
|
+
PaymentsReconcileNotProvisionedError,
|
|
54
|
+
PaymentsSubscriptionChangeRefusedError,
|
|
55
|
+
} from "../error/errors";
|
|
56
|
+
import { fulfillPurchase } from "../grants/apply";
|
|
57
|
+
import { repairOrphanedEvents } from "../projection/orphans";
|
|
58
|
+
import { linkProviderAccount, providerAccountForSubject, resolveNotificationOwner } from "../projection/owner";
|
|
59
|
+
import { resolveEntitlements } from "../projection/resolve";
|
|
60
|
+
import { type PurchaseProjection, projectPurchase } from "../projection/writer";
|
|
61
|
+
import {
|
|
62
|
+
type CheckoutRail,
|
|
63
|
+
isCheckoutRail,
|
|
64
|
+
isDiscountRail,
|
|
65
|
+
isPricingRail,
|
|
66
|
+
isRefundRail,
|
|
67
|
+
isSubscriptionRail,
|
|
68
|
+
noteText,
|
|
69
|
+
type PaymentsRailProvider,
|
|
70
|
+
type RefundRail,
|
|
71
|
+
type SubscriptionRail,
|
|
72
|
+
} from "../rails/contract";
|
|
73
|
+
import { type RailTrustOptions, resolveRailProvider } from "../rails/providers";
|
|
74
|
+
import { PAYMENTS_PROVIDER_SECRET, paymentsSecretsRegistry } from "../secret/registry";
|
|
75
|
+
import { PAYMENTS_CAPABILITY, paymentsWorkflows } from "../workflows/specs";
|
|
76
|
+
import { requireAuth } from "./guards";
|
|
77
|
+
import type {
|
|
78
|
+
PaymentsAdminCatalogResponse,
|
|
79
|
+
PaymentsAdminDiscountsResponse,
|
|
80
|
+
PaymentsAdminEntitlementsResponse,
|
|
81
|
+
PaymentsAdminPurchasesResponse,
|
|
82
|
+
PaymentsAdminReconcileRunStarted,
|
|
83
|
+
PaymentsAdminReconcileRunsResponse,
|
|
84
|
+
PaymentsAdminSubjectEntitlementsResponse,
|
|
85
|
+
PaymentsAdminSubscriptionsResponse,
|
|
86
|
+
PaymentsCheckoutHandoffResponse,
|
|
87
|
+
PaymentsDiscountResponse,
|
|
88
|
+
PaymentsEntitlementResponse,
|
|
89
|
+
PaymentsEntitlementsResponse,
|
|
90
|
+
PaymentsPortalHandoffResponse,
|
|
91
|
+
PaymentsPricingEnvelope,
|
|
92
|
+
PaymentsPricingResponse,
|
|
93
|
+
PaymentsPurchaseResponse,
|
|
94
|
+
PaymentsQuotedFrom,
|
|
95
|
+
PaymentsRefundRequest,
|
|
96
|
+
PaymentsRefundResponse,
|
|
97
|
+
PaymentsRestoreResponse,
|
|
98
|
+
PaymentsSubscriptionQuote,
|
|
99
|
+
PaymentsSubscriptionQuoteResponse,
|
|
100
|
+
PaymentsSubscriptionResponse,
|
|
101
|
+
PaymentsSubscriptionStandingResponse,
|
|
102
|
+
PaymentsSubscriptionView,
|
|
103
|
+
} from "./responses";
|
|
104
|
+
import {
|
|
105
|
+
AdminDiscountsQuery,
|
|
106
|
+
AdminEntitlementsQuery,
|
|
107
|
+
AdminPurchasesQuery,
|
|
108
|
+
AdminReconcileRunsQuery,
|
|
109
|
+
AdminSubjectParam,
|
|
110
|
+
AdminSubscriptionsQuery,
|
|
111
|
+
AppleWebhookNotification,
|
|
112
|
+
CheckoutRequest,
|
|
113
|
+
DiscountCreateRequest,
|
|
114
|
+
EntitlementGrantRequest,
|
|
115
|
+
EntitlementRevokeRequest,
|
|
116
|
+
GoogleWebhookNotification,
|
|
117
|
+
LemonSqueezyWebhookNotification,
|
|
118
|
+
PaddleWebhookNotification,
|
|
119
|
+
PurchaseSubmission,
|
|
120
|
+
RestoreRequest,
|
|
121
|
+
StripeWebhookNotification,
|
|
122
|
+
SubscriptionCancelRequest,
|
|
123
|
+
SubscriptionChangeRequest,
|
|
124
|
+
SubscriptionPreviewRequest,
|
|
125
|
+
} from "./schemas";
|
|
126
|
+
import {
|
|
127
|
+
PAYMENTS_CATALOG_READ_SCOPE,
|
|
128
|
+
PAYMENTS_DISCOUNT_CREATE_SCOPE,
|
|
129
|
+
PAYMENTS_DISCOUNT_READ_SCOPE,
|
|
130
|
+
PAYMENTS_ENTITLEMENT_GRANT_SCOPE,
|
|
131
|
+
PAYMENTS_ENTITLEMENT_REVOKE_SCOPE,
|
|
132
|
+
PAYMENTS_ENTITLEMENTS_READ_SCOPE,
|
|
133
|
+
PAYMENTS_PURCHASES_READ_SCOPE,
|
|
134
|
+
PAYMENTS_RECONCILE_READ_SCOPE,
|
|
135
|
+
PAYMENTS_RECONCILE_RUN_SCOPE,
|
|
136
|
+
PAYMENTS_SUBSCRIPTIONS_READ_SCOPE,
|
|
137
|
+
} from "./scopes";
|
|
138
|
+
import {
|
|
139
|
+
adminCatalogView,
|
|
140
|
+
adminEntitlementView,
|
|
141
|
+
adminPurchaseView,
|
|
142
|
+
adminReconcileRunView,
|
|
143
|
+
entitlementView,
|
|
144
|
+
purchaseView,
|
|
145
|
+
} from "./view";
|
|
146
|
+
import { completeWebhook, requireSignedWebhook, verifiedWebhook } from "./webhookGuard";
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The payments routes, their declared verification strategies, and what each accepts.
|
|
150
|
+
*
|
|
151
|
+
* **This list is prose, and it had rotted.** It named sixteen of the twenty-one routes mounted before #465 —
|
|
152
|
+
* `GET /pricing`, two webhook rails and both discount routes had landed without a line here — which is the
|
|
153
|
+
* same failure the README's Routes table had before a gate held it, and for the same reason: a list that is
|
|
154
|
+
* merely *near* the code is a list nothing compares. It was brought level with the registrations as part of
|
|
155
|
+
* #465 and every mounted route is below, but **nothing holds it there**: the gated inventories are the
|
|
156
|
+
* README's Routes table and the `mountedRoutes` pin in `routeContract.test.ts`, checked in both directions
|
|
157
|
+
* against the real registrations. Read those as the record; edit this one by hand and expect it to rot again.
|
|
158
|
+
*
|
|
159
|
+
* POST /payments/purchases → verify a receipt, project it (bearer | session) json: PurchaseSubmission
|
|
160
|
+
* GET /payments/entitlements → the caller's entitlements (bearer | session) —
|
|
161
|
+
* POST /payments/restore → rebind store history (bearer | session) json: RestoreRequest
|
|
162
|
+
* GET /payments/pricing → what the caller's subscription pays, and when that changes
|
|
163
|
+
* (bearer | session) —
|
|
164
|
+
* POST /payments/checkout → a hosted checkout, on whichever rail sells the product
|
|
165
|
+
* (bearer | session) json: CheckoutRequest
|
|
166
|
+
* POST /payments/portal → a billing-portal session (bearer | session) —
|
|
167
|
+
* GET /payments/subscription → where the caller's subscription stands, read live
|
|
168
|
+
* (bearer | session) —
|
|
169
|
+
* POST /payments/subscription/preview → what a move would cost (bearer | session) json: SubscriptionPreviewRequest
|
|
170
|
+
* POST /payments/subscription/change → move it onto a catalog product (bearer | session) json: SubscriptionChangeRequest
|
|
171
|
+
* POST /payments/subscription/cancel → stop it renewing (bearer | session) json: SubscriptionCancelRequest
|
|
172
|
+
* POST /payments/subscription/keep → withdraw a scheduled cancel (bearer | session) —
|
|
173
|
+
* POST /payments/subscription/refund → ask for its payments back (bearer | session) —
|
|
174
|
+
* POST /payments/webhooks/apple → ASSN V2 (signed-webhook) json: AppleWebhookNotification
|
|
175
|
+
* POST /payments/webhooks/google → Play RTDN via Pub/Sub push (signed-webhook) json: GoogleWebhookNotification
|
|
176
|
+
* POST /payments/webhooks/stripe → Stripe events (signed-webhook) json: StripeWebhookNotification
|
|
177
|
+
* POST /payments/webhooks/lemon-squeezy → Lemon Squeezy events (signed-webhook) json: LemonSqueezyWebhookNotification
|
|
178
|
+
* POST /payments/webhooks/paddle → Paddle events (signed-webhook) json: PaddleWebhookNotification
|
|
179
|
+
* POST /payments/entitlements/grant → comp or repair an entitlement (control-plane) json: EntitlementGrantRequest
|
|
180
|
+
* POST /payments/entitlements/revoke → take one back (control-plane) json: EntitlementRevokeRequest
|
|
181
|
+
*
|
|
182
|
+
* GET /payments/admin/catalog → what this project sells (control-plane: payments:catalog:read) —
|
|
183
|
+
* GET /payments/admin/purchases → the purchase log, paged (control-plane: payments:purchases:read) query: AdminPurchasesQuery
|
|
184
|
+
* GET /payments/admin/subscriptions → the purchases that renew (control-plane: payments:subscriptions:read) query: AdminSubscriptionsQuery
|
|
185
|
+
* GET /payments/admin/entitlements → the entitlement model, paged (control-plane: payments:entitlements:read) query: AdminEntitlementsQuery
|
|
186
|
+
* GET /payments/admin/entitlements/:subjectType/:subjectId
|
|
187
|
+
* → one subject's entitlements (control-plane: payments:entitlements:read) param: AdminSubjectParam
|
|
188
|
+
* GET /payments/admin/reconcile-runs → the reconciliation run log (control-plane: payments:reconcile:read) query: AdminReconcileRunsQuery
|
|
189
|
+
* GET /payments/admin/discounts → the codes this project issued (control-plane: payments:discounts:read) query: AdminDiscountsQuery
|
|
190
|
+
* POST /payments/admin/discounts → mint one at a store (control-plane: payments:discounts:create) json: DiscountCreateRequest
|
|
191
|
+
*
|
|
192
|
+
* **The reads exist because the writes did.** Payments shipped `entitlements/grant` and
|
|
193
|
+
* `entitlements/revoke` with no read beside them, so a management client could comp an entitlement and take
|
|
194
|
+
* one back and could never list one — and a dashboard's Purchases, Entitlements and Subscriptions panes
|
|
195
|
+
* computed *absent* against a live manifest and dropped out of the rail entirely. Not blocked, not refused:
|
|
196
|
+
* absent, which no grant and no seed can repair, because there was no route to grant a scope to (#247).
|
|
197
|
+
*
|
|
198
|
+
* **They sit under `admin/` because the player surface owns the bare paths.** `GET ${base}/entitlements` is
|
|
199
|
+
* already a bearer route reading the caller's own; a control-plane read at the same address would sit behind
|
|
200
|
+
* whichever Hono matched first, with a route's gate decided by registration order. The extra segment makes
|
|
201
|
+
* the two sets disjoint by construction.
|
|
202
|
+
*
|
|
203
|
+
* **One `POST` among them, and it is the exception that states the rule.** Every `admin/` route is a read
|
|
204
|
+
* except `POST ${base}/admin/discounts`, which mints a code at a store — a write with a cost attached, so it
|
|
205
|
+
* carries its own scope, `payments:discounts:create`, granted separately from the `payments:discounts:read`
|
|
206
|
+
* on the listing beside it, and its own audit event. The three writes this capability offers a management
|
|
207
|
+
* client are grant, revoke and mint; each is separately scoped, and nothing here widens any of them.
|
|
208
|
+
*
|
|
209
|
+
* That one path serving two methods is also why `routeContract.test.ts` pins `METHOD /path` rather than the
|
|
210
|
+
* path: a `POST ${base}/admin/purchases` mounted beside the read would be a write nobody declared, sitting
|
|
211
|
+
* on a path the pin already contained.
|
|
212
|
+
*
|
|
213
|
+
* **The two control-plane routes are the only way an entitlement appears without money moving**, and that is
|
|
214
|
+
* the whole reason they are gated the way they are. Each wears core's `requireControlPlane()` and nothing
|
|
215
|
+
* else: an EdDSA-signed token on the `pithy-control-plane` header, verified against a public key the adopter
|
|
216
|
+
* registered, bound to one connection, one environment, one request body and one use — and carrying the single
|
|
217
|
+
* scope that route needs, `payments:entitlements:grant` or `payments:entitlements:revoke`. The two are granted
|
|
218
|
+
* separately, so a refund tool cannot comp and a comp tool cannot revoke.
|
|
219
|
+
*
|
|
220
|
+
* **`requireAuth()` is deliberately absent from those two lines, and adding it would break them.** A
|
|
221
|
+
* management client is not a user of this app; the seam never populates `c.var.auth`, precisely so that a
|
|
222
|
+
* control-plane credential cannot satisfy an ordinary `requireAuth()` anywhere in the tree. An auth gate here
|
|
223
|
+
* would deny every legitimate management call and no credential could fix it. With the seam not composed at
|
|
224
|
+
* all the gate raises `controlplane/not_connected`, so the routes are shut rather than open. Both writes are
|
|
225
|
+
* audited to the management client, because the trail is the only record of who decided an account should have
|
|
226
|
+
* something nobody paid for.
|
|
227
|
+
*
|
|
228
|
+
* `POST /payments/purchases` and `/restore` serve every rail with no branch of their own: the rail a caller names
|
|
229
|
+
* selects a verifier through `resolveRailProvider`, and everything past that point is the normalized event. A
|
|
230
|
+
* rail arriving is one entry in `rails/providers.ts`, not a case in a handler.
|
|
231
|
+
*
|
|
232
|
+
* **No `public` routes, ever.** Every caller is either an authenticated user acting on their own purchases or a
|
|
233
|
+
* machine proving authenticity. Turnstile has nothing to gate here.
|
|
234
|
+
*
|
|
235
|
+
* **`signed-webhook` is one strategy over three unrelated mechanisms** — Apple signs a JWS against its
|
|
236
|
+
* certificate chain, Google's Pub/Sub push carries an OIDC token verified with an audience check, Stripe sends
|
|
237
|
+
* an HMAC. Each has its own verifier, which is why these are literal routes rather than one `:rail`: a single
|
|
238
|
+
* route line could not carry three of them, and the rail a caller *claims* is not something to route on.
|
|
239
|
+
*
|
|
240
|
+
* **`/checkout` and `/portal` are Stripe's alone, and they are not a fourth strategy.** Apple and Google
|
|
241
|
+
* purchases happen inside a store SDK before the server hears of them, so there is no session for Pithy to
|
|
242
|
+
* create; Stripe's are ones Pithy initiates. The routes narrow to {@link CheckoutRail} rather than branching on
|
|
243
|
+
* the rail name, so a rail is never asked for something it does not do — and with Stripe off they raise
|
|
244
|
+
* `payments/rail_not_configured`, which reads as "that payment method is not available here" rather than as a
|
|
245
|
+
* broken endpoint.
|
|
246
|
+
*
|
|
247
|
+
* **The five subscription routes are one surface with one rule: nothing a caller sends names anything.**
|
|
248
|
+
* The subscription comes from that caller's own purchase rows, the rail from the row it found, and the
|
|
249
|
+
* store's price from the catalog by logical product id. A body-named subscription moves somebody else's — a
|
|
250
|
+
* claim this capability could not check, because it holds no members table by design — and a body-named
|
|
251
|
+
* price moves a customer onto a plan this project does not sell, at a price it did not set. Neither is
|
|
252
|
+
* refused by a check; both are unreachable because there is nowhere to write them.
|
|
253
|
+
*
|
|
254
|
+
* **The read shipped before the four verbs, deliberately and in its own step.** `GET ${base}/subscription`
|
|
255
|
+
* is the answer to the #247 paragraph below, applied before the mistake could repeat: a capability that can
|
|
256
|
+
* cancel a subscription and cannot report the cancellation ships the half that creates the support ticket,
|
|
257
|
+
* and Paddle leaves `status` at `active` with a blank next billing date when one is scheduled.
|
|
258
|
+
*
|
|
259
|
+
* **None of the four writes touches the projection.** The webhook owns the purchase row; a route that wrote
|
|
260
|
+
* it too would be a second producer of one row, and the two disagree the first time a webhook is late. So
|
|
261
|
+
* each verb answers the *store's* own report of where the subscription now stands, rather than a prediction
|
|
262
|
+
* of it — a prediction is how a customer sees a plan they are not on.
|
|
263
|
+
*
|
|
264
|
+
* **Validators sit after the guards on every route line.** A validator ahead of a guard turns a 401 into a 400
|
|
265
|
+
* and tells an unauthenticated caller which of its requests were well-formed. Config-backed resolution stays in
|
|
266
|
+
* the handler and raises its own domain 404 — a schema constrains a string, it never replaces a lookup.
|
|
267
|
+
*
|
|
268
|
+
* ## What the handlers never trust
|
|
269
|
+
*
|
|
270
|
+
* The **product** comes from the verified payload's SKU, never from the request. A client-supplied product id
|
|
271
|
+
* would let a caller present a cheap receipt as an expensive product. The **owner** comes from the subject
|
|
272
|
+
* seam on the authed routes and from the provider-account map on the webhook, never from a body. The
|
|
273
|
+
* **environment** comes from this deployment's own `ENVIRONMENT` var, never from the payload: inferring it
|
|
274
|
+
* from what the store said is exactly what lets a sandbox purchase grant a real entitlement.
|
|
275
|
+
*
|
|
276
|
+
* ## Who a request is about: one question, one implementation
|
|
277
|
+
*
|
|
278
|
+
* Every route here that touches a holder asks the subject seam, and asks it in one of exactly two ways.
|
|
279
|
+
* A **read** calls {@link resolvePaymentsSubject}: nobody resolved is an empty or denied read, never a 500 and
|
|
280
|
+
* never a guess, because a gate that resolved *something* when it could not tell who was asking would hand one
|
|
281
|
+
* holder's plan to whoever asked next. A **write** calls {@link requirePaymentsSubject}, which refuses with
|
|
282
|
+
* `payments/subject_unresolved` (403): a purchase, a restore, a checkout and a portal session each need a row
|
|
283
|
+
* key, and a guessed key attributes real money to the wrong holder.
|
|
284
|
+
*
|
|
285
|
+
* **Nothing here falls back to `c.var.auth.userId`, in either mode.** Under `billingSubject: "user"` the
|
|
286
|
+
* fallback would be identical to the seam's own default and would therefore look harmless; under
|
|
287
|
+
* `"organization"` it would silently key a company's subscription to whichever employee happened to be signed
|
|
288
|
+
* in. One expression, in `entitlement/subjectSeam.ts`, is what keeps the gate and the routes answering the
|
|
289
|
+
* same question — a second copy is a second policy, and the two disagree the day one of them is edited.
|
|
290
|
+
*
|
|
291
|
+
* **No player-facing request names a subject, and no player-facing response publishes one.** A body or a query
|
|
292
|
+
* that could name a holder is a body that could name somebody else's, and this capability has nothing to check
|
|
293
|
+
* that claim against: it has no members table, by design. The control-plane surfaces are the deliberate
|
|
294
|
+
* exception, and the exception is the feature — support acting on another holder's account is what they are
|
|
295
|
+
* for, which is why each sits behind a default-denied scoped credential and is audited. `schemas.ts` states
|
|
296
|
+
* the same rule from the request side, and `routeContract.test.ts` asserts it as a property over every schema
|
|
297
|
+
* and every response this capability declares, rather than route by route.
|
|
298
|
+
*/
|
|
299
|
+
export interface PaymentsRoutesOptions {
|
|
300
|
+
/** The resolved catalog. */
|
|
301
|
+
config: PaymentsConfig;
|
|
302
|
+
/** Where the routes mount. Defaults to the config's own `basePath`. */
|
|
303
|
+
basePath?: string;
|
|
304
|
+
/** The clock. Injected so verification windows and stored timestamps are deterministic in tests. */
|
|
305
|
+
now?: () => Date;
|
|
306
|
+
/**
|
|
307
|
+
* Certificate roots to accept *in addition to* each rail's pinned ones. Absent in production, and additive
|
|
308
|
+
* only — no caller can narrow the trust set. See {@link RailTrustOptions} for the two callers that need it.
|
|
309
|
+
*/
|
|
310
|
+
trust?: RailTrustOptions;
|
|
311
|
+
/**
|
|
312
|
+
* Which subject a caller is acting for: the mode, and the adopter's resolver. See
|
|
313
|
+
* `entitlement/subjectSeam.ts`.
|
|
314
|
+
*
|
|
315
|
+
* **Handed in whole rather than assembled here**, because `payments()` builds exactly one of these and
|
|
316
|
+
* gives the same object to the entitlement middleware and to these routes. Two constructions are two
|
|
317
|
+
* policies, and the two disagree the day one of them is edited.
|
|
318
|
+
*
|
|
319
|
+
* **The resolver rides on this rather than on `PaymentsConfig`, and that is a hard constraint rather than a
|
|
320
|
+
* preference.** `provision/resolvePaymentsConfig.ts` serializes the resolved config into the
|
|
321
|
+
* `PAYMENTS_CONFIG` var and `workflows/worker.ts` parses it back, so a function cannot survive the round
|
|
322
|
+
* trip. The serializable half of the decision — `billingSubject`, which mode this project bills in — is
|
|
323
|
+
* config; the callable half is a composition-time option.
|
|
324
|
+
*
|
|
325
|
+
* The consequence is worth stating out loud: **a Workflow runs with no adopter resolver.** The reconcile and
|
|
326
|
+
* Paddle sweep hosts parse the config and never see this, so anything in them needing a holder reads it from
|
|
327
|
+
* the stored row that already carries the pair, and never from the seam.
|
|
328
|
+
*
|
|
329
|
+
* Omitted means the config's own mode with no adopter resolver — the authenticated caller under
|
|
330
|
+
* `billingSubject: "user"`, and nobody at all under `"organization"`. The default is the *same* derivation
|
|
331
|
+
* `payments()` makes for a project that supplies none, never a different one, and `payments()` refuses to
|
|
332
|
+
* compose organization billing without a resolver rather than reaching this state.
|
|
333
|
+
*/
|
|
334
|
+
subject?: PaymentsSubjectSeam;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** The app `DB` binding, or a wiring failure. Payments cannot resolve anything without it. */
|
|
338
|
+
function database(c: Context<PithyHonoEnv>): D1Database {
|
|
339
|
+
const binding = (c.env as Record<string, unknown>).DB as D1Database | undefined;
|
|
340
|
+
if (!binding) {
|
|
341
|
+
throw new InternalError({
|
|
342
|
+
message: "Payments is not configured.",
|
|
343
|
+
action: "Bind a D1 database named DB in wrangler.jsonc.",
|
|
344
|
+
detail: "Payments requires a `DB` D1 binding; none was present on env.",
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
return binding;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* This deployment's store environment, from the `ENVIRONMENT` var.
|
|
352
|
+
*
|
|
353
|
+
* Only a Worker deployed to `prod` is production. Every other value — `staging`, `dev`, a var nobody
|
|
354
|
+
* set — is sandbox, because the failure directions are not symmetric: treating production as sandbox loses a
|
|
355
|
+
* purchase that reconciliation repairs, while treating sandbox as production hands out real entitlements for
|
|
356
|
+
* test transactions. That is the single most common in-app-purchase security defect there is, and the default
|
|
357
|
+
* is what decides it.
|
|
358
|
+
*/
|
|
359
|
+
function deploymentEnvironment(c: Context<PithyHonoEnv>): PurchaseEnvironment {
|
|
360
|
+
return (c.env as Record<string, unknown>).ENVIRONMENT === "prod" ? "production" : "sandbox";
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* This deployment's `ENVIRONMENT` var, verbatim, or undefined.
|
|
365
|
+
*
|
|
366
|
+
* Deliberately *not* {@link deploymentEnvironment}'s two-valued answer. A rail sharing one store across
|
|
367
|
+
* every environment — Lemon Squeezy, whose test mode is a flag on an object rather than a separate store —
|
|
368
|
+
* fences its webhooks on this, and `dev` and `staging` both evaluate to `sandbox`, so fencing on that would
|
|
369
|
+
* separate neither. The two are different questions: one asks whether real money moved, this asks which
|
|
370
|
+
* deployment is speaking.
|
|
371
|
+
*/
|
|
372
|
+
function deploymentName(c: Context<PithyHonoEnv>): string | undefined {
|
|
373
|
+
const value = (c.env as Record<string, unknown>).ENVIRONMENT;
|
|
374
|
+
return typeof value === "string" && value !== "" ? value : undefined;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* The locale a figure in this response is rendered for.
|
|
379
|
+
*
|
|
380
|
+
* **The translator seam, not the request.** `c.var.t` is always present — core seeds a translator over the
|
|
381
|
+
* baked English when nothing composes `@pithy-sh/i18n`, and the capability replaces it with one negotiated
|
|
382
|
+
* from the URL, the reader's account, a cookie and `Accept-Language` — so this is the same locale every
|
|
383
|
+
* other string in the same response is written in. `@pithy-sh/email` sets the precedent, building a
|
|
384
|
+
* per-recipient translator from a stored locale rather than a second rule of its own.
|
|
385
|
+
*
|
|
386
|
+
* **Not a request field and not a header read here.** A locale a caller can name in a body is a locale a
|
|
387
|
+
* caller sets, and these are the routes whose rule is that nothing a caller sends names anything.
|
|
388
|
+
* `Accept-Language` is already one of the four inputs the negotiation weighs, and reading it directly would
|
|
389
|
+
* put the money in one language and the sentence around it in another.
|
|
390
|
+
*
|
|
391
|
+
* `formattingLocale` rather than `catalogLocale`: it is the tag `Intl` is meant to be handed, region and
|
|
392
|
+
* all, so an `es-AR` reader gets `es-AR` grouping whether or not anybody wrote Spanish copy. The seam
|
|
393
|
+
* documents that split at the field.
|
|
394
|
+
*/
|
|
395
|
+
function readerLocale(c: Context<PithyHonoEnv>): string {
|
|
396
|
+
return c.var.t.formattingLocale;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* The subject filter on a management listing: the pair, or nothing at all.
|
|
401
|
+
*
|
|
402
|
+
* `AdminPurchasesQuery` and its siblings carry the two halves as two optional query fields with a both-or-
|
|
403
|
+
* neither refinement, because a query string is flat. This is the one place that pair is reassembled, and the
|
|
404
|
+
* `&&` is what makes an id with no kind resolve to *no filter* rather than to half of one. The refinement has
|
|
405
|
+
* already refused that request with a 400 — a listing narrowed on `subject_id` alone would hand a client
|
|
406
|
+
* asking about a person the rows of an organization that happens to share the id — so this is belt and braces
|
|
407
|
+
* on the one narrowing where being wrong discloses somebody else's commerce.
|
|
408
|
+
*/
|
|
409
|
+
function subjectFilter(query: { subjectType?: PaymentsSubjectType; subjectId?: string }): PaymentsSubject | undefined {
|
|
410
|
+
return query.subjectType !== undefined && query.subjectId !== undefined
|
|
411
|
+
? { subjectType: query.subjectType, subjectId: query.subjectId }
|
|
412
|
+
: undefined;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* The verified management client behind a control-plane call. `requireControlPlane()` has run on every route
|
|
417
|
+
* that calls this, so a null context is a wiring mistake rather than an unverified request — hence
|
|
418
|
+
* `InternalError`, not a 401.
|
|
419
|
+
*
|
|
420
|
+
* Deliberately not the subject seam. A management client has no user row and no session, so there is nothing
|
|
421
|
+
* here to read off `c.var.auth` and nothing for a resolver to answer; keeping the two apart is what stops a
|
|
422
|
+
* control-plane caller from being recorded as, or mistaken for, a user of this app.
|
|
423
|
+
*/
|
|
424
|
+
function controlPlaneCaller(c: Context<PithyHonoEnv>): ControlPlaneContext {
|
|
425
|
+
const caller = c.var.controlPlane;
|
|
426
|
+
if (!caller) {
|
|
427
|
+
throw new InternalError({
|
|
428
|
+
detail: "requireControlPlane() must run before a payments handler reads the management caller.",
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
return caller;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** Every rail's credentials, read through the one reader at the point of need. */
|
|
435
|
+
async function credentials(c: Context<PithyHonoEnv>) {
|
|
436
|
+
const secrets = await sharedSecretsStore(c.env as unknown as SecretsStoreEnv, paymentsSecretsRegistry);
|
|
437
|
+
return secrets.get(PAYMENTS_PROVIDER_SECRET);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* The catalog product a caller named, or a 404. The catalog is config rather than rows, so an id nothing maps
|
|
442
|
+
* is a missing resource — and the lookup stays in the handler, where it can raise its own domain error, rather
|
|
443
|
+
* than being folded into a request schema built from the configured key set.
|
|
444
|
+
*/
|
|
445
|
+
function product(config: PaymentsConfig, id: string): PaymentsCatalogEntry {
|
|
446
|
+
const found = resolveProduct(config, id);
|
|
447
|
+
if (found === undefined) throw new PaymentsProductNotFoundError({ detail: `No product "${id}" is configured.` });
|
|
448
|
+
return found;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* The statuses a subscription can still be *acted on* in — the filter {@link changeableSubscription} applies
|
|
453
|
+
* before it counts anything.
|
|
454
|
+
*
|
|
455
|
+
* The set is "there is something left to do to this", not "this grants access", and the two differ at both
|
|
456
|
+
* ends. It is stated positively rather than as a list of exclusions so that a status added later is inert
|
|
457
|
+
* here until somebody decides it belongs, which is the safe direction: an unknown status silently joining an
|
|
458
|
+
* actionable set is how a dead row starts competing with a live subscription.
|
|
459
|
+
*
|
|
460
|
+
* - **`active`** — the ordinary case.
|
|
461
|
+
* - **`in_grace`** — a renewal that failed inside the retry window. The subscription exists at the store and
|
|
462
|
+
* a customer with a bounced card is precisely the customer who wants to downgrade or cancel.
|
|
463
|
+
* - **`on_hold`** — the same, past the retry window. No rail implementing {@link SubscriptionRail} writes it
|
|
464
|
+
* today; it is here because a subscription nobody has ended is still one somebody may want to end.
|
|
465
|
+
* - **`canceled`** — auto-renew off with the paid period still running. It **grants access**
|
|
466
|
+
* ({@link ACCESS_GRANTING_STATUSES}), so a holder in it still has what they paid for, and it is the only
|
|
467
|
+
* status `keepSubscription` can start from on the rails that write it while the schedule is pending.
|
|
468
|
+
* - **`paused`** — suspended, not ended. Canceling a paused subscription is legitimate; a store that will
|
|
469
|
+
* not move one answers `payments/subscription_change_refused` with its own reason, which is a better
|
|
470
|
+
* sentence than this query pretending the subscription is not there.
|
|
471
|
+
*
|
|
472
|
+
* **The four that are absent are endings**, and every one of them is an ending the *store* declared:
|
|
473
|
+
* `expired` (a period we were paid for, over), `never_paid` (terminated before money moved), `refunded` and
|
|
474
|
+
* `revoked` (taken back). None of them can be changed, canceled or un-canceled, and counting one is how a
|
|
475
|
+
* holder with a history of subscriptions becomes ambiguous forever.
|
|
476
|
+
*
|
|
477
|
+
* **Refusing on state is not this set's job.** A store that will not move a particular subscription is the
|
|
478
|
+
* rail's 409, carrying the store's own reason; this filter only decides whether there is a subscription to
|
|
479
|
+
* ask about at all. Widening the difference between the two is how a customer is told they have no
|
|
480
|
+
* subscription when what is true is that this one cannot be upgraded today.
|
|
481
|
+
*/
|
|
482
|
+
const SUBSCRIPTION_ACTIONABLE_STATUSES: readonly PurchaseStatus[] = [
|
|
483
|
+
"active",
|
|
484
|
+
"in_grace",
|
|
485
|
+
"on_hold",
|
|
486
|
+
"canceled",
|
|
487
|
+
"paused",
|
|
488
|
+
];
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Which subscription a change verb may act on — none, exactly one, or more than one.
|
|
492
|
+
*
|
|
493
|
+
* **A discriminated answer rather than `PaymentsPurchase | undefined`**, because the three cases are three
|
|
494
|
+
* different sentences to a caller: nothing to change, here it is, and *this server will not choose for you*.
|
|
495
|
+
* Collapsing the last into either of the others is the defect this whole helper exists to make impossible —
|
|
496
|
+
* silently picking one of two live subscriptions moves a plan the customer did not name.
|
|
497
|
+
*/
|
|
498
|
+
export type SubscriptionTarget =
|
|
499
|
+
| {
|
|
500
|
+
/** The caller holds no subscription this server can act on. */
|
|
501
|
+
found: "none";
|
|
502
|
+
}
|
|
503
|
+
| {
|
|
504
|
+
/** Exactly one, and it is theirs. */
|
|
505
|
+
found: "one";
|
|
506
|
+
/** The row a {@link SubscriptionRail} is handed. The whole row — see `SubscriptionChangeInput.purchase`. */
|
|
507
|
+
purchase: PaymentsPurchase;
|
|
508
|
+
}
|
|
509
|
+
| {
|
|
510
|
+
/** More than one, so no verb can proceed without being told which. */
|
|
511
|
+
found: "many";
|
|
512
|
+
/** Every candidate, newest event first — so a refusal can say how many and on which rails. */
|
|
513
|
+
purchases: readonly PaymentsPurchase[];
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* The one subscription this subject may change, cancel or keep — resolved from their own rows, never from a
|
|
518
|
+
* request.
|
|
519
|
+
*
|
|
520
|
+
* ## Why neither existing answer was reusable
|
|
521
|
+
*
|
|
522
|
+
* `ownSubscriptionIds` returns *every* subscription row a subject holds, at any status, and that is right for
|
|
523
|
+
* what it feeds: a portal request hands the whole list to Paddle, which sorts out which are live. A write
|
|
524
|
+
* verb cannot hand a list to anything — it needs one — and expired, refunded and superseded rows are all in
|
|
525
|
+
* that list.
|
|
526
|
+
*
|
|
527
|
+
* `GET {base}/pricing` takes `rows.find(r => r.role === "state") ?? rows[0]` from an unfiltered, time-ordered
|
|
528
|
+
* read. Two things are wrong with that here. The order is not a filter, so the newest row of a holder who
|
|
529
|
+
* canceled a year ago is still a year-old cancellation; and `?? rows[0]` can land on a money row, whose
|
|
530
|
+
* `providerTransactionId` is Paddle's `txn_…` rather than a `sub_…`. Pricing survives it because
|
|
531
|
+
* `readPricing` answers `undefined` for a row it cannot address. A cancel verb would not: it would ask a
|
|
532
|
+
* store to end a subscription by a transaction id, and the useful outcome is the one where that fails.
|
|
533
|
+
*
|
|
534
|
+
* ## The three rules
|
|
535
|
+
*
|
|
536
|
+
* **One: the head row, never a period of it.** A subscription's money rows carry the family in
|
|
537
|
+
* `originalTransactionId` and their own invoice or transaction id in `providerTransactionId`; the row that
|
|
538
|
+
* *is* the subscription carries the same value in both — Paddle's and Lemon Squeezy's state builders say so
|
|
539
|
+
* at the field, and it is what `subscriptionIdOf` relies on when it demands a `sub_` prefix. So the predicate
|
|
540
|
+
* is `originalTransactionId = providerTransactionId`, compared in SQL, which also excludes a one-off (whose
|
|
541
|
+
* family is null, and `null = anything` is not true) without a second clause. It is structural rather than a
|
|
542
|
+
* prefix test, so it needs no edit when a rail is added — and a rail whose rows never satisfy it resolves to
|
|
543
|
+
* `none`, which is an honest "there is nothing here this server can change" rather than a wrong row handed
|
|
544
|
+
* to a store.
|
|
545
|
+
*
|
|
546
|
+
* `UNIQUE (rail, providerTransactionId)` then does the rest of the work: a family has at most one head row,
|
|
547
|
+
* so surviving rows are subscriptions one-for-one and there is no de-duplication to get wrong.
|
|
548
|
+
*
|
|
549
|
+
* **Two: a cancellation whose period has run out is over.** `canceled` is in the actionable set because the
|
|
550
|
+
* holder still has what they paid for — but only until `expiresAt`, and the `expired` webhook that would
|
|
551
|
+
* overwrite the row can be late or dropped. Without this, every resubscriber is permanently ambiguous:
|
|
552
|
+
* their old, dead cancellation competes with the subscription they are currently paying for, and they are
|
|
553
|
+
* locked out of managing it. The check is deliberately **only** on `canceled`, because that is the one
|
|
554
|
+
* status whose `expiresAt` is a final date the store has already committed to. On every other actionable
|
|
555
|
+
* status the date is a rolling period end that each renewal moves, and dropping a row on it would tell a
|
|
556
|
+
* paying customer they have no subscription for as long as one renewal webhook is late.
|
|
557
|
+
*
|
|
558
|
+
* **Three: both halves of the subject, always.** `user:acme` and `organization:acme` are two holders and
|
|
559
|
+
* nothing in the kit keeps the id namespaces disjoint, so an id-only filter would let either act on the
|
|
560
|
+
* other's subscription. The pair arrives resolved from the seam, under whichever mode the project bills in.
|
|
561
|
+
*
|
|
562
|
+
* @param d1 The purchases database.
|
|
563
|
+
* @param subject The holder, as the subject seam resolved them — both halves.
|
|
564
|
+
* @param now The clock, for rule two. Passed rather than read, so a test can stand either side of an expiry.
|
|
565
|
+
*/
|
|
566
|
+
export async function changeableSubscription(
|
|
567
|
+
d1: D1Database,
|
|
568
|
+
subject: PaymentsSubject,
|
|
569
|
+
now: Date,
|
|
570
|
+
): Promise<SubscriptionTarget> {
|
|
571
|
+
const rows = await paymentsDatabase(d1)
|
|
572
|
+
.selectFrom(PAYMENTS_PURCHASES_TABLE)
|
|
573
|
+
// Every column: a rail is handed the whole row, because what identifies a subscription at its store
|
|
574
|
+
// differs per rail and some of it survives only in the payload. See `SubscriptionChangeInput.purchase`.
|
|
575
|
+
.selectAll()
|
|
576
|
+
.where("subjectType", "=", subject.subjectType)
|
|
577
|
+
.where("subjectId", "=", subject.subjectId)
|
|
578
|
+
.where("type", "=", "subscription")
|
|
579
|
+
.where("status", "in", SUBSCRIPTION_ACTIONABLE_STATUSES)
|
|
580
|
+
// Rule one, in SQL. A null family is not equal to anything, so one-off purchases fall out here too.
|
|
581
|
+
.whereRef("originalTransactionId", "=", "providerTransactionId")
|
|
582
|
+
// Newest first, and `id` to break a tie, so a `many` answer is stable rather than whatever D1 returns.
|
|
583
|
+
.orderBy("providerEventAt", "desc")
|
|
584
|
+
.orderBy("id", "desc")
|
|
585
|
+
.execute();
|
|
586
|
+
|
|
587
|
+
// Parsed, not read raw. A D1 row is a boundary like any other, and `expiresAt` is an epoch integer until
|
|
588
|
+
// the codec makes it a date — comparing the number against a `Date` is the kind of quiet nonsense this
|
|
589
|
+
// package validates at every edge to prevent.
|
|
590
|
+
const candidates = rows
|
|
591
|
+
.map((row) => PaymentsPurchase.parse(row))
|
|
592
|
+
.filter((purchase) => !cancellationRanOut(purchase, now));
|
|
593
|
+
|
|
594
|
+
const [only, ...rest] = candidates;
|
|
595
|
+
if (only === undefined) return { found: "none" };
|
|
596
|
+
if (rest.length > 0) return { found: "many", purchases: candidates };
|
|
597
|
+
return { found: "one", purchase: only };
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Every payment made on one subscription, as this holder's own rows record them — what a refund acts on.
|
|
602
|
+
*
|
|
603
|
+
* ## Why a refund needs a different query from every other verb
|
|
604
|
+
*
|
|
605
|
+
* The other four act on the subscription, and {@link changeableSubscription} resolves *the* row that is one.
|
|
606
|
+
* A refund cannot: **no store refunds a subscription.** Every refund attaches to a transaction, and a
|
|
607
|
+
* subscription is a family of them — so this returns the money rows, which is a set, and the caller raises
|
|
608
|
+
* one refund each.
|
|
609
|
+
*
|
|
610
|
+
* The set is ordinary rather than exotic. A customer who joined on Solo at 6.00, upgraded to Team on day 10
|
|
611
|
+
* for a 65.82 proration and cancels on day 13 has paid twice. An adopter whose policy gives them their money
|
|
612
|
+
* back owes both, and a query returning one would quietly keep 65.82 of somebody's money.
|
|
613
|
+
*
|
|
614
|
+
* ## The three rules
|
|
615
|
+
*
|
|
616
|
+
* **One: money rows only, stated twice.** `role = 'charge'` is the discriminator Lemon Squeezy forced into
|
|
617
|
+
* existence, and the second clause — `providerTransactionId <> originalTransactionId` — is the structural
|
|
618
|
+
* form of the same statement for the rails that write `charge` for everything: the row that *is* the
|
|
619
|
+
* subscription carries its family key in both columns. Either alone is right on some rail and wrong on
|
|
620
|
+
* another, and what they exclude is the head row, whose provider id is a `sub_…`. Sending that to a store is
|
|
621
|
+
* asking it to refund a subscription, which is not a thing it can do.
|
|
622
|
+
*
|
|
623
|
+
* **Two: both halves of the subject, always.** `user:acme` and `organization:acme` are two holders and
|
|
624
|
+
* nothing keeps the id namespaces disjoint, so an id-only filter would refund one holder's payments on the
|
|
625
|
+
* other's request. The same rule every query here follows, and here it is the difference between a refund
|
|
626
|
+
* and a theft.
|
|
627
|
+
*
|
|
628
|
+
* **Three: the family, and only this rail.** A holder may have paid on two stores; the rail comes from the
|
|
629
|
+
* subscription row that was resolved, never from a request.
|
|
630
|
+
*
|
|
631
|
+
* ## What is deliberately absent
|
|
632
|
+
*
|
|
633
|
+
* **A window, and a status filter.** How long a customer has to ask for their money back is the *adopter's*
|
|
634
|
+
* policy — the kit must not hard-code fourteen days, or any number — so every payment on the subscription is
|
|
635
|
+
* returned and the adopter's screen decides which button exists. And nothing here filters on a status of
|
|
636
|
+
* *ours*: whether a payment can still be refunded is the **store's** answer, read live from the transaction's
|
|
637
|
+
* own adjustments, and a projected row is a lagging copy of it. Filtering here would drop a payment on a
|
|
638
|
+
* stale row and call the result a complete refund.
|
|
639
|
+
*
|
|
640
|
+
* Ordered oldest first and tie-broken by id, so the report a caller gets back is in the order the money was
|
|
641
|
+
* taken and is reproducible across two identical requests.
|
|
642
|
+
*/
|
|
643
|
+
export async function refundablePayments(
|
|
644
|
+
d1: D1Database,
|
|
645
|
+
subject: PaymentsSubject,
|
|
646
|
+
subscription: PaymentsPurchase,
|
|
647
|
+
): Promise<readonly PaymentsPurchase[]> {
|
|
648
|
+
const family = subscription.originalTransactionId ?? subscription.providerTransactionId;
|
|
649
|
+
const rows = await paymentsDatabase(d1)
|
|
650
|
+
.selectFrom(PAYMENTS_PURCHASES_TABLE)
|
|
651
|
+
// Every column, for `SubscriptionChangeInput.purchase`'s reason: a rail is handed the whole row.
|
|
652
|
+
.selectAll()
|
|
653
|
+
.where("subjectType", "=", subject.subjectType)
|
|
654
|
+
.where("subjectId", "=", subject.subjectId)
|
|
655
|
+
.where("rail", "=", subscription.rail)
|
|
656
|
+
.where("type", "=", "subscription")
|
|
657
|
+
.where("originalTransactionId", "=", family)
|
|
658
|
+
.where("role", "=", "charge")
|
|
659
|
+
// The head row, structurally. A `sub_…` sent to a refund endpoint is a request no store can honor.
|
|
660
|
+
.whereRef("providerTransactionId", "!=", "originalTransactionId")
|
|
661
|
+
.orderBy("providerEventAt", "asc")
|
|
662
|
+
.orderBy("id", "asc")
|
|
663
|
+
.execute();
|
|
664
|
+
// Parsed, not read raw. A D1 row is a boundary like any other.
|
|
665
|
+
return rows.map((row) => PaymentsPurchase.parse(row));
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/** Rule two: a cancellation the paid period has already outrun. See {@link changeableSubscription}. */
|
|
669
|
+
function cancellationRanOut(purchase: PaymentsPurchase, now: Date): boolean {
|
|
670
|
+
if (purchase.status !== "canceled" || purchase.expiresAt === null) return false;
|
|
671
|
+
return purchase.expiresAt.getTime() <= now.getTime();
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* More than one subscription, on every one of the five routes — the read included.
|
|
676
|
+
*
|
|
677
|
+
* **A refusal rather than a choice, and it is the same refusal on the read as on the writes.** The read's
|
|
678
|
+
* envelope holds one subscription or none, so `many` has no honest encoding in it: null says the caller
|
|
679
|
+
* holds nothing, which is false, and picking one renders somebody's other plan beside a cancel button
|
|
680
|
+
* that would end a third thing. `changeableSubscription` exists to make that silent pick impossible, and
|
|
681
|
+
* this is where its third answer becomes a sentence.
|
|
682
|
+
*
|
|
683
|
+
* 409 rather than 400: the request is well-formed and the conflict is with the state of the account, which
|
|
684
|
+
* is what tells a client to re-read rather than to re-word. `detail` counts them and names the rails —
|
|
685
|
+
* throw-site context, stripped by the codec — and the message names nothing about anybody's billing.
|
|
686
|
+
*/
|
|
687
|
+
function tooManySubscriptions(purchases: readonly PaymentsPurchase[]): PithyError {
|
|
688
|
+
return new PaymentsSubscriptionChangeRefusedError({
|
|
689
|
+
message: "There is more than one subscription on this account.",
|
|
690
|
+
action: "Open the billing portal to manage them there. This route acts on one subscription and will not choose.",
|
|
691
|
+
detail: `${purchases.length} actionable subscriptions resolved for this holder, on ${[
|
|
692
|
+
...new Set(purchases.map((purchase) => purchase.rail)),
|
|
693
|
+
].join(", ")}. No verb may pick one.`,
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* The one subscription a verb acts on, or the refusal that says why there is not one.
|
|
699
|
+
*
|
|
700
|
+
* The `none` case is a 404 rather than a payments-domain refusal, for the reason `POST {base}/portal`
|
|
701
|
+
* gives for the same shape: the rail is configured and working, and the resource simply does not exist.
|
|
702
|
+
* `GET {base}/subscription` does **not** call this — a read has an honest empty answer and `null` is it.
|
|
703
|
+
*/
|
|
704
|
+
function requireOneSubscription(target: SubscriptionTarget): PaymentsPurchase {
|
|
705
|
+
if (target.found === "many") throw tooManySubscriptions(target.purchases);
|
|
706
|
+
if (target.found === "none") {
|
|
707
|
+
throw new NotFoundError({
|
|
708
|
+
message: "No subscription to manage.",
|
|
709
|
+
action: "Buy a subscription first, then change or cancel it here.",
|
|
710
|
+
detail: "The caller holds no subscription in a status this server can act on.",
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
return target.purchase;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* One subscription as its own holder reads it — the standing the store reported, plus the two facts a
|
|
718
|
+
* screen cannot derive.
|
|
719
|
+
*
|
|
720
|
+
* **`productId` is a parameter rather than read off the purchase row, and that is not tidiness.** After a
|
|
721
|
+
* plan change the row still names the old plan: the webhook owns that row and has not arrived yet
|
|
722
|
+
* (invariant 2 — no route writes the projection). A view built from the row would answer the change route
|
|
723
|
+
* with the plan the customer just left, on the screen that renders what it wrote. So the read passes the
|
|
724
|
+
* row's product and `change` passes the one the store just confirmed.
|
|
725
|
+
*
|
|
726
|
+
* **`nextEvent` is derived here rather than by the client**, because the precedence is not obvious and
|
|
727
|
+
* every client would have to rediscover it: a scheduled change wins over the next billing date, since
|
|
728
|
+
* Paddle blanks that date the moment a cancellation is scheduled. `nextSubscriptionEvent` is the one
|
|
729
|
+
* implementation, in `data/subscription.ts`; a client cannot call it, because it takes `Date`s.
|
|
730
|
+
*/
|
|
731
|
+
function subscriptionView(productId: string, standing: SubscriptionStanding): PaymentsSubscriptionView {
|
|
732
|
+
const event = nextSubscriptionEvent(standing);
|
|
733
|
+
return {
|
|
734
|
+
productId,
|
|
735
|
+
status: standing.status,
|
|
736
|
+
currency: standing.currency,
|
|
737
|
+
currentPeriodEndsAt: standing.currentPeriodEndsAt?.toISOString() ?? null,
|
|
738
|
+
nextBilledAt: standing.nextBilledAt?.toISOString() ?? null,
|
|
739
|
+
scheduledChange:
|
|
740
|
+
standing.scheduledChange === null
|
|
741
|
+
? null
|
|
742
|
+
: {
|
|
743
|
+
action: standing.scheduledChange.action,
|
|
744
|
+
effectiveAt: standing.scheduledChange.effectiveAt.toISOString(),
|
|
745
|
+
resumesAt: standing.scheduledChange.resumesAt?.toISOString() ?? null,
|
|
746
|
+
},
|
|
747
|
+
nextEvent:
|
|
748
|
+
event.kind === "unknown" ? { kind: "unknown", at: null } : { kind: event.kind, at: event.at.toISOString() },
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* The store's own price for a catalog product, on the rail the subscription actually lives at.
|
|
754
|
+
*
|
|
755
|
+
* **This is the whole of "no body-named price".** A caller names the logical product — the key in
|
|
756
|
+
* `products` — and this resolves it, server-side, on a rail read from the caller's own purchase row. A
|
|
757
|
+
* `pri_…` in a request body would move a customer onto a plan this project does not sell, at a price it did
|
|
758
|
+
* not set, and nothing here could refuse it: the catalog is the only statement of what is for sale.
|
|
759
|
+
*
|
|
760
|
+
* A 404 on the **product** when the rail has no SKU for it, never on the rail, for `/checkout`'s reason:
|
|
761
|
+
* the rail is available and this product simply is not one of the things it sells. Naming which of the two
|
|
762
|
+
* it was would describe the deployment to a stranger.
|
|
763
|
+
*/
|
|
764
|
+
function subscriptionPrice(entry: PaymentsCatalogEntry, rail: PaymentsRail): string {
|
|
765
|
+
const sku = providerProductId(entry.product, rail);
|
|
766
|
+
if (sku === undefined) {
|
|
767
|
+
throw new PaymentsProductNotFoundError({
|
|
768
|
+
detail: `Product "${entry.id}" declares no ${rail} price, so a subscription at that store cannot be moved onto it.`,
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
return sku;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* Whether a plan change is a change at all — the route's own reading of the no-op, and the only thing that
|
|
776
|
+
* decides whether an audit row is written.
|
|
777
|
+
*
|
|
778
|
+
* **The rail answers the no-op and reports nothing about it.** `changePlan` returns the current standing
|
|
779
|
+
* either way, with no flag, because a standing is a subscription's state and "did anything happen" is not
|
|
780
|
+
* one of its fields. So the route cannot learn it from the answer and has to decide from what it holds.
|
|
781
|
+
*
|
|
782
|
+
* What it holds is the purchase row, which names the catalog product the store last told this deployment
|
|
783
|
+
* the subscription was for. Equal to the product asked for means the caller is asking for the plan their
|
|
784
|
+
* own subscription is already on, which is exactly the retry the no-op exists to absorb.
|
|
785
|
+
*
|
|
786
|
+
* **The gap, stated rather than left to be discovered.** The row lags the store by one webhook. During that
|
|
787
|
+
* window a caller repeating a change they already made can find the row still naming the old plan, and this
|
|
788
|
+
* answers `true` for a call the rail then no-ops — one audit row for a change that did not happen on that
|
|
789
|
+
* request. It cannot go the other way: the row only ever names a plan the store has already reported, so a
|
|
790
|
+
* genuine move is never recorded as a no-op and the trail never goes silent about a real change. Of the two
|
|
791
|
+
* directions to be wrong in, a duplicate row that a reader can date against the store's own history is
|
|
792
|
+
* recoverable, and a missing one is not.
|
|
793
|
+
*/
|
|
794
|
+
function changedPlan(purchase: PaymentsPurchase, productId: string): boolean {
|
|
795
|
+
return purchase.productId !== productId;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* Whether a verb changed anything — the no-op test for `cancel` and `keep`, read off the store's own
|
|
800
|
+
* before and after rather than off a rule copied out of the rail.
|
|
801
|
+
*
|
|
802
|
+
* **The rail reports no such flag, and the fact this needs is not in the purchase row.** A cancellation
|
|
803
|
+
* scheduled for the end of the period is exactly the thing a projected row cannot carry — Paddle leaves
|
|
804
|
+
* `status` at `active` and blanks `next_billed_at` — so the route reads the standing before it writes.
|
|
805
|
+
* That costs one `GET` beside the one the rail makes, and it buys the only thing that keeps the trail
|
|
806
|
+
* honest: an audit row is written when something actually happened and not when a caller retried.
|
|
807
|
+
*
|
|
808
|
+
* **A comparison of outcomes, not a second copy of the rail's rules.** Restating "already scheduled to
|
|
809
|
+
* cancel" and "already ended" here would be two statements of one policy, and they disagree the day one is
|
|
810
|
+
* edited. This asks a narrower question the rail cannot get wrong: after the call, does the store describe
|
|
811
|
+
* the subscription differently than it did before? A no-op is defined by its answer.
|
|
812
|
+
*
|
|
813
|
+
* The views are compared rather than the standings, because a view is what a screen renders and both sides
|
|
814
|
+
* are built by the same function in the same field order. `before === undefined` counts as changed, and is
|
|
815
|
+
* unreachable in practice: a rail that cannot address the purchase refuses the write rather than returning
|
|
816
|
+
* from it.
|
|
817
|
+
*/
|
|
818
|
+
function standingMoved(before: SubscriptionStanding | undefined, after: SubscriptionStanding): boolean {
|
|
819
|
+
if (before === undefined) return true;
|
|
820
|
+
// The product id is irrelevant to this comparison and identical on both sides — no verb here changes it.
|
|
821
|
+
return JSON.stringify(subscriptionView("", before)) !== JSON.stringify(subscriptionView("", after));
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* A quote on the wire: the store's own three figures, with the dates rendered.
|
|
826
|
+
*
|
|
827
|
+
* Nothing is computed, summed or netted — `data/subscription.ts` holds the argument, and the short form is
|
|
828
|
+
* that a second answer to "what will this cost" is a second number for a customer to hold against their
|
|
829
|
+
* statement. The settlements cross verbatim because they already are what a screen renders: a direction
|
|
830
|
+
* and a magnitude, with the direction as the discriminant so the amount cannot be reached without it.
|
|
831
|
+
*
|
|
832
|
+
* **The money is already rendered when it gets here, and that is why this function does not render it.**
|
|
833
|
+
* `QuotedMoney` requires the string, so the figure exists from the moment the quote does — an adopter
|
|
834
|
+
* calling the rail directly gets the same sentence this response carries, rather than a formatting rule
|
|
835
|
+
* that lives only on the HTTP path. What this layer supplies is the *locale*, resolved from the translator
|
|
836
|
+
* seam at the route and handed down through `RailRequestContext`. See `data/renderMoney.ts`.
|
|
837
|
+
*/
|
|
838
|
+
function quoteView(quote: SubscriptionChangeQuote): PaymentsSubscriptionQuote {
|
|
839
|
+
return {
|
|
840
|
+
settlesToday: quote.settlesToday,
|
|
841
|
+
nextInvoice:
|
|
842
|
+
quote.nextInvoice === null
|
|
843
|
+
? null
|
|
844
|
+
: { settlement: quote.nextInvoice.settlement, at: quote.nextInvoice.at.toISOString() },
|
|
845
|
+
recurring:
|
|
846
|
+
quote.recurring === null
|
|
847
|
+
? null
|
|
848
|
+
: {
|
|
849
|
+
amount: quote.recurring.amount,
|
|
850
|
+
startsAt: quote.recurring.startsAt.toISOString(),
|
|
851
|
+
madeUpOf: quote.recurring.madeUpOf,
|
|
852
|
+
},
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/**
|
|
857
|
+
* A refund report on the wire: what became of each payment, and nothing that identifies one.
|
|
858
|
+
*
|
|
859
|
+
* **Every id is dropped and no amount is added.** The store's adjustment id and our own purchase id both
|
|
860
|
+
* stay server-side — a store identifier never crosses a bearer response, and an id published here is a field
|
|
861
|
+
* a request grows next — and the store's refusal sentence is throw-site context that names transactions and
|
|
862
|
+
* account facts. What a screen renders is how many payments were asked about and where each stands; the ids
|
|
863
|
+
* and the reasons are in the audit trail, where an operator is the reader.
|
|
864
|
+
*
|
|
865
|
+
* The order is the server's resolution order and the length is the input's, so a partial arrives as a
|
|
866
|
+
* countable list rather than as a success with something missing.
|
|
867
|
+
*/
|
|
868
|
+
function refundView(refund: RefundRequest): PaymentsRefundRequest {
|
|
869
|
+
return {
|
|
870
|
+
outcomes: refund.outcomes.map((outcome) =>
|
|
871
|
+
outcome.outcome === "failed" ? { outcome: "failed" } : { outcome: outcome.outcome, status: outcome.status },
|
|
872
|
+
),
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/**
|
|
877
|
+
* Record a management read.
|
|
878
|
+
*
|
|
879
|
+
* **Every read, not only the writes.** A credential quietly paging every account's purchases leaves no
|
|
880
|
+
* other trace anywhere, and the customer's ability to reconstruct what a dashboard did with the access
|
|
881
|
+
* they granted is the whole promise of the control plane. Counts, filters and identifiers only: no row, no
|
|
882
|
+
* amount, no provider identifier. Copying the purchase log into the audit trail would make a second
|
|
883
|
+
* purchase log with weaker access rules than the first.
|
|
884
|
+
*/
|
|
885
|
+
async function recordRead(
|
|
886
|
+
c: Context<PithyHonoEnv>,
|
|
887
|
+
action: PaymentsAuditAction,
|
|
888
|
+
resourceId: string | null,
|
|
889
|
+
metadata: Record<string, unknown>,
|
|
890
|
+
): Promise<void> {
|
|
891
|
+
const who = controlPlaneCaller(c);
|
|
892
|
+
await c.var.emit({
|
|
893
|
+
action,
|
|
894
|
+
outcome: "success",
|
|
895
|
+
// A management client, not a user of this app — and the actor is the token's `sub`, so the trail names
|
|
896
|
+
// which person at the dashboard looked, not merely "the dashboard".
|
|
897
|
+
actorType: "control-plane",
|
|
898
|
+
actorId: who.subject,
|
|
899
|
+
resourceType: "payments_read",
|
|
900
|
+
resourceId,
|
|
901
|
+
requestId: c.req.header("cf-ray"),
|
|
902
|
+
ip: c.req.header("cf-connecting-ip"),
|
|
903
|
+
userAgent: c.req.header("user-agent"),
|
|
904
|
+
metadata: { connectionId: who.connectionId, ...metadata },
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
export function registerPaymentsRoutes(options: PaymentsRoutesOptions): (app: Hono<PithyHonoEnv>) => void {
|
|
909
|
+
const { config } = options;
|
|
910
|
+
const base = options.basePath ?? config.basePath;
|
|
911
|
+
const clock = options.now ?? (() => new Date());
|
|
912
|
+
const trust = options.trust ?? {};
|
|
913
|
+
/** The seam `payments()` built, or the resolver-less one this config implies. See {@link PaymentsRoutesOptions.subject}. */
|
|
914
|
+
const seam: PaymentsSubjectSeam = options.subject ?? { billingSubject: config.billingSubject };
|
|
915
|
+
|
|
916
|
+
/**
|
|
917
|
+
* Stripe's hosted-flow return URLs, or the 404 that says the rail is off.
|
|
918
|
+
*
|
|
919
|
+
* Reachable only when `rails.stripe` is false — the catalog refuses to parse with the rail on and the block
|
|
920
|
+
* absent, so a deploy is where that is caught. This is the "Stripe is not enabled here" answer, and it costs
|
|
921
|
+
* no secret read to give.
|
|
922
|
+
*/
|
|
923
|
+
function stripeSettings(): PaymentsStripeSettings {
|
|
924
|
+
if (config.stripe === undefined) {
|
|
925
|
+
throw new PaymentsRailNotConfiguredError({
|
|
926
|
+
detail: "The stripe rail is off in this project's config, so there are no hosted flows to start.",
|
|
927
|
+
});
|
|
928
|
+
}
|
|
929
|
+
return config.stripe;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
/**
|
|
933
|
+
* Where a hosted checkout returns the browser, for whichever rail is taking the money.
|
|
934
|
+
*
|
|
935
|
+
* Each rail carries its own pair, because each returns to a different page: a Stripe success URL holds a
|
|
936
|
+
* `{CHECKOUT_SESSION_ID}` token the return page posts back, and a Lemon Squeezy one cannot — that rail has
|
|
937
|
+
* no submittable receipt, so its page shows a pending state and waits for the webhook.
|
|
938
|
+
*/
|
|
939
|
+
function returnUrls(rail: PaymentsRail): { successUrl: string; cancelUrl?: string } {
|
|
940
|
+
if (rail === "paddle") {
|
|
941
|
+
if (config.paddle === undefined) {
|
|
942
|
+
throw new PaymentsRailNotConfiguredError({
|
|
943
|
+
detail: "The paddle rail is off in this project's config, so there are no checkout flows to start.",
|
|
944
|
+
});
|
|
945
|
+
}
|
|
946
|
+
// The cancel URL is optional here and required for Stripe, and the asymmetry is the store's: an
|
|
947
|
+
// overlay a buyer closes leaves them exactly where they were, with nowhere to be sent.
|
|
948
|
+
return { successUrl: config.paddle.successUrl, cancelUrl: config.paddle.cancelUrl };
|
|
949
|
+
}
|
|
950
|
+
if (rail === "lemonSqueezy") {
|
|
951
|
+
if (config.lemonSqueezy === undefined) {
|
|
952
|
+
throw new PaymentsRailNotConfiguredError({
|
|
953
|
+
detail: "The lemonSqueezy rail is off in this project's config, so there are no hosted flows to start.",
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
// No cancel URL: that store's checkout has no cancel destination to send one to.
|
|
957
|
+
return { successUrl: config.lemonSqueezy.successUrl };
|
|
958
|
+
}
|
|
959
|
+
const settings = stripeSettings();
|
|
960
|
+
return { successUrl: settings.successUrl, cancelUrl: settings.cancelUrl };
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/**
|
|
964
|
+
* Which hosted-checkout rail this request is for.
|
|
965
|
+
*
|
|
966
|
+
* A product declares its rails by carrying their blocks, so the candidates are the checkout-capable rails
|
|
967
|
+
* this project has enabled *and* this product is listed on. One candidate is the common case and needs no
|
|
968
|
+
* request field. Two, with the caller naming neither, is refused: picking one would decide who takes a
|
|
969
|
+
* customer's money on their behalf, and an adopter selling on two rails means to offer the choice.
|
|
970
|
+
*
|
|
971
|
+
* The candidate list is `PAYMENTS_HOSTED_RAILS`, and it is not written out here: this route and the
|
|
972
|
+
* scaffolded screens ask the same question, and a fifth copy of the answer is what #336 was.
|
|
973
|
+
* `PAYMENTS_HOSTED_RAILS` is ordered, which is the only thing that order does — when a product sells on
|
|
974
|
+
* two rails and the caller named neither the request is refused, so nothing here resolves silently; the
|
|
975
|
+
* order exists to make the refusal's message deterministic.
|
|
976
|
+
*/
|
|
977
|
+
function checkoutRailFor(entry: PaymentsCatalogEntry, requested: PaymentsRail | undefined): PaymentsRail {
|
|
978
|
+
const enabled = PAYMENTS_HOSTED_RAILS.filter((rail) => railEnabled(config, rail));
|
|
979
|
+
|
|
980
|
+
// No hosted-checkout rail at all. That is a fact about the deployment rather than about the product —
|
|
981
|
+
// a mobile-only project sells through Apple and Google and has no browser flow to start — so it is the
|
|
982
|
+
// rail refusal, and it costs no credential read and no round-trip to give.
|
|
983
|
+
if (enabled.length === 0) {
|
|
984
|
+
throw new PaymentsRailNotConfiguredError({
|
|
985
|
+
detail:
|
|
986
|
+
"No hosted-checkout rail is on in this project's config, so there are no hosted flows to start. Enable `rails.stripe`, `rails.lemonSqueezy`, or `rails.paddle`.",
|
|
987
|
+
});
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
const candidates = enabled.filter((rail) => providerProductId(entry.product, rail) !== undefined);
|
|
991
|
+
|
|
992
|
+
if (requested !== undefined) {
|
|
993
|
+
if (candidates.includes(requested)) return requested;
|
|
994
|
+
// Named a rail this product is not sold on, or one this project has turned off. A 404 on the product
|
|
995
|
+
// rather than the rail: naming which of the two it was would describe the deployment to a stranger.
|
|
996
|
+
throw new PaymentsProductNotFoundError({
|
|
997
|
+
detail: `Product "${entry.id}" cannot be bought through ${requested} here — the rail is off, or the product declares no ${requested} block.`,
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
const [only, ...rest] = candidates;
|
|
1002
|
+
if (only === undefined) {
|
|
1003
|
+
throw new PaymentsProductNotFoundError({
|
|
1004
|
+
detail: `Product "${entry.id}" declares no enabled hosted-checkout rail, so it cannot be bought through this route.`,
|
|
1005
|
+
});
|
|
1006
|
+
}
|
|
1007
|
+
if (rest.length > 0) {
|
|
1008
|
+
throw new ValidationError({
|
|
1009
|
+
message: "Choose how to pay.",
|
|
1010
|
+
action: `Send \`rail\` as one of: ${candidates.join(", ")}.`,
|
|
1011
|
+
detail: `Product "${entry.id}" is sold through ${candidates.join(" and ")}, and the request named neither.`,
|
|
1012
|
+
});
|
|
1013
|
+
}
|
|
1014
|
+
return only;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
/** One rail, narrowed to the interface that creates hosted sessions. */
|
|
1018
|
+
async function checkoutRail(
|
|
1019
|
+
c: Context<PithyHonoEnv>,
|
|
1020
|
+
rail: PaymentsRail,
|
|
1021
|
+
): Promise<PaymentsRailProvider & CheckoutRail> {
|
|
1022
|
+
const provider = resolveRailProvider(rail, config, await credentials(c), trust);
|
|
1023
|
+
if (!isCheckoutRail(provider)) {
|
|
1024
|
+
// Structural rather than a name check, so a future rail that initiates purchases needs no edit here and one
|
|
1025
|
+
// that does not can never be asked.
|
|
1026
|
+
throw new PaymentsRailNotConfiguredError({
|
|
1027
|
+
detail: `The ${provider.rail} rail does not create hosted sessions.`,
|
|
1028
|
+
});
|
|
1029
|
+
}
|
|
1030
|
+
return provider;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
/**
|
|
1034
|
+
* One rail, narrowed to the interface that manages a subscription from the server.
|
|
1035
|
+
*
|
|
1036
|
+
* {@link checkoutRail}'s shape and {@link checkoutRail}'s reason: structural, never a rail-name check, so
|
|
1037
|
+
* a rail that gains the ability needs no edit here and one that has not can never be asked. The guard
|
|
1038
|
+
* ANDs all five verbs — a rail missing any one of them is simply not a subscription rail, and the answer
|
|
1039
|
+
* is `payments/rail_not_configured` rather than a `TypeError` thrown mid-cancellation on a route that has
|
|
1040
|
+
* already written an audit row.
|
|
1041
|
+
*
|
|
1042
|
+
* **Which rail is never a request field.** It is the rail on the caller's own purchase row: a subscription
|
|
1043
|
+
* that exists lives at exactly one store, and a rail a caller could name could only ever be the wrong
|
|
1044
|
+
* store asked about somebody's subscription. That is why the argument comes from
|
|
1045
|
+
* {@link changeableSubscription} and from nowhere else.
|
|
1046
|
+
*
|
|
1047
|
+
* The refusal is honest on the mobile rails rather than a bug: an Apple or Google subscription is changed
|
|
1048
|
+
* inside the store's own UI, on the device, and there is no server call that does it.
|
|
1049
|
+
*/
|
|
1050
|
+
async function subscriptionRail(
|
|
1051
|
+
c: Context<PithyHonoEnv>,
|
|
1052
|
+
rail: PaymentsRail,
|
|
1053
|
+
): Promise<PaymentsRailProvider & SubscriptionRail> {
|
|
1054
|
+
const provider = resolveRailProvider(rail, config, await credentials(c), trust);
|
|
1055
|
+
if (!isSubscriptionRail(provider)) {
|
|
1056
|
+
throw new PaymentsRailNotConfiguredError({
|
|
1057
|
+
detail: `The ${provider.rail} rail does not manage subscriptions from the server. A subscription on that store is changed inside the store's own UI, on the device.`,
|
|
1058
|
+
});
|
|
1059
|
+
}
|
|
1060
|
+
return provider;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
/**
|
|
1064
|
+
* One rail, narrowed to the interface that refunds at its store.
|
|
1065
|
+
*
|
|
1066
|
+
* Its own narrowing rather than a sixth verb on {@link subscriptionRail}, because the two abilities are
|
|
1067
|
+
* independent in both directions: Google Play refunds from the server and changes no plan from it, and
|
|
1068
|
+
* Apple's only refund endpoint is a lookup. Asking one guard for both would refuse a rail that can do the
|
|
1069
|
+
* thing being asked for.
|
|
1070
|
+
*
|
|
1071
|
+
* Structural, like the others, and the rail still comes from the caller's own purchase row rather than
|
|
1072
|
+
* from a request — a rail a caller could name could only ever be the wrong store asked about somebody
|
|
1073
|
+
* else's money.
|
|
1074
|
+
*/
|
|
1075
|
+
async function refundRail(c: Context<PithyHonoEnv>, rail: PaymentsRail): Promise<PaymentsRailProvider & RefundRail> {
|
|
1076
|
+
const provider = resolveRailProvider(rail, config, await credentials(c), trust);
|
|
1077
|
+
if (!isRefundRail(provider)) {
|
|
1078
|
+
throw new PaymentsRailNotConfiguredError({
|
|
1079
|
+
detail: `The ${provider.rail} rail does not refund from the server. A refund on that store is asked for through the store itself.`,
|
|
1080
|
+
});
|
|
1081
|
+
}
|
|
1082
|
+
return provider;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
/**
|
|
1086
|
+
* The store's own subscription ids this caller holds on a rail — the family keys of their own rows.
|
|
1087
|
+
*
|
|
1088
|
+
* Read from the projection rather than from the store, and always for the subject the seam resolved. What
|
|
1089
|
+
* it feeds is a portal request for authenticated cancel links, so the set has to be exactly what this
|
|
1090
|
+
* holder owns: a wider one would mint somebody else's cancel button, and there is no request field that
|
|
1091
|
+
* could widen it.
|
|
1092
|
+
*
|
|
1093
|
+
* `originalTransactionId` rather than `providerTransactionId`, because a subscription's money rows name
|
|
1094
|
+
* the family there and the state row names itself there too — so one column answers for both.
|
|
1095
|
+
*/
|
|
1096
|
+
async function ownSubscriptionIds(
|
|
1097
|
+
c: Context<PithyHonoEnv>,
|
|
1098
|
+
rail: PaymentsRail,
|
|
1099
|
+
subject: PaymentsSubject,
|
|
1100
|
+
): Promise<readonly string[]> {
|
|
1101
|
+
const rows = await paymentsDatabase(database(c))
|
|
1102
|
+
.selectFrom(PAYMENTS_PURCHASES_TABLE)
|
|
1103
|
+
.select(["originalTransactionId"])
|
|
1104
|
+
// Both halves, as everywhere: an id-only predicate would mint `user:acme` a cancel link for whatever
|
|
1105
|
+
// `organization:acme` is paying for.
|
|
1106
|
+
.where("subjectType", "=", subject.subjectType)
|
|
1107
|
+
.where("subjectId", "=", subject.subjectId)
|
|
1108
|
+
.where("rail", "=", rail)
|
|
1109
|
+
.where("type", "=", "subscription")
|
|
1110
|
+
.orderBy("providerEventAt", "desc")
|
|
1111
|
+
.execute();
|
|
1112
|
+
const ids = new Set<string>();
|
|
1113
|
+
for (const row of rows) if (typeof row.originalTransactionId === "string") ids.add(row.originalTransactionId);
|
|
1114
|
+
return [...ids];
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
/** The subject's store account on a rail, or null. What keeps one buyer to one Stripe customer. */
|
|
1118
|
+
async function accountFor(
|
|
1119
|
+
c: Context<PithyHonoEnv>,
|
|
1120
|
+
rail: PaymentsRail,
|
|
1121
|
+
subject: PaymentsSubject,
|
|
1122
|
+
): Promise<string | null> {
|
|
1123
|
+
return (await providerAccountForSubject(paymentsDatabase(database(c)), rail, subject)) ?? null;
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
/**
|
|
1127
|
+
* Verify one receipt through its rail and project it against the subject. Shared by submit and restore.
|
|
1128
|
+
*
|
|
1129
|
+
* **The subject is a parameter, not resolved here**, and that is deliberate: `/restore` submits a batch, and
|
|
1130
|
+
* a seam call per receipt would ask the adopter's resolver the same question fifty times — and would let the
|
|
1131
|
+
* answer change halfway through a batch, filing one client's history under two holders.
|
|
1132
|
+
*/
|
|
1133
|
+
async function submit(
|
|
1134
|
+
c: Context<PithyHonoEnv>,
|
|
1135
|
+
subject: PaymentsSubject,
|
|
1136
|
+
rail: PaymentsRail,
|
|
1137
|
+
receipt: string,
|
|
1138
|
+
): Promise<PurchaseProjection> {
|
|
1139
|
+
const now = clock();
|
|
1140
|
+
const d1 = database(c);
|
|
1141
|
+
const provider = resolveRailProvider(rail, config, await credentials(c), trust);
|
|
1142
|
+
// **The deployment travels with the clock**, and on one rail it is load-bearing rather than incidental.
|
|
1143
|
+
// Paddle's `verify` honors a submitted transaction's ownership stamp only when a MAC keyed on this
|
|
1144
|
+
// deployment's own secret verifies beside it — and the environment is *inside* that MAC's message, so a
|
|
1145
|
+
// rail handed no deployment can prove nothing and refuses every submission. The other rails ignore it.
|
|
1146
|
+
const verified = await provider.verify(receipt, { now, deployment: deploymentName(c) });
|
|
1147
|
+
|
|
1148
|
+
// A purchase this deployment initiated names its own purchaser, and a submission from anyone else is refused
|
|
1149
|
+
// before it is projected. Only Stripe and Paddle set this — see `VerifiedPurchase.accountReference` for why
|
|
1150
|
+
// an app-supplied identifier like Apple's `appAccountToken` deliberately does not.
|
|
1151
|
+
//
|
|
1152
|
+
// **A reference that does not decode is refused, not ignored.** It is bytes from a store, so it may be a
|
|
1153
|
+
// bare id — the shape every pre-subject client sent — or a kind this build does not know, and neither names
|
|
1154
|
+
// this caller. `sameSubject` answers false for `undefined` against anything, so the one comparison covers
|
|
1155
|
+
// the malformed case and the wrong-holder case together, and covers both halves of the pair: `user:acme`
|
|
1156
|
+
// submitting `organization:acme`'s purchase is refused exactly as a stranger's is.
|
|
1157
|
+
if (verified.accountReference) {
|
|
1158
|
+
const stamped = decodeSubjectReference(verified.accountReference);
|
|
1159
|
+
if (!sameSubject(stamped, subject)) {
|
|
1160
|
+
throw new PaymentsReceiptAlreadyOwnedError({
|
|
1161
|
+
detail: `${rail} purchase ${verified.event.providerTransactionId} was started for ${stamped === undefined ? "a reference this build cannot read" : encodeSubjectReference(stamped)}; ${encodeSubjectReference(subject)} submitted it.`,
|
|
1162
|
+
});
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
// `return await`, not `return`. Returning a promise from an async function makes this frame *adopt* the
|
|
1167
|
+
// rejection instead of raising it, and the workerd runtime then reports the adopted promise as an
|
|
1168
|
+
// unhandled rejection even though Hono's `onError` answers the request correctly. A refusal here is normal
|
|
1169
|
+
// traffic — a stale receipt, a sandbox transaction — so it must not read as a runtime fault in a log.
|
|
1170
|
+
const projection = await projectPurchase(
|
|
1171
|
+
d1,
|
|
1172
|
+
// Both halves, off the one resolved subject. The event carries them flat because a row does, and
|
|
1173
|
+
// spreading the pair is what keeps a kind from one place beside an id from another.
|
|
1174
|
+
{ ...verified.event, ...subject },
|
|
1175
|
+
{ config, environment: deploymentEnvironment(c), now },
|
|
1176
|
+
);
|
|
1177
|
+
|
|
1178
|
+
// The account link comes **after** the projection, not before it.
|
|
1179
|
+
//
|
|
1180
|
+
// On Apple and Google `providerAccountId` is `appAccountToken` / `obfuscatedAccountId` — a value the app
|
|
1181
|
+
// chose. Writing the link first meant any receipt this deployment refused still minted a permanent
|
|
1182
|
+
// ownership record: a Sandbox transaction is free and is rejected by the environment check *inside* the
|
|
1183
|
+
// projection, so an attacker could bind an unlimited number of identifiers at no cost and then collect the
|
|
1184
|
+
// notifications of whoever those identifiers really belonged to. Requiring a projected purchase first means
|
|
1185
|
+
// a binding costs a real purchase on the deployment's own environment, and leaves a row naming who made it.
|
|
1186
|
+
if (verified.providerAccountId) {
|
|
1187
|
+
const bound = await linkProviderAccount(d1, rail, verified.providerAccountId, subject, { now });
|
|
1188
|
+
// The binding never rebinds, so a disagreement means somebody else claimed this store account first.
|
|
1189
|
+
// That is legitimate often enough (a shared device, a reinstall against a new Pithy account) that
|
|
1190
|
+
// refusing would hand an attacker a way to lock the real owner out — so the first binding stands, this
|
|
1191
|
+
// caller keeps the purchase they just projected, and the collision is recorded for somebody to look at.
|
|
1192
|
+
// The purchases that were waiting on exactly this link. On Apple and Google the client submission is
|
|
1193
|
+
// the *only* link event there is — no webhook on those rails carries an account reference — so a rail
|
|
1194
|
+
// that cannot replay its own recorded payload is answered by a no-op here rather than by an omission.
|
|
1195
|
+
await repairOrphans(c, rail, now);
|
|
1196
|
+
if (!sameSubject(bound, subject)) {
|
|
1197
|
+
await c.var.emit({
|
|
1198
|
+
action: PaymentsAuditActions.providerAccountContested,
|
|
1199
|
+
outcome: "denied",
|
|
1200
|
+
severity: "warning",
|
|
1201
|
+
// The **person** who submitted, not the holder they act for. An organization does not press a
|
|
1202
|
+
// button; somebody at it does, and a trail that recorded the company here would answer "who did
|
|
1203
|
+
// this" with a name nobody can be asked about it.
|
|
1204
|
+
actorType: "user",
|
|
1205
|
+
actorId: c.var.auth?.userId,
|
|
1206
|
+
resourceType: "provider_account",
|
|
1207
|
+
resourceId: `${rail}:${verified.providerAccountId}`,
|
|
1208
|
+
// Two keys per subject, never one joined string. A trail is queried by equality on a column, and a
|
|
1209
|
+
// `user:ada` that has to be split before it can be compared is a column nobody filters correctly
|
|
1210
|
+
// twice. `encodeSubjectReference` is for the single-field slots a *store* gives us; this is ours.
|
|
1211
|
+
metadata: {
|
|
1212
|
+
rail,
|
|
1213
|
+
boundToType: bound.subjectType,
|
|
1214
|
+
boundToId: bound.subjectId,
|
|
1215
|
+
claimedByType: subject.subjectType,
|
|
1216
|
+
claimedById: subject.subjectId,
|
|
1217
|
+
},
|
|
1218
|
+
});
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
await fulfill(c, projection);
|
|
1223
|
+
return projection;
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
/**
|
|
1227
|
+
* Credit what the purchase bought, and reverse it where a refund and the catalog both ask.
|
|
1228
|
+
*
|
|
1229
|
+
* A no-op for every product with no `grants` clause, and it resolves the optional `@pithy-sh/ledger` import
|
|
1230
|
+
* only when one is present — so a project selling nothing but features never reaches the package. A failure
|
|
1231
|
+
* here is deliberately left to propagate: the credit's ref is stable, so a retry settles it, and swallowing
|
|
1232
|
+
* it would lose a purchase's currency with nothing anywhere to read.
|
|
1233
|
+
*/
|
|
1234
|
+
/**
|
|
1235
|
+
* Project the orphans a link just made resolvable. Never throws: see {@link repairOrphanedEvents}.
|
|
1236
|
+
*
|
|
1237
|
+
* The rail provider is built here rather than passed, because `replay` is the one thing the repair needs
|
|
1238
|
+
* from a rail and building one costs a secrets read the handler has already paid for on every path that
|
|
1239
|
+
* reaches this.
|
|
1240
|
+
*/
|
|
1241
|
+
async function repairOrphans(c: Context<PithyHonoEnv>, rail: PaymentsRail, now: Date): Promise<void> {
|
|
1242
|
+
const provider = resolveRailProvider(rail, config, await credentials(c), trust);
|
|
1243
|
+
const replay = provider.replay?.bind(provider);
|
|
1244
|
+
if (replay === undefined) return;
|
|
1245
|
+
const repaired = await repairOrphanedEvents(database(c), rail, {
|
|
1246
|
+
config,
|
|
1247
|
+
environment: deploymentEnvironment(c),
|
|
1248
|
+
now,
|
|
1249
|
+
replay: (payload) => replay(payload, { now, deployment: deploymentName(c) }),
|
|
1250
|
+
fulfill: (projection) => fulfill(c, projection),
|
|
1251
|
+
});
|
|
1252
|
+
for (const providerEventId of repaired.projected) {
|
|
1253
|
+
await c.var.emit({
|
|
1254
|
+
action: PaymentsAuditActions.webhookReceived,
|
|
1255
|
+
outcome: "success",
|
|
1256
|
+
actorType: "service",
|
|
1257
|
+
actorId: rail,
|
|
1258
|
+
metadata: { rail, providerEventId, projected: true, repaired: "orphan" },
|
|
1259
|
+
});
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
async function fulfill(c: Context<PithyHonoEnv>, projection: PurchaseProjection): Promise<void> {
|
|
1264
|
+
await fulfillPurchase(database(c), projection, {
|
|
1265
|
+
config,
|
|
1266
|
+
emit: c.var.emit,
|
|
1267
|
+
now: () => clock().getTime(),
|
|
1268
|
+
});
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
return (app) => {
|
|
1272
|
+
// The management surface: control-plane, read-only, under `admin/`. Its paths are disjoint from the
|
|
1273
|
+
// player surface by construction, so registering it first is presentation rather than semantics.
|
|
1274
|
+
|
|
1275
|
+
/**
|
|
1276
|
+
* CONTROL PLANE. What this project sells — the catalog, with the entitlement keys each product grants.
|
|
1277
|
+
*
|
|
1278
|
+
* **The read that makes a comp control possible.** Without it a management client offering "give this
|
|
1279
|
+
* person an entitlement" has nothing to populate a list from, so it offers a text box — and a text box
|
|
1280
|
+
* beside a free-text key is how an operator who means `pro` types `pr` and gets a success. The
|
|
1281
|
+
* validation on `entitlements/grant` is the other half, and it is the half that matters: this makes a
|
|
1282
|
+
* good control possible, that makes a bad one impossible.
|
|
1283
|
+
*
|
|
1284
|
+
* Its own scope. Reading what a project sells is not reading what anybody bought — it names no account
|
|
1285
|
+
* and no transaction, and would answer identically against a database with no rows in it — so a tool
|
|
1286
|
+
* that needs a dropdown can hold this and nothing else.
|
|
1287
|
+
*
|
|
1288
|
+
* No query at all, and none to add: the catalog is small, fixed at deploy, and has no page. What
|
|
1289
|
+
* crosses is decided by `adminCatalogView` and asserted as an invariant rather than as a field list.
|
|
1290
|
+
*/
|
|
1291
|
+
app.get(`${base}/admin/catalog`, requireControlPlane(PAYMENTS_CATALOG_READ_SCOPE), async (c) => {
|
|
1292
|
+
const view = adminCatalogView(config);
|
|
1293
|
+
await recordRead(c, PaymentsAuditActions.catalogRead, null, {
|
|
1294
|
+
products: view.enabled ? view.products.length : 0,
|
|
1295
|
+
});
|
|
1296
|
+
return c.json(view satisfies PaymentsAdminCatalogResponse, 200);
|
|
1297
|
+
});
|
|
1298
|
+
|
|
1299
|
+
/**
|
|
1300
|
+
* CONTROL PLANE. The purchase log, paged, newest first.
|
|
1301
|
+
*
|
|
1302
|
+
* The filters narrow and never widen: there is no shape of this request that reaches a column the
|
|
1303
|
+
* projection does not return, and no filter that turns the response into something a wider scope
|
|
1304
|
+
* would have been needed for. `environment` is unfiltered by default on purpose — hiding sandbox
|
|
1305
|
+
* transactions would hide the single thing an operator most needs to notice on a production board.
|
|
1306
|
+
*/
|
|
1307
|
+
app.get(
|
|
1308
|
+
`${base}/admin/purchases`,
|
|
1309
|
+
requireControlPlane(PAYMENTS_PURCHASES_READ_SCOPE),
|
|
1310
|
+
zValidator("query", AdminPurchasesQuery, validationHook),
|
|
1311
|
+
async (c) => {
|
|
1312
|
+
const query = c.req.valid("query");
|
|
1313
|
+
const subject = subjectFilter(query);
|
|
1314
|
+
const page = await listPurchases(database(c), { ...query, subject });
|
|
1315
|
+
// The audit row's `resourceId` is one column, so the pair is encoded for it — the same encoding a
|
|
1316
|
+
// store's single-field slot gets, and the only place in this file one is written. The **filters** stay
|
|
1317
|
+
// two keys, because those are what a trail is queried by.
|
|
1318
|
+
await recordRead(
|
|
1319
|
+
c,
|
|
1320
|
+
PaymentsAuditActions.purchasesRead,
|
|
1321
|
+
subject === undefined ? null : encodeSubjectReference(subject),
|
|
1322
|
+
{
|
|
1323
|
+
returned: page.items.length,
|
|
1324
|
+
resumed: query.cursor !== undefined,
|
|
1325
|
+
filters: {
|
|
1326
|
+
subjectType: subject?.subjectType ?? null,
|
|
1327
|
+
subjectId: subject?.subjectId ?? null,
|
|
1328
|
+
rail: query.rail ?? null,
|
|
1329
|
+
status: query.status ?? null,
|
|
1330
|
+
environment: query.environment ?? null,
|
|
1331
|
+
},
|
|
1332
|
+
},
|
|
1333
|
+
);
|
|
1334
|
+
return c.json(
|
|
1335
|
+
{
|
|
1336
|
+
purchases: page.items.map(adminPurchaseView),
|
|
1337
|
+
nextCursor: page.nextCursor,
|
|
1338
|
+
} satisfies PaymentsAdminPurchasesResponse,
|
|
1339
|
+
200,
|
|
1340
|
+
);
|
|
1341
|
+
},
|
|
1342
|
+
);
|
|
1343
|
+
|
|
1344
|
+
/**
|
|
1345
|
+
* CONTROL PLANE. The purchases that renew, paged, newest first.
|
|
1346
|
+
*
|
|
1347
|
+
* Its own scope, granted separately from the purchase log's. A renewal or churn tool needs to know who
|
|
1348
|
+
* is still paying and when their period ends; it has no business reading what everybody ever bought,
|
|
1349
|
+
* and an adopter who wants to hand out only the narrower half must have a way to say so. The narrowing
|
|
1350
|
+
* is applied by `listSubscriptions` rather than by a `type` filter the request could name, so the
|
|
1351
|
+
* wider read is not reachable through this scope by construction.
|
|
1352
|
+
*/
|
|
1353
|
+
app.get(
|
|
1354
|
+
`${base}/admin/subscriptions`,
|
|
1355
|
+
requireControlPlane(PAYMENTS_SUBSCRIPTIONS_READ_SCOPE),
|
|
1356
|
+
zValidator("query", AdminSubscriptionsQuery, validationHook),
|
|
1357
|
+
async (c) => {
|
|
1358
|
+
const query = c.req.valid("query");
|
|
1359
|
+
const subject = subjectFilter(query);
|
|
1360
|
+
const page = await listSubscriptions(database(c), { ...query, subject });
|
|
1361
|
+
await recordRead(
|
|
1362
|
+
c,
|
|
1363
|
+
PaymentsAuditActions.subscriptionsRead,
|
|
1364
|
+
subject === undefined ? null : encodeSubjectReference(subject),
|
|
1365
|
+
{
|
|
1366
|
+
returned: page.items.length,
|
|
1367
|
+
resumed: query.cursor !== undefined,
|
|
1368
|
+
filters: {
|
|
1369
|
+
subjectType: subject?.subjectType ?? null,
|
|
1370
|
+
subjectId: subject?.subjectId ?? null,
|
|
1371
|
+
status: query.status ?? null,
|
|
1372
|
+
},
|
|
1373
|
+
},
|
|
1374
|
+
);
|
|
1375
|
+
return c.json(
|
|
1376
|
+
{
|
|
1377
|
+
subscriptions: page.items.map(adminPurchaseView),
|
|
1378
|
+
nextCursor: page.nextCursor,
|
|
1379
|
+
} satisfies PaymentsAdminSubscriptionsResponse,
|
|
1380
|
+
200,
|
|
1381
|
+
);
|
|
1382
|
+
},
|
|
1383
|
+
);
|
|
1384
|
+
|
|
1385
|
+
/**
|
|
1386
|
+
* CONTROL PLANE. The entitlement model, paged — what accounts hold, and why.
|
|
1387
|
+
*
|
|
1388
|
+
* `granted` is resolved here against `expiresAt`, exactly as the gate the adopter's own app calls
|
|
1389
|
+
* resolves it. A dashboard that recomputed it from the stored flag would eventually disagree with that
|
|
1390
|
+
* gate, and the customer would believe the dashboard.
|
|
1391
|
+
*/
|
|
1392
|
+
app.get(
|
|
1393
|
+
`${base}/admin/entitlements`,
|
|
1394
|
+
requireControlPlane(PAYMENTS_ENTITLEMENTS_READ_SCOPE),
|
|
1395
|
+
zValidator("query", AdminEntitlementsQuery, validationHook),
|
|
1396
|
+
async (c) => {
|
|
1397
|
+
const query = c.req.valid("query");
|
|
1398
|
+
const now = clock();
|
|
1399
|
+
const subject = subjectFilter(query);
|
|
1400
|
+
const page = await listEntitlements(database(c), { ...query, subject });
|
|
1401
|
+
await recordRead(
|
|
1402
|
+
c,
|
|
1403
|
+
PaymentsAuditActions.entitlementsRead,
|
|
1404
|
+
subject === undefined ? null : encodeSubjectReference(subject),
|
|
1405
|
+
{
|
|
1406
|
+
returned: page.items.length,
|
|
1407
|
+
resumed: query.cursor !== undefined,
|
|
1408
|
+
filters: {
|
|
1409
|
+
subjectType: subject?.subjectType ?? null,
|
|
1410
|
+
subjectId: subject?.subjectId ?? null,
|
|
1411
|
+
entitlement: query.entitlement ?? null,
|
|
1412
|
+
},
|
|
1413
|
+
},
|
|
1414
|
+
);
|
|
1415
|
+
return c.json(
|
|
1416
|
+
{
|
|
1417
|
+
entitlements: page.items.map((row) => adminEntitlementView(row, now)),
|
|
1418
|
+
nextCursor: page.nextCursor,
|
|
1419
|
+
} satisfies PaymentsAdminEntitlementsResponse,
|
|
1420
|
+
200,
|
|
1421
|
+
);
|
|
1422
|
+
},
|
|
1423
|
+
);
|
|
1424
|
+
|
|
1425
|
+
/**
|
|
1426
|
+
* CONTROL PLANE. Everything one subject is entitled to, resolved now.
|
|
1427
|
+
*
|
|
1428
|
+
* **Two path segments, because the address is a pair.** The table is keyed
|
|
1429
|
+
* `UNIQUE (subjectType, subjectId, entitlement)`, and nothing in the kit keeps an organization id from
|
|
1430
|
+
* equalling some user's — so a route addressed by the id alone would answer about whichever holder
|
|
1431
|
+
* happened to carry it. Both segments are validated: an unknown kind is a 400 naming the two that exist,
|
|
1432
|
+
* which is what a malformed address deserves, where the encoded-reference form would have been a 404
|
|
1433
|
+
* reading as a holder who is simply not here.
|
|
1434
|
+
*
|
|
1435
|
+
* Unpaginated, because the key above admits at most one row per entitlement. A subject holding nothing is
|
|
1436
|
+
* an empty list rather than a 404: an entitlement row appears with the first purchase that grants one, so
|
|
1437
|
+
* its absence is not a missing holder — and a 404 would make this surface an existence oracle for user and
|
|
1438
|
+
* organization ids alike.
|
|
1439
|
+
*/
|
|
1440
|
+
app.get(
|
|
1441
|
+
`${base}/admin/entitlements/:subjectType/:subjectId`,
|
|
1442
|
+
requireControlPlane(PAYMENTS_ENTITLEMENTS_READ_SCOPE),
|
|
1443
|
+
zValidator("param", AdminSubjectParam, validationHook),
|
|
1444
|
+
async (c) => {
|
|
1445
|
+
const subject = c.req.valid("param");
|
|
1446
|
+
const now = clock();
|
|
1447
|
+
const rows = await readEntitlements(database(c), subject);
|
|
1448
|
+
await recordRead(c, PaymentsAuditActions.entitlementsRead, encodeSubjectReference(subject), {
|
|
1449
|
+
subjectType: subject.subjectType,
|
|
1450
|
+
subjectId: subject.subjectId,
|
|
1451
|
+
returned: rows.length,
|
|
1452
|
+
});
|
|
1453
|
+
return c.json(
|
|
1454
|
+
{
|
|
1455
|
+
...subject,
|
|
1456
|
+
entitlements: rows.map((row) => adminEntitlementView(row, now)),
|
|
1457
|
+
} satisfies PaymentsAdminSubjectEntitlementsResponse,
|
|
1458
|
+
200,
|
|
1459
|
+
);
|
|
1460
|
+
},
|
|
1461
|
+
);
|
|
1462
|
+
|
|
1463
|
+
/**
|
|
1464
|
+
* CONTROL PLANE. What reconciliation has done — the passes this deployment has run, newest first.
|
|
1465
|
+
*
|
|
1466
|
+
* The compensating control for a delivery mechanism that is known to fail leaves a trace, so an adopter
|
|
1467
|
+
* can tell a healthy integration from one whose cron stopped firing. An empty page is a real answer and
|
|
1468
|
+
* the loudest one this route gives.
|
|
1469
|
+
*
|
|
1470
|
+
* Its own scope: a run names no account, no transaction and no amount, so a health monitor can be granted
|
|
1471
|
+
* exactly this without acquiring the purchase log.
|
|
1472
|
+
*/
|
|
1473
|
+
app.get(
|
|
1474
|
+
`${base}/admin/reconcile-runs`,
|
|
1475
|
+
requireControlPlane(PAYMENTS_RECONCILE_READ_SCOPE),
|
|
1476
|
+
zValidator("query", AdminReconcileRunsQuery, validationHook),
|
|
1477
|
+
async (c) => {
|
|
1478
|
+
const query = c.req.valid("query");
|
|
1479
|
+
const page = await listReconcileRuns(database(c), query);
|
|
1480
|
+
await recordRead(c, PaymentsAuditActions.reconcileRunsRead, null, {
|
|
1481
|
+
returned: page.items.length,
|
|
1482
|
+
resumed: query.cursor !== undefined,
|
|
1483
|
+
filters: { rail: query.rail ?? null, environment: query.environment ?? null },
|
|
1484
|
+
});
|
|
1485
|
+
return c.json(
|
|
1486
|
+
{
|
|
1487
|
+
runs: page.items.map(adminReconcileRunView),
|
|
1488
|
+
nextCursor: page.nextCursor,
|
|
1489
|
+
} satisfies PaymentsAdminReconcileRunsResponse,
|
|
1490
|
+
200,
|
|
1491
|
+
);
|
|
1492
|
+
},
|
|
1493
|
+
);
|
|
1494
|
+
|
|
1495
|
+
/**
|
|
1496
|
+
* CONTROL PLANE WRITE. Start a reconciliation pass now.
|
|
1497
|
+
*
|
|
1498
|
+
* **The counterpart to the read above, and a different power.** Reading the log says whether the nightly
|
|
1499
|
+
* repair has been firing; this one calls the store, walks the catalog and writes entitlements — granting
|
|
1500
|
+
* what a dropped webhook never granted, revoking what a missed cancellation left standing. Its own scope,
|
|
1501
|
+
* so the health monitor that alarms on a stopped cron cannot move anybody's access.
|
|
1502
|
+
*
|
|
1503
|
+
* **It answers a start, never an outcome.** The pass is a Workflow — durable, retried, outliving this
|
|
1504
|
+
* request by minutes — so there is nothing true to say here about what it repaired. The tally lands in the
|
|
1505
|
+
* run log, joined to this instance by its id.
|
|
1506
|
+
*
|
|
1507
|
+
* **A missing binding refuses rather than degrading.** `triggerWorkflow` skips an `optional` job with a
|
|
1508
|
+
* warning, which is right for a background dispatch on a request path that works without it: media
|
|
1509
|
+
* finalizes an upload and merely skips enrichment. It is wrong here. Somebody pressed a button to make a
|
|
1510
|
+
* pass happen, and answering 202 while nothing started is the failure this codebase spends its comments
|
|
1511
|
+
* avoiding — so the binding is resolved directly and its absence is a refusal naming what to deploy.
|
|
1512
|
+
*
|
|
1513
|
+
* Idempotent, like the pass itself. A second press while one is in flight starts nothing and says
|
|
1514
|
+
* `started: false`, which is a true sentence rather than a 409 that reads as a fault.
|
|
1515
|
+
*/
|
|
1516
|
+
app.post(`${base}/admin/reconcile-runs`, requireControlPlane(PAYMENTS_RECONCILE_RUN_SCOPE), async (c) => {
|
|
1517
|
+
const spec = paymentsWorkflows.reconcile;
|
|
1518
|
+
const binding = resolveWorkflowBinding(c.env as Record<string, unknown>, {
|
|
1519
|
+
binding: spec.binding,
|
|
1520
|
+
capability: PAYMENTS_CAPABILITY,
|
|
1521
|
+
log: c.var.log,
|
|
1522
|
+
});
|
|
1523
|
+
if (!binding) {
|
|
1524
|
+
// Named as a provisioning fact rather than a fault of the request: the caller's scope was right, the
|
|
1525
|
+
// route exists, and the job's host is not deployed here. `action` is what a person does about it.
|
|
1526
|
+
throw new PaymentsReconcileNotProvisionedError({
|
|
1527
|
+
detail: `Dispatching a reconciliation pass needs a Workflow binding named ${spec.binding} on this Worker.`,
|
|
1528
|
+
});
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
// `create` answers `unknown` — the binding is structural, matched by having the method at all, and a
|
|
1532
|
+
// loopback stand-in under `pithy dev` answers a different shape from the Workflows runtime. Narrowed
|
|
1533
|
+
// here rather than cast: an instance with no readable id is a pass that started and cannot be named,
|
|
1534
|
+
// which is `runId: null` and a true sentence, not a reason to fail the press.
|
|
1535
|
+
const instance: unknown = await binding.create({ params: {} });
|
|
1536
|
+
const runId =
|
|
1537
|
+
typeof instance === "object" && instance !== null && "id" in instance && typeof instance.id === "string"
|
|
1538
|
+
? instance.id
|
|
1539
|
+
: null;
|
|
1540
|
+
|
|
1541
|
+
const who = controlPlaneCaller(c);
|
|
1542
|
+
await c.var.emit({
|
|
1543
|
+
action: PaymentsAuditActions.reconcileRunStarted,
|
|
1544
|
+
outcome: "success",
|
|
1545
|
+
// The token's `sub`, so the trail names the person at the console rather than the console. One
|
|
1546
|
+
// connection is normally shared by everybody using it, and this row is the join to every repair the
|
|
1547
|
+
// pass then made.
|
|
1548
|
+
actorType: "control-plane",
|
|
1549
|
+
actorId: who.subject,
|
|
1550
|
+
resourceType: "reconcile_run",
|
|
1551
|
+
resourceId: runId,
|
|
1552
|
+
metadata: { deployment: deploymentName(c) },
|
|
1553
|
+
});
|
|
1554
|
+
|
|
1555
|
+
return c.json({ started: true, runId } satisfies PaymentsAdminReconcileRunStarted, 202);
|
|
1556
|
+
});
|
|
1557
|
+
|
|
1558
|
+
/**
|
|
1559
|
+
* AUTHED WRITE. The purchaser's own app submitting what the store SDK gave it, so the buyer sees their
|
|
1560
|
+
* entitlement immediately rather than waiting for the webhook. A replay by its own owner is a 200 with the
|
|
1561
|
+
* existing purchase — the write path is idempotent, so a repeat is not an error. A receipt belonging to
|
|
1562
|
+
* somebody else is a 409, audited as denied.
|
|
1563
|
+
*/
|
|
1564
|
+
app.post(`${base}/purchases`, requireAuth(), zValidator("json", PurchaseSubmission, validationHook), async (c) => {
|
|
1565
|
+
const input = c.req.valid("json");
|
|
1566
|
+
// Resolved before the store is called: a caller acting for nobody has no row to write, so refusing here
|
|
1567
|
+
// costs no round trip and cannot leave a charged customer with an unattributed purchase.
|
|
1568
|
+
const subject = await requirePaymentsSubject(c, seam);
|
|
1569
|
+
try {
|
|
1570
|
+
const projection = await submit(c, subject, input.rail, input.receipt);
|
|
1571
|
+
await c.var.emit({
|
|
1572
|
+
action: PaymentsAuditActions.purchaseVerified,
|
|
1573
|
+
outcome: "success",
|
|
1574
|
+
// The person who submitted, always — the subject they act for rides in the metadata below. Under
|
|
1575
|
+
// organization billing the two differ, and collapsing them would answer "who did this" with a
|
|
1576
|
+
// company rather than with somebody who can be asked.
|
|
1577
|
+
actorType: "user",
|
|
1578
|
+
actorId: c.var.auth?.userId,
|
|
1579
|
+
sessionId: c.var.auth?.sessionId,
|
|
1580
|
+
resourceType: "purchase",
|
|
1581
|
+
resourceId: projection.purchase.id,
|
|
1582
|
+
// Identifiers and outcomes only. Never the receipt: the trail is long-lived and queryable, and a
|
|
1583
|
+
// receipt is a bearer artifact. The holder is two keys, never one joined string.
|
|
1584
|
+
metadata: {
|
|
1585
|
+
rail: input.rail,
|
|
1586
|
+
subjectType: subject.subjectType,
|
|
1587
|
+
subjectId: subject.subjectId,
|
|
1588
|
+
productId: projection.product.id,
|
|
1589
|
+
status: projection.purchase.status,
|
|
1590
|
+
outcome: projection.outcome,
|
|
1591
|
+
},
|
|
1592
|
+
});
|
|
1593
|
+
return c.json(
|
|
1594
|
+
{
|
|
1595
|
+
purchase: purchaseView(projection),
|
|
1596
|
+
entitlements: projection.entitlements.map((row) =>
|
|
1597
|
+
entitlementView({ key: row.entitlement, active: row.active, expiresAt: row.expiresAt }),
|
|
1598
|
+
),
|
|
1599
|
+
} satisfies PaymentsPurchaseResponse,
|
|
1600
|
+
200,
|
|
1601
|
+
);
|
|
1602
|
+
} catch (cause) {
|
|
1603
|
+
// A refused submission is the security-relevant event, so it is recorded before the error propagates.
|
|
1604
|
+
await c.var.emit({
|
|
1605
|
+
action: PaymentsAuditActions.purchaseVerified,
|
|
1606
|
+
outcome: "denied",
|
|
1607
|
+
severity: "warning",
|
|
1608
|
+
actorType: "user",
|
|
1609
|
+
actorId: c.var.auth?.userId,
|
|
1610
|
+
sessionId: c.var.auth?.sessionId,
|
|
1611
|
+
metadata: {
|
|
1612
|
+
rail: input.rail,
|
|
1613
|
+
subjectType: subject.subjectType,
|
|
1614
|
+
subjectId: subject.subjectId,
|
|
1615
|
+
reason: cause instanceof PithyError ? cause.payload.code : "unknown",
|
|
1616
|
+
},
|
|
1617
|
+
});
|
|
1618
|
+
throw cause;
|
|
1619
|
+
}
|
|
1620
|
+
});
|
|
1621
|
+
|
|
1622
|
+
/**
|
|
1623
|
+
* AUTHED READ. Always the caller's own holder — the subject comes from the seam, never the request, so
|
|
1624
|
+
* there is no shape of this route that reads somebody else's entitlements. A pure read: repairing a stale
|
|
1625
|
+
* row is the reconciliation Workflow's job, and `expiresAt` is rechecked here on every request.
|
|
1626
|
+
*
|
|
1627
|
+
* **Nobody resolved is an empty list, not a refusal**, and that is the read half of the seam's rule. A
|
|
1628
|
+
* signed-in person with no organization selected holds nothing, which is what every gate in the kit
|
|
1629
|
+
* already answers for somebody who has bought nothing — and a 403 here would make a paywall render an
|
|
1630
|
+
* error where it should render the paywall.
|
|
1631
|
+
*/
|
|
1632
|
+
app.get(`${base}/entitlements`, requireAuth(), async (c) => {
|
|
1633
|
+
const subject = await resolvePaymentsSubject(c, seam);
|
|
1634
|
+
if (subject === undefined) {
|
|
1635
|
+
return c.json({ entitlements: [] } satisfies PaymentsEntitlementsResponse, 200);
|
|
1636
|
+
}
|
|
1637
|
+
const entitlements = await resolveEntitlements(paymentsDatabase(database(c)), subject, clock());
|
|
1638
|
+
return c.json({ entitlements: entitlements.map(entitlementView) } satisfies PaymentsEntitlementsResponse, 200);
|
|
1639
|
+
});
|
|
1640
|
+
|
|
1641
|
+
/**
|
|
1642
|
+
* AUTHED WRITE. Restore Purchases, client-driven because only the device can enumerate what its store
|
|
1643
|
+
* account owns.
|
|
1644
|
+
*
|
|
1645
|
+
* One bad receipt fails the whole request rather than being skipped. That is deliberate: a client submits
|
|
1646
|
+
* *its own* history, so a receipt belonging to another account in the batch is an attack and a partial
|
|
1647
|
+
* success would hide it. Every receipt that did project stays projected — the writer is idempotent, so the
|
|
1648
|
+
* client simply retries.
|
|
1649
|
+
*/
|
|
1650
|
+
app.post(`${base}/restore`, requireAuth(), zValidator("json", RestoreRequest, validationHook), async (c) => {
|
|
1651
|
+
const input = c.req.valid("json");
|
|
1652
|
+
// One resolution for the whole batch. A client submits *its own* history, so every receipt in it is
|
|
1653
|
+
// filed against one holder or the request is refused — asking the seam per receipt would let the answer
|
|
1654
|
+
// change mid-batch and split one store account across two.
|
|
1655
|
+
const subject = await requirePaymentsSubject(c, seam);
|
|
1656
|
+
const purchases: ReturnType<typeof purchaseView>[] = [];
|
|
1657
|
+
for (const receipt of input.receipts) purchases.push(purchaseView(await submit(c, subject, input.rail, receipt)));
|
|
1658
|
+
|
|
1659
|
+
const entitlements = await resolveEntitlements(paymentsDatabase(database(c)), subject, clock());
|
|
1660
|
+
await c.var.emit({
|
|
1661
|
+
action: PaymentsAuditActions.purchaseRestored,
|
|
1662
|
+
outcome: "success",
|
|
1663
|
+
actorType: "user",
|
|
1664
|
+
actorId: c.var.auth?.userId,
|
|
1665
|
+
sessionId: c.var.auth?.sessionId,
|
|
1666
|
+
metadata: {
|
|
1667
|
+
rail: input.rail,
|
|
1668
|
+
subjectType: subject.subjectType,
|
|
1669
|
+
subjectId: subject.subjectId,
|
|
1670
|
+
restored: purchases.length,
|
|
1671
|
+
},
|
|
1672
|
+
});
|
|
1673
|
+
return c.json(
|
|
1674
|
+
{ purchases, entitlements: entitlements.map(entitlementView) } satisfies PaymentsRestoreResponse,
|
|
1675
|
+
200,
|
|
1676
|
+
);
|
|
1677
|
+
});
|
|
1678
|
+
|
|
1679
|
+
/**
|
|
1680
|
+
* SIGNED WEBHOOK — Apple. App Store Server Notifications V2, signed as a JWS against Apple's pinned chain.
|
|
1681
|
+
*/
|
|
1682
|
+
app.post(
|
|
1683
|
+
`${base}/webhooks/apple`,
|
|
1684
|
+
requireSignedWebhook("apple", { config, now: clock, trust }),
|
|
1685
|
+
zValidator("json", AppleWebhookNotification, validationHook),
|
|
1686
|
+
webhookHandler("apple"),
|
|
1687
|
+
);
|
|
1688
|
+
|
|
1689
|
+
/**
|
|
1690
|
+
* SIGNED WEBHOOK — Google. A Pub/Sub push whose OIDC token the guard verified, audience and all.
|
|
1691
|
+
*
|
|
1692
|
+
* The handler is the same one Apple's route uses, and that is the point: by the time it runs, the rail has
|
|
1693
|
+
* already turned Play's pointer into a normalized event through the Play Developer API, so there is nothing
|
|
1694
|
+
* Google-shaped left to branch on.
|
|
1695
|
+
*/
|
|
1696
|
+
app.post(
|
|
1697
|
+
`${base}/webhooks/google`,
|
|
1698
|
+
requireSignedWebhook("google", { config, now: clock, trust }),
|
|
1699
|
+
zValidator("json", GoogleWebhookNotification, validationHook),
|
|
1700
|
+
webhookHandler("google"),
|
|
1701
|
+
);
|
|
1702
|
+
|
|
1703
|
+
/**
|
|
1704
|
+
* SIGNED WEBHOOK — Stripe. An HMAC in `Stripe-Signature`, over the exact received bytes and dated, which the
|
|
1705
|
+
* guard has checked inside the replay window before this validator ever parses the body.
|
|
1706
|
+
*
|
|
1707
|
+
* The same handler again. Stripe is the rail with no client-submission path of its own in the common case, so
|
|
1708
|
+
* this is where most Stripe purchases enter the system — and it is still nothing but a verified notification
|
|
1709
|
+
* by the time the handler runs.
|
|
1710
|
+
*/
|
|
1711
|
+
app.post(
|
|
1712
|
+
`${base}/webhooks/stripe`,
|
|
1713
|
+
requireSignedWebhook("stripe", { config, now: clock, trust }),
|
|
1714
|
+
zValidator("json", StripeWebhookNotification, validationHook),
|
|
1715
|
+
webhookHandler("stripe"),
|
|
1716
|
+
);
|
|
1717
|
+
|
|
1718
|
+
/**
|
|
1719
|
+
* SIGNED WEBHOOK — Lemon Squeezy. A bare HMAC in `X-Signature`, over the exact received bytes, which the
|
|
1720
|
+
* guard has checked before this validator ever parses the body.
|
|
1721
|
+
*
|
|
1722
|
+
* **No timestamp, so no freshness window**, and that is a true fact about the scheme rather than an omission
|
|
1723
|
+
* here — `rails/lemonSqueezy/signature.ts` says so in its type by taking no clock. Replay protection is
|
|
1724
|
+
* entirely the guard's `UNIQUE (rail, providerEventId)` insert, and the projection's monotonic rule behind
|
|
1725
|
+
* it.
|
|
1726
|
+
*
|
|
1727
|
+
* The same handler again. This is where every Lemon Squeezy purchase enters the system, because that rail
|
|
1728
|
+
* has no client-submission path at all: its order ids are sequential integers, so no submitted receipt
|
|
1729
|
+
* could be trusted.
|
|
1730
|
+
*/
|
|
1731
|
+
app.post(
|
|
1732
|
+
`${base}/webhooks/lemon-squeezy`,
|
|
1733
|
+
requireSignedWebhook("lemonSqueezy", { config, now: clock, trust }),
|
|
1734
|
+
zValidator("json", LemonSqueezyWebhookNotification, validationHook),
|
|
1735
|
+
webhookHandler("lemonSqueezy"),
|
|
1736
|
+
);
|
|
1737
|
+
|
|
1738
|
+
/**
|
|
1739
|
+
* SIGNED WEBHOOK — Paddle. `Paddle-Signature: ts=…;h1=…`, an HMAC-SHA256 over `${ts}:${exact received
|
|
1740
|
+
* bytes}`, which the guard has checked inside the freshness window before this validator ever parses the
|
|
1741
|
+
* body.
|
|
1742
|
+
*
|
|
1743
|
+
* **A different scheme from Stripe's, and a second verifier rather than a widened core primitive.** Core's
|
|
1744
|
+
* `signed-webhook` splits its header on `,` and joins its signed payload with `.`; Paddle uses `;` and
|
|
1745
|
+
* `:`. Neither is a parameter there, deliberately — `rails/paddle/signature.ts` says why at length, and
|
|
1746
|
+
* says it out loud because a verifier that "fits an existing shape" it does not fit is a verifier that
|
|
1747
|
+
* returns without comparing anything.
|
|
1748
|
+
*
|
|
1749
|
+
* The same handler again. Paddle purchases enter through here and through `/purchases`: unlike Lemon
|
|
1750
|
+
* Squeezy this rail has a submittable receipt, because a `txn_…` checked against a *proven* ownership
|
|
1751
|
+
* stamp is safe where an unguessable order id is not.
|
|
1752
|
+
*/
|
|
1753
|
+
app.post(
|
|
1754
|
+
`${base}/webhooks/paddle`,
|
|
1755
|
+
requireSignedWebhook("paddle", { config, now: clock, trust }),
|
|
1756
|
+
zValidator("json", PaddleWebhookNotification, validationHook),
|
|
1757
|
+
webhookHandler("paddle"),
|
|
1758
|
+
);
|
|
1759
|
+
|
|
1760
|
+
/**
|
|
1761
|
+
* AUTHED WRITE — Stripe only. Create a hosted Checkout Session and hand back where to send the browser.
|
|
1762
|
+
*
|
|
1763
|
+
* Everything that decides what is bought and where the buyer is returned to comes from config or from the
|
|
1764
|
+
* seam, never from the body: the **price** from the catalog entry the product id resolves to, the **return
|
|
1765
|
+
* URLs** from `config.stripe`, and the **purchaser** from the subject seam. A client that could name a price
|
|
1766
|
+
* could buy Pro for the price of a coin pack; one that could name a return URL could send a paying customer
|
|
1767
|
+
* to a page it controls; one that could name a purchaser could attach its purchase to another account.
|
|
1768
|
+
*
|
|
1769
|
+
* The subject is resolved first, ahead of the catalog lookup: a caller acting for no holder has nothing to
|
|
1770
|
+
* be charged as, and refusing before the 404 keeps this from answering what a project sells to somebody it
|
|
1771
|
+
* cannot bill.
|
|
1772
|
+
*/
|
|
1773
|
+
app.post(`${base}/checkout`, requireAuth(), zValidator("json", CheckoutRequest, validationHook), async (c) => {
|
|
1774
|
+
const input = c.req.valid("json");
|
|
1775
|
+
const subject = await requirePaymentsSubject(c, seam);
|
|
1776
|
+
const entry = product(config, input.productId);
|
|
1777
|
+
const rail = checkoutRailFor(entry, input.rail);
|
|
1778
|
+
const settings = returnUrls(rail);
|
|
1779
|
+
const sku = providerProductId(entry.product, rail);
|
|
1780
|
+
if (sku === undefined) {
|
|
1781
|
+
// In the catalog, but not sold here. A 404 on the product rather than on the rail: the rail is
|
|
1782
|
+
// available, this product simply is not one of the things it sells.
|
|
1783
|
+
throw new PaymentsProductNotFoundError({
|
|
1784
|
+
detail: `Product "${entry.id}" declares no ${rail} SKU, so it cannot be bought through hosted checkout.`,
|
|
1785
|
+
});
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
const provider = await checkoutRail(c, rail);
|
|
1789
|
+
const handoff = await provider.createCheckoutSession(
|
|
1790
|
+
{
|
|
1791
|
+
providerProductId: sku,
|
|
1792
|
+
subscription: entry.product.type === "subscription",
|
|
1793
|
+
// The pair, so the rail stamps `encodeSubjectReference(subject)` into the checkout it creates and
|
|
1794
|
+
// the webhook that follows already names a holder this server chose.
|
|
1795
|
+
subject,
|
|
1796
|
+
// Reuse the buyer's existing store customer, so one buyer keeps one account and their billing portal
|
|
1797
|
+
// shows every purchase rather than only the last one's.
|
|
1798
|
+
providerAccountId: await accountFor(c, rail, subject),
|
|
1799
|
+
successUrl: settings.successUrl,
|
|
1800
|
+
cancelUrl: settings.cancelUrl,
|
|
1801
|
+
// Passed to the store unchanged. Pithy never computes a discounted amount and never checks a code
|
|
1802
|
+
// against anything of its own — the provider is the authority on what is owed.
|
|
1803
|
+
discountCode: input.discountCode,
|
|
1804
|
+
},
|
|
1805
|
+
{ now: clock(), deployment: deploymentName(c) },
|
|
1806
|
+
);
|
|
1807
|
+
|
|
1808
|
+
await c.var.emit({
|
|
1809
|
+
action: PaymentsAuditActions.checkoutStarted,
|
|
1810
|
+
outcome: "success",
|
|
1811
|
+
actorType: "user",
|
|
1812
|
+
actorId: c.var.auth?.userId,
|
|
1813
|
+
sessionId: c.var.auth?.sessionId,
|
|
1814
|
+
resourceType: "product",
|
|
1815
|
+
resourceId: entry.id,
|
|
1816
|
+
metadata: {
|
|
1817
|
+
rail,
|
|
1818
|
+
subjectType: subject.subjectType,
|
|
1819
|
+
subjectId: subject.subjectId,
|
|
1820
|
+
productId: entry.id,
|
|
1821
|
+
subscription: entry.product.type === "subscription",
|
|
1822
|
+
// Whether one was used, never which. The trail is long-lived and a code is a commercial fact.
|
|
1823
|
+
discounted: input.discountCode !== undefined,
|
|
1824
|
+
},
|
|
1825
|
+
});
|
|
1826
|
+
// The handoff, verbatim. A `redirect` carries a URL; a `paddle` handoff carries the transaction the
|
|
1827
|
+
// browser opens in place, because that rail's overlay and inline modes never leave this page.
|
|
1828
|
+
return c.json(handoff satisfies PaymentsCheckoutHandoffResponse, 200);
|
|
1829
|
+
});
|
|
1830
|
+
|
|
1831
|
+
/**
|
|
1832
|
+
* AUTHED WRITE — Stripe only. Open the Billing Portal for the caller's own store account.
|
|
1833
|
+
*
|
|
1834
|
+
* No body at all, and that is the request contract: there is exactly one customer this caller may manage,
|
|
1835
|
+
* and it comes from the provider-account map. A `customer` field here would be the whole vulnerability —
|
|
1836
|
+
* any signed-in caller could open a session against somebody else's billing history and cancel it.
|
|
1837
|
+
*/
|
|
1838
|
+
app.post(`${base}/portal`, requireAuth(), async (c) => {
|
|
1839
|
+
const subject = await requirePaymentsSubject(c, seam);
|
|
1840
|
+
|
|
1841
|
+
// Which rail this caller actually bought on, found by asking the account map rather than by taking it
|
|
1842
|
+
// from the request. Still no body: the caller names neither the customer nor the rail, so there is
|
|
1843
|
+
// nothing here anyone could point at somebody else's billing.
|
|
1844
|
+
const enabled = PAYMENTS_HOSTED_RAILS.filter((rail) => railEnabled(config, rail));
|
|
1845
|
+
// No hosted rail at all is a fact about the deployment, not about this caller — a mobile-only project
|
|
1846
|
+
// has no billing portal to open. Same refusal `/checkout` gives, rather than a 404 that reads as "you
|
|
1847
|
+
// have no account" to somebody who could never have had one.
|
|
1848
|
+
if (enabled.length === 0) {
|
|
1849
|
+
throw new PaymentsRailNotConfiguredError({
|
|
1850
|
+
detail:
|
|
1851
|
+
"No hosted-checkout rail is on in this project's config, so there is no billing portal to open. Enable `rails.stripe`, `rails.lemonSqueezy`, or `rails.paddle`.",
|
|
1852
|
+
});
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
let found: { rail: PaymentsRail; providerAccountId: string } | undefined;
|
|
1856
|
+
for (const rail of enabled) {
|
|
1857
|
+
const providerAccountId = await accountFor(c, rail, subject);
|
|
1858
|
+
if (providerAccountId !== null) {
|
|
1859
|
+
found = { rail, providerAccountId };
|
|
1860
|
+
break;
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
if (found === undefined) {
|
|
1865
|
+
// Nothing has ever been bought through a hosted rail by this caller, so there is no billing to
|
|
1866
|
+
// manage. Not a payments-domain refusal: the rail is configured and working, the resource simply
|
|
1867
|
+
// does not exist.
|
|
1868
|
+
throw new NotFoundError({
|
|
1869
|
+
message: "No billing account yet.",
|
|
1870
|
+
action: "Buy a subscription first, then manage it here.",
|
|
1871
|
+
detail: `No hosted-rail provider account is linked to ${encodeSubjectReference(subject)}.`,
|
|
1872
|
+
});
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
const provider = await checkoutRail(c, found.rail);
|
|
1876
|
+
const session = await provider.createPortalSession(
|
|
1877
|
+
{
|
|
1878
|
+
providerAccountId: found.providerAccountId,
|
|
1879
|
+
// Undefined for a rail whose portal takes no return parameter — Lemon Squeezy's is a signed,
|
|
1880
|
+
// expiring link with nowhere to go back to, and Paddle's takes none at all. The contract admits
|
|
1881
|
+
// that rather than have the rail silently drop a URL an adopter configured.
|
|
1882
|
+
returnUrl: found.rail === "stripe" ? stripeSettings().portalReturnUrl : undefined,
|
|
1883
|
+
// **From the caller's own rows, never from a body.** A store that mints per-subscription deep
|
|
1884
|
+
// links mints authenticated ones, so naming a subscription is naming somebody's cancel button.
|
|
1885
|
+
// There is still no request field: the route reads what this caller owns.
|
|
1886
|
+
subscriptionIds: await ownSubscriptionIds(c, found.rail, subject),
|
|
1887
|
+
},
|
|
1888
|
+
{ now: clock(), deployment: deploymentName(c) },
|
|
1889
|
+
);
|
|
1890
|
+
|
|
1891
|
+
await c.var.emit({
|
|
1892
|
+
action: PaymentsAuditActions.portalOpened,
|
|
1893
|
+
outcome: "success",
|
|
1894
|
+
actorType: "user",
|
|
1895
|
+
actorId: c.var.auth?.userId,
|
|
1896
|
+
sessionId: c.var.auth?.sessionId,
|
|
1897
|
+
resourceType: "provider_account",
|
|
1898
|
+
resourceId: found.providerAccountId,
|
|
1899
|
+
metadata: { rail: found.rail, subjectType: subject.subjectType, subjectId: subject.subjectId },
|
|
1900
|
+
});
|
|
1901
|
+
return c.json(
|
|
1902
|
+
{
|
|
1903
|
+
url: session.url,
|
|
1904
|
+
...(session.subscriptions === undefined ? {} : { subscriptions: [...session.subscriptions] }),
|
|
1905
|
+
} satisfies PaymentsPortalHandoffResponse,
|
|
1906
|
+
200,
|
|
1907
|
+
);
|
|
1908
|
+
});
|
|
1909
|
+
|
|
1910
|
+
/**
|
|
1911
|
+
* CONTROL PLANE. Grant an entitlement by hand — a comped account, or the repair of a purchase that
|
|
1912
|
+
* verified and never projected.
|
|
1913
|
+
*
|
|
1914
|
+
* Support needs this on day one, and it is the more dangerous of the two: a caller that could reach it
|
|
1915
|
+
* could give itself anything the product sells. So it is a control-plane route and nothing else.
|
|
1916
|
+
* `requireControlPlane(PAYMENTS_ENTITLEMENT_GRANT_SCOPE)` establishes authenticity — signature, registered
|
|
1917
|
+
* key, connection, environment, token lifetime, body digest, single use — before anything below runs, and
|
|
1918
|
+
* denies unless the adopter granted *that* operation. Holding the revoke scope confers nothing here: the
|
|
1919
|
+
* seam matches scopes exactly, with no prefix rule. The validator sits behind the gate, so a well-formed
|
|
1920
|
+
* body is never confirmed to a caller who may not send one.
|
|
1921
|
+
*
|
|
1922
|
+
* The write is a repair of the read model, not a purchase: null provenance, no purchase row, and the
|
|
1923
|
+
* projection stays authoritative for any key the catalog sells. See `entitlement/manual.ts`.
|
|
1924
|
+
*/
|
|
1925
|
+
/**
|
|
1926
|
+
* AUTHED READ — what the caller's own subscription pays, and when that changes.
|
|
1927
|
+
*
|
|
1928
|
+
* The half that stops a bill changing unannounced. A discount that lapses with nothing having said so
|
|
1929
|
+
* is, from a customer's seat, indistinguishable from a billing error — so a screen offering a
|
|
1930
|
+
* twelve-month rate has to be able to say when the twelve months end.
|
|
1931
|
+
*
|
|
1932
|
+
* The caller's own holder, always: the subscription is found from rows keyed on the subject the seam
|
|
1933
|
+
* resolved, and there is no request field naming one. Answers `null` when that holder has no subscription
|
|
1934
|
+
* a rail can price, which is a fact rather than a failure.
|
|
1935
|
+
*
|
|
1936
|
+
* **`quotedFrom` rides here because a browser cannot price a customer it cannot name.** Paddle quotes
|
|
1937
|
+
* in the browser — `PricePreview` runs from the visitor's own page — and with no customer id it
|
|
1938
|
+
* quotes from the visitor's IP, which is where a browser connected from and not where a card is
|
|
1939
|
+
* registered. The charge settles on the billing address, so `POST /payments/checkout` hands the rail
|
|
1940
|
+
* the customer id from the provider-account map; this publishes **the same read of the same row** so
|
|
1941
|
+
* the quote and the charge cannot resolve location differently. A caller with no store customer yet
|
|
1942
|
+
* gets null, quotes from their IP, and is told the figure is an estimate.
|
|
1943
|
+
*
|
|
1944
|
+
* Beside `pricing` rather than inside it, because the two are independent: somebody who has never
|
|
1945
|
+
* bought anything has no pricing and may still have no customer, and somebody mid-subscription has
|
|
1946
|
+
* both. Nesting one in the other would make the common case unreachable.
|
|
1947
|
+
*/
|
|
1948
|
+
app.get(`${base}/pricing`, requireAuth(), async (c) => {
|
|
1949
|
+
const subject = await resolvePaymentsSubject(c, seam);
|
|
1950
|
+
// A read, so nobody resolved is the empty answer rather than a refusal — the same direction
|
|
1951
|
+
// `GET {base}/entitlements` takes, and for the same reason: a screen with no billing account selected
|
|
1952
|
+
// is quoting from nothing, which is a fact rather than a failure.
|
|
1953
|
+
if (subject === undefined)
|
|
1954
|
+
return c.json({ pricing: null, quotedFrom: null } satisfies PaymentsPricingEnvelope, 200);
|
|
1955
|
+
const db = paymentsDatabase(database(c));
|
|
1956
|
+
|
|
1957
|
+
// The one read, shared with `/checkout`. `accountFor` is what that route calls to decide who is
|
|
1958
|
+
// charged, so a screen quoting from this answer is quoting from the row that will be billed.
|
|
1959
|
+
const paddleCustomer = await accountFor(c, "paddle", subject);
|
|
1960
|
+
const quotedFrom: PaymentsQuotedFrom | null =
|
|
1961
|
+
paddleCustomer === null ? null : { rail: "paddle", providerAccountId: paddleCustomer };
|
|
1962
|
+
/** The envelope, so every exit below carries both facts rather than three of them carrying one. */
|
|
1963
|
+
const answer = (pricing: PaymentsPricingResponse | null) =>
|
|
1964
|
+
c.json({ pricing, quotedFrom } satisfies PaymentsPricingEnvelope, 200);
|
|
1965
|
+
|
|
1966
|
+
// The newest subscription row this caller holds on a rail that can price one. `role` matters: a money
|
|
1967
|
+
// row records a closed period and has no "next".
|
|
1968
|
+
const rows = await db
|
|
1969
|
+
.selectFrom(PAYMENTS_PURCHASES_TABLE)
|
|
1970
|
+
.selectAll()
|
|
1971
|
+
.where("subjectType", "=", subject.subjectType)
|
|
1972
|
+
.where("subjectId", "=", subject.subjectId)
|
|
1973
|
+
.where("type", "=", "subscription")
|
|
1974
|
+
.orderBy("providerEventAt", "desc")
|
|
1975
|
+
.execute();
|
|
1976
|
+
// The row that carries the subscription's *standing*, preferred over any receipt.
|
|
1977
|
+
//
|
|
1978
|
+
// On Lemon Squeezy a subscriber's newest rows are `charge` receipts — one per invoice — and a receipt
|
|
1979
|
+
// names a closed period no rail can price. Taking the newest of any role therefore returned null for
|
|
1980
|
+
// every LS subscriber while looking like it had worked. Every other rail writes `charge` for
|
|
1981
|
+
// everything and has no `state` row, so the fallback is what serves them.
|
|
1982
|
+
const row = rows.find((candidate) => candidate.role === "state") ?? rows[0];
|
|
1983
|
+
if (row === undefined) return answer(null);
|
|
1984
|
+
|
|
1985
|
+
const purchase = PaymentsPurchase.parse(row);
|
|
1986
|
+
const provider = resolveRailProvider(purchase.rail, config, await credentials(c), trust);
|
|
1987
|
+
if (!isPricingRail(provider)) return answer(null);
|
|
1988
|
+
|
|
1989
|
+
const pricing = await provider.readPricing(purchase, { now: clock(), deployment: deploymentName(c) });
|
|
1990
|
+
if (pricing === undefined) return answer(null);
|
|
1991
|
+
|
|
1992
|
+
return answer({
|
|
1993
|
+
currency: pricing.currency,
|
|
1994
|
+
currentAmountMinor: pricing.currentAmountMinor,
|
|
1995
|
+
listAmountMinor: pricing.listAmountMinor,
|
|
1996
|
+
discountCode: pricing.discountCode,
|
|
1997
|
+
discountEndsAt: pricing.discountEndsAt === null ? null : pricing.discountEndsAt.toISOString(),
|
|
1998
|
+
});
|
|
1999
|
+
});
|
|
2000
|
+
|
|
2001
|
+
/**
|
|
2002
|
+
* AUTHED READ — where the caller's own subscription stands, read live from the store.
|
|
2003
|
+
*
|
|
2004
|
+
* **This route ships before the four verbs beside it, and that ordering is the lesson of #247**, which
|
|
2005
|
+
* the module doc above records verbatim: payments shipped `entitlements/grant` and
|
|
2006
|
+
* `entitlements/revoke` with no read beside them, and a dashboard's panes computed *absent* against a
|
|
2007
|
+
* live manifest and dropped out of the rail entirely. A capability that can cancel a subscription and
|
|
2008
|
+
* cannot report the cancellation ships the half that creates the support ticket.
|
|
2009
|
+
*
|
|
2010
|
+
* **Live, never from the projected row, because the one fact this exists to report is the one a row
|
|
2011
|
+
* does not carry.** With a cancellation scheduled Paddle answers `status: "active"`, `canceled_at:
|
|
2012
|
+
* null` and `next_billed_at: null` (recorded 2026-08-28, #465): two of the three say the subscription
|
|
2013
|
+
* is fine and the third says nothing at all. The end date lives only on `scheduled_change.effective_at`,
|
|
2014
|
+
* and the webhook that would have announced it can be dropped.
|
|
2015
|
+
*
|
|
2016
|
+
* **`null` is a real answer and a common one** — somebody who has never bought anything, and somebody
|
|
2017
|
+
* whose store has nothing to say about the row. Not a 404: that would make this an existence oracle and
|
|
2018
|
+
* would read, to a screen, exactly like a Worker that could not be reached.
|
|
2019
|
+
*
|
|
2020
|
+
* The subject comes from the seam and the subscription from that subject's own rows, so there is no
|
|
2021
|
+
* shape of this request that reads somebody else's. Nobody resolved is the empty answer rather than a
|
|
2022
|
+
* refusal, which is the read half of the seam's rule and what `GET {base}/entitlements` already does.
|
|
2023
|
+
*/
|
|
2024
|
+
app.get(`${base}/subscription`, requireAuth(), async (c) => {
|
|
2025
|
+
const subject = await resolvePaymentsSubject(c, seam);
|
|
2026
|
+
if (subject === undefined) return c.json({ subscription: null } satisfies PaymentsSubscriptionResponse, 200);
|
|
2027
|
+
|
|
2028
|
+
const target = await changeableSubscription(database(c), subject, clock());
|
|
2029
|
+
// Two subscriptions have no encoding in an envelope that holds one, and picking is the defect the
|
|
2030
|
+
// whole resolver exists to prevent. The refusal is the same one every verb gives.
|
|
2031
|
+
if (target.found === "many") throw tooManySubscriptions(target.purchases);
|
|
2032
|
+
if (target.found === "none") return c.json({ subscription: null } satisfies PaymentsSubscriptionResponse, 200);
|
|
2033
|
+
|
|
2034
|
+
const provider = await subscriptionRail(c, target.purchase.rail);
|
|
2035
|
+
const standing = await provider.readStanding(target.purchase, { now: clock(), deployment: deploymentName(c) });
|
|
2036
|
+
// A rail that cannot address this purchase, or a store that no longer knows it. A fact about the
|
|
2037
|
+
// purchase rather than a failure — a store that could not be *reached* raises
|
|
2038
|
+
// `payments/provider_unavailable` instead, so a screen distinguishes the two.
|
|
2039
|
+
if (standing === undefined) return c.json({ subscription: null } satisfies PaymentsSubscriptionResponse, 200);
|
|
2040
|
+
|
|
2041
|
+
return c.json(
|
|
2042
|
+
{
|
|
2043
|
+
subscription: subscriptionView(target.purchase.productId, standing),
|
|
2044
|
+
} satisfies PaymentsSubscriptionResponse,
|
|
2045
|
+
200,
|
|
2046
|
+
);
|
|
2047
|
+
});
|
|
2048
|
+
|
|
2049
|
+
/**
|
|
2050
|
+
* AUTHED READ — what moving to one catalog product would cost, as the store previews it.
|
|
2051
|
+
*
|
|
2052
|
+
* **A read that discloses a price, and a `POST` only because it carries a body.** It commits nothing,
|
|
2053
|
+
* stores nothing and writes no audit row: a quote goes stale the moment the billing period moves, and a
|
|
2054
|
+
* persisted one is a price nobody is bound by.
|
|
2055
|
+
*
|
|
2056
|
+
* **The no-op rule does not apply to a preview.** Asking what the plan already held would cost is a
|
|
2057
|
+
* question with an honest answer, and a second preview is free. Borrowing the writes' short-circuit
|
|
2058
|
+
* would mean inventing a recurring figure to fill the quote with, which is the one thing this package
|
|
2059
|
+
* will not do.
|
|
2060
|
+
*
|
|
2061
|
+
* The subscription comes from the caller's own rows and the price from the catalog, exactly as on
|
|
2062
|
+
* `change` — the two routes share {@link SubscriptionPreviewRequest}, which *is*
|
|
2063
|
+
* {@link SubscriptionChangeRequest}, so a preview cannot quote a figure the commit then refuses.
|
|
2064
|
+
*
|
|
2065
|
+
* `requirePaymentsSubject`, not `resolvePaymentsSubject`, even though this is a read. The seam's rule
|
|
2066
|
+
* gives a read two honest answers — empty or denied — and a quote has no empty form: a figure is what
|
|
2067
|
+
* this route exists to produce. With no holder selected there is no subscription to quote, and
|
|
2068
|
+
* `payments/subject_unresolved` says exactly that, where a 404 would claim the caller has no
|
|
2069
|
+
* subscription anywhere.
|
|
2070
|
+
*/
|
|
2071
|
+
app.post(
|
|
2072
|
+
`${base}/subscription/preview`,
|
|
2073
|
+
requireAuth(),
|
|
2074
|
+
zValidator("json", SubscriptionPreviewRequest, validationHook),
|
|
2075
|
+
async (c) => {
|
|
2076
|
+
const input = c.req.valid("json");
|
|
2077
|
+
const subject = await requirePaymentsSubject(c, seam);
|
|
2078
|
+
const purchase = requireOneSubscription(await changeableSubscription(database(c), subject, clock()));
|
|
2079
|
+
const entry = product(config, input.productId);
|
|
2080
|
+
const sku = subscriptionPrice(entry, purchase.rail);
|
|
2081
|
+
|
|
2082
|
+
const provider = await subscriptionRail(c, purchase.rail);
|
|
2083
|
+
const quote = await provider.previewChange(
|
|
2084
|
+
// The row, never an id off it: what identifies a subscription at its store differs per rail and
|
|
2085
|
+
// some of it survives only in the payload. See `SubscriptionChangeInput.purchase`.
|
|
2086
|
+
{ purchase, providerProductId: sku },
|
|
2087
|
+
// The only rail call carrying a locale, because it is the only one whose answer carries money a
|
|
2088
|
+
// person reads. See `RailRequestContext.locale`.
|
|
2089
|
+
{ now: clock(), deployment: deploymentName(c), locale: readerLocale(c) },
|
|
2090
|
+
);
|
|
2091
|
+
return c.json({ quote: quoteView(quote) } satisfies PaymentsSubscriptionQuoteResponse, 200);
|
|
2092
|
+
},
|
|
2093
|
+
);
|
|
2094
|
+
|
|
2095
|
+
/**
|
|
2096
|
+
* AUTHED WRITE — move the caller's own subscription onto one catalog product.
|
|
2097
|
+
*
|
|
2098
|
+
* **Nothing this route needs comes from the request but the product.** The subscription is resolved
|
|
2099
|
+
* from the caller's own purchase rows ({@link changeableSubscription}), the rail from that row, and the
|
|
2100
|
+
* store's price from the catalog. A body-named subscription moves somebody else's; a body-named price
|
|
2101
|
+
* moves a customer onto a plan this project does not sell; a body-named rail is the wrong store asked
|
|
2102
|
+
* about a subscription that does not live there. None of the three is refused by a check — each is
|
|
2103
|
+
* unreachable because there is nowhere to write it.
|
|
2104
|
+
*
|
|
2105
|
+
* **No proration mode and no `on_payment_failure`, for the same reason.** The rail picks the mode from
|
|
2106
|
+
* the *direction* of the change — up charges now, down defers the credit to the next invoice — and
|
|
2107
|
+
* always prevents a change that cannot be paid for. A mode a client could set is a mode a client would
|
|
2108
|
+
* eventually set to Paddle's `do_not_bill`, which is a free upgrade.
|
|
2109
|
+
*
|
|
2110
|
+
* **This route writes no purchase row.** The webhook owns that row; a route that also wrote it would be
|
|
2111
|
+
* a second producer of one row, and the two disagree the first time a webhook is late. So the response
|
|
2112
|
+
* is the store's own answer to where the subscription now stands, and the plan named on it is the one
|
|
2113
|
+
* the store just confirmed rather than the one the stale row still carries.
|
|
2114
|
+
*
|
|
2115
|
+
* **The no-op is a success and leaves no trail.** A change to the plan already held returns the current
|
|
2116
|
+
* standing without calling the provider — the rail's rule, and the route must not turn it into a 409.
|
|
2117
|
+
* The audit row is withheld with it: a retried write must not become a second proration, and it must
|
|
2118
|
+
* not become a second row asserting a change that did not happen either. The route decides that from
|
|
2119
|
+
* its own row rather than from the rail, which reports no such flag, and the residual gap is stated at
|
|
2120
|
+
* {@link changedPlan}.
|
|
2121
|
+
*/
|
|
2122
|
+
app.post(
|
|
2123
|
+
`${base}/subscription/change`,
|
|
2124
|
+
requireAuth(),
|
|
2125
|
+
zValidator("json", SubscriptionChangeRequest, validationHook),
|
|
2126
|
+
async (c) => {
|
|
2127
|
+
const input = c.req.valid("json");
|
|
2128
|
+
const subject = await requirePaymentsSubject(c, seam);
|
|
2129
|
+
const purchase = requireOneSubscription(await changeableSubscription(database(c), subject, clock()));
|
|
2130
|
+
const entry = product(config, input.productId);
|
|
2131
|
+
const sku = subscriptionPrice(entry, purchase.rail);
|
|
2132
|
+
// Decided *before* the call, from the row as it stands. See {@link changedPlan}.
|
|
2133
|
+
const changed = changedPlan(purchase, entry.id);
|
|
2134
|
+
|
|
2135
|
+
const provider = await subscriptionRail(c, purchase.rail);
|
|
2136
|
+
const standing = await provider.changePlan(
|
|
2137
|
+
{ purchase, providerProductId: sku },
|
|
2138
|
+
{ now: clock(), deployment: deploymentName(c) },
|
|
2139
|
+
);
|
|
2140
|
+
|
|
2141
|
+
if (changed) {
|
|
2142
|
+
await c.var.emit({
|
|
2143
|
+
action: PaymentsAuditActions.subscriptionPlanChanged,
|
|
2144
|
+
outcome: "success",
|
|
2145
|
+
// The person who asked, always — the holder they act for rides in the metadata. Under
|
|
2146
|
+
// organization billing the two differ, and collapsing them answers "who did this" with a
|
|
2147
|
+
// company rather than with somebody who can be asked about it.
|
|
2148
|
+
actorType: "user",
|
|
2149
|
+
actorId: c.var.auth?.userId,
|
|
2150
|
+
sessionId: c.var.auth?.sessionId,
|
|
2151
|
+
resourceType: "purchase",
|
|
2152
|
+
resourceId: purchase.id,
|
|
2153
|
+
// Both plans, in both vocabularies, and no money. A quote is not what was charged, and copying
|
|
2154
|
+
// an amount here would make a second, weaker ledger beside the purchases table.
|
|
2155
|
+
metadata: {
|
|
2156
|
+
rail: purchase.rail,
|
|
2157
|
+
subjectType: subject.subjectType,
|
|
2158
|
+
subjectId: subject.subjectId,
|
|
2159
|
+
productId: entry.id,
|
|
2160
|
+
fromProductId: purchase.productId,
|
|
2161
|
+
providerProductId: sku,
|
|
2162
|
+
fromProviderProductId: purchase.providerProductId,
|
|
2163
|
+
},
|
|
2164
|
+
});
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2167
|
+
return c.json(
|
|
2168
|
+
// `entry.id`, not the row's product: the store has applied the move and the row has not caught up.
|
|
2169
|
+
{ subscription: subscriptionView(entry.id, standing) } satisfies PaymentsSubscriptionStandingResponse,
|
|
2170
|
+
200,
|
|
2171
|
+
);
|
|
2172
|
+
},
|
|
2173
|
+
);
|
|
2174
|
+
|
|
2175
|
+
/**
|
|
2176
|
+
* AUTHED WRITE — stop the caller's own subscription renewing.
|
|
2177
|
+
*
|
|
2178
|
+
* **`at_period_end` is the settled policy and the timing is still stated, never defaulted.** The two
|
|
2179
|
+
* timings are different things to buy — keep the period already paid for, or lose it today — and a body
|
|
2180
|
+
* that omitted the field would be choosing one of them by silence. `now` exists because support
|
|
2181
|
+
* occasionally has to end a subscription today, and because a policy with no legitimate exit gets
|
|
2182
|
+
* departed from by a direct provider call nothing audits.
|
|
2183
|
+
*
|
|
2184
|
+
* The subscription is the caller's own, resolved from their rows; nothing in the body names one. The
|
|
2185
|
+
* customer's word is what crosses, and the rail translates it — Paddle's `next_billing_period` does not
|
|
2186
|
+
* parse here, so a client that stopped translating fails loudly rather than sending a string Paddle
|
|
2187
|
+
* happens to accept.
|
|
2188
|
+
*
|
|
2189
|
+
* **The no-op is a success and is not audited.** A cancellation already scheduled for the timing asked
|
|
2190
|
+
* for is the state the caller wanted, so there is nothing to refuse; and a retried cancel must not
|
|
2191
|
+
* leave a trail claiming two of them. The standing is read before the write and compared afterwards —
|
|
2192
|
+
* see {@link standingMoved} for why that is the honest test and not a second copy of the rail's rule.
|
|
2193
|
+
*
|
|
2194
|
+
* Writes no purchase row, as every route here does not: the webhook owns it.
|
|
2195
|
+
*/
|
|
2196
|
+
app.post(
|
|
2197
|
+
`${base}/subscription/cancel`,
|
|
2198
|
+
requireAuth(),
|
|
2199
|
+
zValidator("json", SubscriptionCancelRequest, validationHook),
|
|
2200
|
+
async (c) => {
|
|
2201
|
+
const input = c.req.valid("json");
|
|
2202
|
+
const subject = await requirePaymentsSubject(c, seam);
|
|
2203
|
+
const purchase = requireOneSubscription(await changeableSubscription(database(c), subject, clock()));
|
|
2204
|
+
|
|
2205
|
+
const provider = await subscriptionRail(c, purchase.rail);
|
|
2206
|
+
const context = { now: clock(), deployment: deploymentName(c) };
|
|
2207
|
+
// Read before written, because a scheduled cancellation is the one fact the purchase row cannot
|
|
2208
|
+
// carry and therefore the one thing that decides whether this request changed anything.
|
|
2209
|
+
const before = await provider.readStanding(purchase, context);
|
|
2210
|
+
const standing = await provider.cancelSubscription({ purchase, timing: input.timing }, context);
|
|
2211
|
+
|
|
2212
|
+
if (standingMoved(before, standing)) {
|
|
2213
|
+
await c.var.emit({
|
|
2214
|
+
action: PaymentsAuditActions.subscriptionCanceled,
|
|
2215
|
+
outcome: "success",
|
|
2216
|
+
actorType: "user",
|
|
2217
|
+
actorId: c.var.auth?.userId,
|
|
2218
|
+
sessionId: c.var.auth?.sessionId,
|
|
2219
|
+
resourceType: "purchase",
|
|
2220
|
+
resourceId: purchase.id,
|
|
2221
|
+
// The timing asked for, and the day it lands. That day is the answer to "when does this person
|
|
2222
|
+
// lose access", and on a scheduled cancellation it exists nowhere else — the status still says
|
|
2223
|
+
// active and the next billing date has gone blank.
|
|
2224
|
+
metadata: {
|
|
2225
|
+
rail: purchase.rail,
|
|
2226
|
+
subjectType: subject.subjectType,
|
|
2227
|
+
subjectId: subject.subjectId,
|
|
2228
|
+
productId: purchase.productId,
|
|
2229
|
+
timing: input.timing,
|
|
2230
|
+
effectiveAt: standing.scheduledChange?.effectiveAt.toISOString() ?? null,
|
|
2231
|
+
},
|
|
2232
|
+
});
|
|
2233
|
+
}
|
|
2234
|
+
|
|
2235
|
+
return c.json(
|
|
2236
|
+
{
|
|
2237
|
+
subscription: subscriptionView(purchase.productId, standing),
|
|
2238
|
+
} satisfies PaymentsSubscriptionStandingResponse,
|
|
2239
|
+
200,
|
|
2240
|
+
);
|
|
2241
|
+
},
|
|
2242
|
+
);
|
|
2243
|
+
|
|
2244
|
+
/**
|
|
2245
|
+
* AUTHED WRITE — withdraw a scheduled cancellation, so the caller's subscription renews after all.
|
|
2246
|
+
*
|
|
2247
|
+
* **No body at all, and no validator, exactly as `POST {base}/portal` has none.** Which subscription is
|
|
2248
|
+
* the server's answer, and there is nothing to say about it but *do not*. A `z.object({})` would read as
|
|
2249
|
+
* a refusal of everything and be neither — Zod strips rather than refuses — while making a POST with no
|
|
2250
|
+
* body a 400 on a route that wants none.
|
|
2251
|
+
*
|
|
2252
|
+
* **It withdraws a cancellation and only a cancellation, and that check is the rail's.** Paddle offers
|
|
2253
|
+
* no narrower verb: the update clears `scheduled_change` wholesale, and that field also holds a
|
|
2254
|
+
* scheduled pause and a scheduled resume — so a rail that simply sent the clear would restart billing
|
|
2255
|
+
* on a paused account, on a request that said nothing about pausing. The rail re-reads and refuses
|
|
2256
|
+
* anything but a pending `cancel`. **The check cannot move here:** this route holds a projected row, and
|
|
2257
|
+
* the pending action lives only at the store.
|
|
2258
|
+
*
|
|
2259
|
+
* **Its own route and its own audit action, not an outcome on the cancellation.** The two are separate
|
|
2260
|
+
* acts by possibly separate actors, and the pair is what a dispute is reconstructed from. Folded
|
|
2261
|
+
* together, the trail asserts a cancellation and holds nothing saying it was taken back.
|
|
2262
|
+
*
|
|
2263
|
+
* A subscription with nothing scheduled is the no-op rather than a refusal — it already renews, which
|
|
2264
|
+
* is what the caller asked for — and it writes no audit row, for {@link standingMoved}'s reason.
|
|
2265
|
+
*/
|
|
2266
|
+
app.post(`${base}/subscription/keep`, requireAuth(), async (c) => {
|
|
2267
|
+
const subject = await requirePaymentsSubject(c, seam);
|
|
2268
|
+
const purchase = requireOneSubscription(await changeableSubscription(database(c), subject, clock()));
|
|
2269
|
+
|
|
2270
|
+
const provider = await subscriptionRail(c, purchase.rail);
|
|
2271
|
+
const context = { now: clock(), deployment: deploymentName(c) };
|
|
2272
|
+
const before = await provider.readStanding(purchase, context);
|
|
2273
|
+
const standing = await provider.keepSubscription(purchase, context);
|
|
2274
|
+
|
|
2275
|
+
if (standingMoved(before, standing)) {
|
|
2276
|
+
await c.var.emit({
|
|
2277
|
+
action: PaymentsAuditActions.subscriptionCancelWithdrawn,
|
|
2278
|
+
outcome: "success",
|
|
2279
|
+
actorType: "user",
|
|
2280
|
+
actorId: c.var.auth?.userId,
|
|
2281
|
+
sessionId: c.var.auth?.sessionId,
|
|
2282
|
+
resourceType: "purchase",
|
|
2283
|
+
resourceId: purchase.id,
|
|
2284
|
+
metadata: {
|
|
2285
|
+
rail: purchase.rail,
|
|
2286
|
+
subjectType: subject.subjectType,
|
|
2287
|
+
subjectId: subject.subjectId,
|
|
2288
|
+
productId: purchase.productId,
|
|
2289
|
+
},
|
|
2290
|
+
});
|
|
2291
|
+
}
|
|
2292
|
+
|
|
2293
|
+
return c.json(
|
|
2294
|
+
{ subscription: subscriptionView(purchase.productId, standing) } satisfies PaymentsSubscriptionStandingResponse,
|
|
2295
|
+
200,
|
|
2296
|
+
);
|
|
2297
|
+
});
|
|
2298
|
+
|
|
2299
|
+
/**
|
|
2300
|
+
* AUTHED WRITE — ask the store to give this subscription's payments back.
|
|
2301
|
+
*
|
|
2302
|
+
* **A request, and the response never says otherwise.** Paddle holds most live refunds at
|
|
2303
|
+
* `pending_approval` until a person there reviews them, so nothing here revokes an entitlement, writes a
|
|
2304
|
+
* purchase row, or touches a projection. The approval, when it comes, arrives as a webhook, and
|
|
2305
|
+
* `rails/paddle/adjustments.ts` and the projection writer are the only things that act on it. Revoking
|
|
2306
|
+
* on the *request* would take access from a customer whose refund the store then rejects, leaving them
|
|
2307
|
+
* with neither the money nor the product.
|
|
2308
|
+
*
|
|
2309
|
+
* **No body, exactly as `keep` has none.** Which subscription is the server's answer, which payments is
|
|
2310
|
+
* the server's answer, and how much is not a question anyone may ask: every refund raised here is for a
|
|
2311
|
+
* transaction's whole total. A body naming a transaction refunds a stranger's money; a body naming an
|
|
2312
|
+
* amount is a self-service withdrawal; a body naming a reason writes free text into the adopter's own
|
|
2313
|
+
* back office. None of the three is refused by a check — each is unreachable because there is nowhere to
|
|
2314
|
+
* write it.
|
|
2315
|
+
*
|
|
2316
|
+
* **It refunds every payment on the subscription, and applies no window.** How many days a customer has
|
|
2317
|
+
* to ask is the *adopter's* commercial policy, and a kit that hard-coded fourteen would be wrong for the
|
|
2318
|
+
* second adopter. The kit makes the refund possible; the adopter's screen decides which button exists.
|
|
2319
|
+
* See {@link refundablePayments}.
|
|
2320
|
+
*
|
|
2321
|
+
* **A partial cannot be silent.** The rail refuses the whole request before it writes anything and
|
|
2322
|
+
* reports every outcome once it has — {@link RefundRail} holds the argument — and the response carries
|
|
2323
|
+
* one entry per payment, so a caller counting entries and a caller counting their own payments get the
|
|
2324
|
+
* same number.
|
|
2325
|
+
*
|
|
2326
|
+
* **The audit row is the only record that a refund was asked for**, until and unless the store approves
|
|
2327
|
+
* one. It is withheld when nothing was raised, which is the retry: a request repeated against refunds
|
|
2328
|
+
* already standing is the state the caller wanted, and a trail claiming two of them is worse than one
|
|
2329
|
+
* claiming none.
|
|
2330
|
+
*/
|
|
2331
|
+
app.post(`${base}/subscription/refund`, requireAuth(), async (c) => {
|
|
2332
|
+
const subject = await requirePaymentsSubject(c, seam);
|
|
2333
|
+
const purchase = requireOneSubscription(await changeableSubscription(database(c), subject, clock()));
|
|
2334
|
+
// The rail is narrowed **before** the payments are resolved, and the order is the answer a caller
|
|
2335
|
+
// gets. "This store does not refund from the server" is true of every payment on the subscription and
|
|
2336
|
+
// is what an Apple or Google subscriber needs to hear; "there is no payment to refund" would be the
|
|
2337
|
+
// same 409 they would get if they had never paid, and it would send them looking for the wrong thing.
|
|
2338
|
+
const provider = await refundRail(c, purchase.rail);
|
|
2339
|
+
const payments = await refundablePayments(database(c), subject, purchase);
|
|
2340
|
+
if (payments.length === 0) {
|
|
2341
|
+
// A 409 rather than an empty report, and rather than a 404. The subscription is there and the
|
|
2342
|
+
// request is well-formed; what is absent is a payment to refund, which is a fact about the account's
|
|
2343
|
+
// present state. An empty report would read as "refunded, nothing to do".
|
|
2344
|
+
throw new PaymentsSubscriptionChangeRefusedError({
|
|
2345
|
+
message: "There is no payment on this subscription to refund.",
|
|
2346
|
+
action: "Check the subscription's payment history. A refund attaches to a payment, not to a plan.",
|
|
2347
|
+
detail: `No charge rows resolved on ${purchase.rail} for the family this subscription heads.`,
|
|
2348
|
+
});
|
|
2349
|
+
}
|
|
2350
|
+
|
|
2351
|
+
const refund = await provider.requestRefunds(
|
|
2352
|
+
{
|
|
2353
|
+
purchases: payments,
|
|
2354
|
+
// Composed here and never read from a body: it is written into the adopter's own store console,
|
|
2355
|
+
// where a person reads it as a statement about their business. The catalog product and nothing
|
|
2356
|
+
// else — no customer text, no identifier a store does not already hold.
|
|
2357
|
+
reason: `Refund requested by the subscriber for "${purchase.productId}".`,
|
|
2358
|
+
},
|
|
2359
|
+
{ now: clock(), deployment: deploymentName(c) },
|
|
2360
|
+
);
|
|
2361
|
+
|
|
2362
|
+
const raised = refund.outcomes.filter((outcome) => outcome.outcome === "requested");
|
|
2363
|
+
if (raised.length > 0) {
|
|
2364
|
+
await c.var.emit({
|
|
2365
|
+
action: PaymentsAuditActions.subscriptionRefundRequested,
|
|
2366
|
+
outcome: "success",
|
|
2367
|
+
// The person who asked, always — the holder they act for rides in the metadata.
|
|
2368
|
+
actorType: "user",
|
|
2369
|
+
actorId: c.var.auth?.userId,
|
|
2370
|
+
sessionId: c.var.auth?.sessionId,
|
|
2371
|
+
resourceType: "purchase",
|
|
2372
|
+
resourceId: purchase.id,
|
|
2373
|
+
// Counts and the store's own handles on the money in flight. **No amount**: how much anybody is
|
|
2374
|
+
// getting back is the store's later decision, and a figure here would assert something nobody has
|
|
2375
|
+
// agreed to, in the one table nothing corrects.
|
|
2376
|
+
metadata: {
|
|
2377
|
+
rail: purchase.rail,
|
|
2378
|
+
subjectType: subject.subjectType,
|
|
2379
|
+
subjectId: subject.subjectId,
|
|
2380
|
+
productId: purchase.productId,
|
|
2381
|
+
payments: refund.outcomes.length,
|
|
2382
|
+
requested: raised.length,
|
|
2383
|
+
alreadyRequested: refund.outcomes.filter((outcome) => outcome.outcome === "already_requested").length,
|
|
2384
|
+
failed: refund.outcomes.filter((outcome) => outcome.outcome === "failed").length,
|
|
2385
|
+
adjustmentIds: raised.map((outcome) => outcome.adjustmentId),
|
|
2386
|
+
},
|
|
2387
|
+
});
|
|
2388
|
+
}
|
|
2389
|
+
|
|
2390
|
+
return c.json({ refund: refundView(refund) } satisfies PaymentsRefundResponse, 200);
|
|
2391
|
+
});
|
|
2392
|
+
|
|
2393
|
+
/**
|
|
2394
|
+
* CONTROL PLANE. The discount codes this project has issued.
|
|
2395
|
+
*
|
|
2396
|
+
* Read from the store rather than from a table of ours: the store is where a code actually exists, and a
|
|
2397
|
+
* local mirror would be a second answer that drifts the first time somebody uses the dashboard.
|
|
2398
|
+
*
|
|
2399
|
+
* Its own scope, narrower than minting. A pane that lists what was issued does not need the power to
|
|
2400
|
+
* issue. Never reaches a browser — the set of codes an adopter has issued is a commercial fact, and the
|
|
2401
|
+
* client projection draws the same line here it draws for SKUs.
|
|
2402
|
+
*/
|
|
2403
|
+
app.get(
|
|
2404
|
+
`${base}/admin/discounts`,
|
|
2405
|
+
requireControlPlane(PAYMENTS_DISCOUNT_READ_SCOPE),
|
|
2406
|
+
zValidator("query", AdminDiscountsQuery, validationHook),
|
|
2407
|
+
async (c) => {
|
|
2408
|
+
const { rail } = c.req.valid("query");
|
|
2409
|
+
const provider = resolveRailProvider(rail, config, await credentials(c), trust);
|
|
2410
|
+
if (!isDiscountRail(provider)) {
|
|
2411
|
+
throw new PaymentsRailNotConfiguredError({ detail: `The ${provider.rail} rail does not mint discounts.` });
|
|
2412
|
+
}
|
|
2413
|
+
|
|
2414
|
+
const discounts = await provider.listDiscounts({ now: clock(), deployment: deploymentName(c) });
|
|
2415
|
+
|
|
2416
|
+
// Through `recordRead`, like every other management read: its own action, the caller's `sub` as the
|
|
2417
|
+
// actor, and a count rather than the rows.
|
|
2418
|
+
await recordRead(c, PaymentsAuditActions.discountsRead, rail, { rail, count: discounts.length });
|
|
2419
|
+
|
|
2420
|
+
return c.json({ discounts: [...discounts] } satisfies PaymentsAdminDiscountsResponse, 200);
|
|
2421
|
+
},
|
|
2422
|
+
);
|
|
2423
|
+
|
|
2424
|
+
/**
|
|
2425
|
+
* CONTROL PLANE. Mint a discount code at one store.
|
|
2426
|
+
*
|
|
2427
|
+
* **Its own scope**, `payments:discounts:create`, granted separately from the entitlement writes.
|
|
2428
|
+
* Comping somebody an entitlement and creating a code that reduces what everybody holding it pays are
|
|
2429
|
+
* different powers with different blast radii, and a tool that needs one must not acquire the other.
|
|
2430
|
+
*
|
|
2431
|
+
* **The kit provides the verb; the adopter provides the policy.** This creates the object at the store
|
|
2432
|
+
* and answers with what it made. Who may be offered a code, what it is worth, where that offer is
|
|
2433
|
+
* recorded and when it stops being advertised are commercial decisions with a company's pricing behind
|
|
2434
|
+
* them — a capability that guessed at them would be wrong for the second adopter.
|
|
2435
|
+
*
|
|
2436
|
+
* Applying a code does **not** go through here and does not require this scope. An adopter whose codes
|
|
2437
|
+
* are minted by hand in a provider dashboard is fully served by `/checkout`'s `discountCode`.
|
|
2438
|
+
*/
|
|
2439
|
+
app.post(
|
|
2440
|
+
`${base}/admin/discounts`,
|
|
2441
|
+
requireControlPlane(PAYMENTS_DISCOUNT_CREATE_SCOPE),
|
|
2442
|
+
zValidator("json", DiscountCreateRequest, validationHook),
|
|
2443
|
+
async (c) => {
|
|
2444
|
+
const input = c.req.valid("json");
|
|
2445
|
+
const provider = resolveRailProvider(input.rail, config, await credentials(c), trust);
|
|
2446
|
+
if (!isDiscountRail(provider)) {
|
|
2447
|
+
// Structural, so a rail that gains the ability needs no edit here and one that never will can
|
|
2448
|
+
// never be asked.
|
|
2449
|
+
throw new PaymentsRailNotConfiguredError({
|
|
2450
|
+
detail: `The ${provider.rail} rail does not mint discounts.`,
|
|
2451
|
+
});
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2454
|
+
const created = await provider.createDiscount(input.terms, { now: clock() });
|
|
2455
|
+
|
|
2456
|
+
const who = controlPlaneCaller(c);
|
|
2457
|
+
await c.var.emit({
|
|
2458
|
+
action: PaymentsAuditActions.discountCreated,
|
|
2459
|
+
outcome: "success",
|
|
2460
|
+
// The token's `sub`, so the trail names which person at the dashboard minted it rather than merely
|
|
2461
|
+
// "the dashboard" — one connection is normally shared by everybody using that console.
|
|
2462
|
+
actorType: "control-plane",
|
|
2463
|
+
actorId: who.subject,
|
|
2464
|
+
resourceType: "discount",
|
|
2465
|
+
resourceId: created.providerDiscountId,
|
|
2466
|
+
// What was minted and what it is worth, and the **store's id** rather than the code itself. The
|
|
2467
|
+
// code is a live bearer value: anyone who can read the trail could redeem it, and an audit trail
|
|
2468
|
+
// is queryable, long-lived, and read by more people than can mint. The id finds it in the
|
|
2469
|
+
// dashboard, which is where somebody entitled to see the code already is.
|
|
2470
|
+
metadata: {
|
|
2471
|
+
rail: input.rail,
|
|
2472
|
+
connectionId: who.connectionId,
|
|
2473
|
+
amount:
|
|
2474
|
+
created.terms.amount.kind === "percent"
|
|
2475
|
+
? `${created.terms.amount.percent}%`
|
|
2476
|
+
: `${created.terms.amount.amountMinor} ${created.terms.amount.currency}`,
|
|
2477
|
+
duration: created.terms.duration.kind,
|
|
2478
|
+
},
|
|
2479
|
+
});
|
|
2480
|
+
|
|
2481
|
+
return c.json(
|
|
2482
|
+
{
|
|
2483
|
+
code: created.code,
|
|
2484
|
+
providerDiscountId: created.providerDiscountId,
|
|
2485
|
+
rail: input.rail,
|
|
2486
|
+
} satisfies PaymentsDiscountResponse,
|
|
2487
|
+
200,
|
|
2488
|
+
);
|
|
2489
|
+
},
|
|
2490
|
+
);
|
|
2491
|
+
|
|
2492
|
+
app.post(
|
|
2493
|
+
`${base}/entitlements/grant`,
|
|
2494
|
+
requireControlPlane(PAYMENTS_ENTITLEMENT_GRANT_SCOPE),
|
|
2495
|
+
zValidator("json", EntitlementGrantRequest, validationHook),
|
|
2496
|
+
async (c) => {
|
|
2497
|
+
const input = c.req.valid("json");
|
|
2498
|
+
const caller = controlPlaneCaller(c);
|
|
2499
|
+
// **A grant under the kind this project does not bill is refused here**, and this is the one check
|
|
2500
|
+
// that genuinely belongs at the edge rather than at the write. `grantEntitlement` reads a config to
|
|
2501
|
+
// check the entitlement key against the catalog; the kind is a different question with a different
|
|
2502
|
+
// answer, and `revokeEntitlement` takes no config at all — so pushing this down would either grow a
|
|
2503
|
+
// parameter the revoke must never have, or apply to both. The asymmetry is deliberate and is the
|
|
2504
|
+
// same one the catalog check makes: a grant is constrained, a revoke stays legal forever, because a
|
|
2505
|
+
// revoke that a config edit made impossible would strand whoever still holds the row.
|
|
2506
|
+
//
|
|
2507
|
+
// The failure it prevents is the invisible kind #300 exists for. A comp written as `user:ada` in a
|
|
2508
|
+
// project that bills organizations lands in the table, answers 200, and is read by nothing: every
|
|
2509
|
+
// gate resolves the caller's *organization*, so the person is still locked out and the row says
|
|
2510
|
+
// otherwise. Not audited as a denial, unlike the catalog miss — there is nothing to enumerate here,
|
|
2511
|
+
// because `billingSubject` is one bit, fixed at deploy, and the message names it outright.
|
|
2512
|
+
if (input.subjectType !== config.billingSubject) {
|
|
2513
|
+
throw new ValidationError({
|
|
2514
|
+
message: `This project bills ${config.billingSubject}s, not ${input.subjectType}s.`,
|
|
2515
|
+
action: "Send `subjectType` as the kind this project bills, or change `billingSubject` in pithy.config.ts.",
|
|
2516
|
+
detail: `A grant to a ${input.subjectType} here writes a row keyed on a kind no read path resolves, so the holder stays unentitled and the table says otherwise.`,
|
|
2517
|
+
});
|
|
2518
|
+
}
|
|
2519
|
+
// The catalog check lives in `grantEntitlement`, at the write (#305). This route no longer decides
|
|
2520
|
+
// whether a key means something — it reports the refusal, which is a different job and the only one
|
|
2521
|
+
// an edge should have. Anything else here would be a second copy of the rule, and a second copy is
|
|
2522
|
+
// how the first one stops being the rule.
|
|
2523
|
+
let granted: PaymentsEntitlement;
|
|
2524
|
+
try {
|
|
2525
|
+
granted = await grantEntitlement(
|
|
2526
|
+
database(c),
|
|
2527
|
+
config,
|
|
2528
|
+
{
|
|
2529
|
+
subjectType: input.subjectType,
|
|
2530
|
+
subjectId: input.subjectId,
|
|
2531
|
+
entitlement: input.entitlement,
|
|
2532
|
+
expiresAt: input.expiresAt ?? null,
|
|
2533
|
+
},
|
|
2534
|
+
{ now: clock() },
|
|
2535
|
+
);
|
|
2536
|
+
} catch (cause) {
|
|
2537
|
+
// The refusal is the outcome worth recording here, and it was the only one not recorded. A
|
|
2538
|
+
// credential scoped only to grant can otherwise enumerate this project's entitlement vocabulary
|
|
2539
|
+
// one key at a time — 400 for a miss, 200 for a hit — and leave the customer nothing to read
|
|
2540
|
+
// afterwards. One refusal is a typo; a run of them against one connection is somebody mapping
|
|
2541
|
+
// what a project sells.
|
|
2542
|
+
//
|
|
2543
|
+
// `safeEmit` for the same reason the webhook guard uses it: the 400 is already decided, and an
|
|
2544
|
+
// audit write that threw would answer 500 for a failing store and 400 for a healthy one.
|
|
2545
|
+
if (cause instanceof PaymentsEntitlementNotInCatalogError) {
|
|
2546
|
+
await safeEmit(
|
|
2547
|
+
c.var.emit,
|
|
2548
|
+
{
|
|
2549
|
+
action: PaymentsAuditActions.entitlementGranted,
|
|
2550
|
+
outcome: "denied",
|
|
2551
|
+
severity: "warning",
|
|
2552
|
+
actorType: "service",
|
|
2553
|
+
actorId: c.var.controlPlane?.connectionId ?? "control-plane",
|
|
2554
|
+
resourceType: "entitlement",
|
|
2555
|
+
resourceId: input.entitlement,
|
|
2556
|
+
// The route and the submitted key, and nothing else the caller supplied. The key is safe to
|
|
2557
|
+
// record — `EntitlementKey` has already bounded it to `^[a-z][a-z0-9_]*$` at 64 characters —
|
|
2558
|
+
// and it is the only field that makes a run of refusals legible. Never the catalog: the
|
|
2559
|
+
// defined set goes in `detail`, which the codec strips, and must not be copied into a
|
|
2560
|
+
// queryable, long-lived trail.
|
|
2561
|
+
metadata: { route: "entitlements/grant", entitlement: input.entitlement },
|
|
2562
|
+
},
|
|
2563
|
+
c.var.log,
|
|
2564
|
+
);
|
|
2565
|
+
}
|
|
2566
|
+
throw cause;
|
|
2567
|
+
}
|
|
2568
|
+
await c.var.emit({
|
|
2569
|
+
action: PaymentsAuditActions.entitlementGranted,
|
|
2570
|
+
outcome: "success",
|
|
2571
|
+
// Notable, not routine: nothing else in this package writes an entitlement no store paid for.
|
|
2572
|
+
severity: "warning",
|
|
2573
|
+
// A management client, not a user of this app — so `control-plane`, and the actor is the token's
|
|
2574
|
+
// `sub`: which person at the dashboard did this, not merely "the dashboard". No `sessionId`, because
|
|
2575
|
+
// a control-plane call creates none and a null one would read as a correlation that got lost.
|
|
2576
|
+
actorType: "control-plane",
|
|
2577
|
+
actorId: caller.subject,
|
|
2578
|
+
resourceType: "entitlement",
|
|
2579
|
+
resourceId: granted.id,
|
|
2580
|
+
// The holder is the queryable fact — "what has been comped to this subject" is the question the
|
|
2581
|
+
// trail gets asked. The connection joins it to *which* management client, per adopter and per
|
|
2582
|
+
// environment, which the actor's own id space cannot answer on its own.
|
|
2583
|
+
metadata: {
|
|
2584
|
+
connectionId: caller.connectionId,
|
|
2585
|
+
// Two keys, never one joined string: "what has been comped to this holder" is a query, and a
|
|
2586
|
+
// trail is queried by equality on a column.
|
|
2587
|
+
subjectType: input.subjectType,
|
|
2588
|
+
subjectId: input.subjectId,
|
|
2589
|
+
entitlement: input.entitlement,
|
|
2590
|
+
expiresAt: granted.expiresAt?.toISOString() ?? null,
|
|
2591
|
+
},
|
|
2592
|
+
});
|
|
2593
|
+
return c.json(
|
|
2594
|
+
{
|
|
2595
|
+
entitlement: entitlementView({
|
|
2596
|
+
key: granted.entitlement,
|
|
2597
|
+
active: granted.active,
|
|
2598
|
+
expiresAt: granted.expiresAt,
|
|
2599
|
+
}),
|
|
2600
|
+
} satisfies PaymentsEntitlementResponse,
|
|
2601
|
+
200,
|
|
2602
|
+
);
|
|
2603
|
+
},
|
|
2604
|
+
);
|
|
2605
|
+
|
|
2606
|
+
/**
|
|
2607
|
+
* CONTROL PLANE. Revoke an entitlement by hand — a chargeback, an abuse decision, a comp withdrawn.
|
|
2608
|
+
*
|
|
2609
|
+
* Immediate, because the read model is what every gate hits: the account loses access on its next
|
|
2610
|
+
* request. Idempotent, and legal against an account that never held the key — the inactive row is the
|
|
2611
|
+
* record that somebody decided it, and support tooling should not have to ask first.
|
|
2612
|
+
*
|
|
2613
|
+
* Its own scope, `payments:entitlements:revoke`, granted separately from grant's. Taking paid access away
|
|
2614
|
+
* from a live customer and handing product out for free are different mistakes with different victims, and
|
|
2615
|
+
* an adopter's refund tooling has no business being able to make the second one.
|
|
2616
|
+
*/
|
|
2617
|
+
app.post(
|
|
2618
|
+
`${base}/entitlements/revoke`,
|
|
2619
|
+
requireControlPlane(PAYMENTS_ENTITLEMENT_REVOKE_SCOPE),
|
|
2620
|
+
zValidator("json", EntitlementRevokeRequest, validationHook),
|
|
2621
|
+
async (c) => {
|
|
2622
|
+
const input = c.req.valid("json");
|
|
2623
|
+
const caller = controlPlaneCaller(c);
|
|
2624
|
+
// **No billing-mode check here, and its absence is the design.** `revokeEntitlement` takes no config,
|
|
2625
|
+
// deliberately — see `entitlement/manual.ts` — so it has nothing to check a kind against, and giving
|
|
2626
|
+
// it one would make a `billingSubject` change as irreversible as a catalog edit: every row written
|
|
2627
|
+
// under the old kind would become unrevokable, on accounts that still hold it. A grant is
|
|
2628
|
+
// constrained and a revoke is not, exactly as with the catalog key.
|
|
2629
|
+
const revoked = await revokeEntitlement(
|
|
2630
|
+
database(c),
|
|
2631
|
+
{ subjectType: input.subjectType, subjectId: input.subjectId, entitlement: input.entitlement },
|
|
2632
|
+
{ now: clock() },
|
|
2633
|
+
);
|
|
2634
|
+
await c.var.emit({
|
|
2635
|
+
action: PaymentsAuditActions.entitlementRevoked,
|
|
2636
|
+
outcome: "success",
|
|
2637
|
+
severity: "warning",
|
|
2638
|
+
// Same attribution as the grant: the management client's own subject, and the connection it called
|
|
2639
|
+
// on. See that handler for why neither is a user id and why there is no session.
|
|
2640
|
+
actorType: "control-plane",
|
|
2641
|
+
actorId: caller.subject,
|
|
2642
|
+
resourceType: "entitlement",
|
|
2643
|
+
resourceId: revoked.id,
|
|
2644
|
+
metadata: {
|
|
2645
|
+
connectionId: caller.connectionId,
|
|
2646
|
+
subjectType: input.subjectType,
|
|
2647
|
+
subjectId: input.subjectId,
|
|
2648
|
+
entitlement: input.entitlement,
|
|
2649
|
+
},
|
|
2650
|
+
});
|
|
2651
|
+
return c.json(
|
|
2652
|
+
{
|
|
2653
|
+
entitlement: entitlementView({
|
|
2654
|
+
key: revoked.entitlement,
|
|
2655
|
+
active: revoked.active,
|
|
2656
|
+
expiresAt: revoked.expiresAt,
|
|
2657
|
+
}),
|
|
2658
|
+
} satisfies PaymentsEntitlementResponse,
|
|
2659
|
+
200,
|
|
2660
|
+
);
|
|
2661
|
+
},
|
|
2662
|
+
);
|
|
2663
|
+
};
|
|
2664
|
+
|
|
2665
|
+
/**
|
|
2666
|
+
* The webhook handler, once, for every rail.
|
|
2667
|
+
*
|
|
2668
|
+
* By the time it runs the guard has verified authenticity, recorded the delivery, and short-circuited a
|
|
2669
|
+
* redelivery it already processed — and the rail has produced a normalized event or explained why it could
|
|
2670
|
+
* not. So this is entirely about what to do with a verified notification, and none of it is rail-specific:
|
|
2671
|
+
* three stores, three unrelated proofs, one projection.
|
|
2672
|
+
*
|
|
2673
|
+
* **Always 200 once authenticity is established.** Every provider retries a non-2xx, and every refusal that
|
|
2674
|
+
* can be reached here is deterministic — an unmapped SKU, a sandbox transaction against production, a
|
|
2675
|
+
* notification with nobody to project it against. Retrying those produces the identical refusal forever, so a
|
|
2676
|
+
* 5xx would buy a retry storm and no repair. The recorded row with its reason is what makes the failure
|
|
2677
|
+
* visible, queryable, and replayable instead. A failure that is *not* deterministic never gets here: the guard
|
|
2678
|
+
* lets `payments/provider_unavailable` through with its own status, so an unreachable store is a non-2xx the
|
|
2679
|
+
* provider will redeliver.
|
|
2680
|
+
*/
|
|
2681
|
+
function webhookHandler(rail: PaymentsRail) {
|
|
2682
|
+
return async (c: Context<PithyHonoEnv>) => {
|
|
2683
|
+
const { notification, eventRowId } = verifiedWebhook(c);
|
|
2684
|
+
const now = clock();
|
|
2685
|
+
const d1 = database(c);
|
|
2686
|
+
|
|
2687
|
+
/**
|
|
2688
|
+
* Record the delivery's outcome and put it on the audit trail. One shape for every non-projecting path.
|
|
2689
|
+
*
|
|
2690
|
+
* **`note` and `error` are the same sentence to an operator and opposite states in the row**, so the
|
|
2691
|
+
* call sites below say which they mean rather than letting the presence of a reason decide. A note is
|
|
2692
|
+
* why nothing was *ever* going to project — the row is finished. An error is why this attempt did not
|
|
2693
|
+
* — the row stays repairable. See {@link completeWebhook}; #339 is what happens when the two are one.
|
|
2694
|
+
*/
|
|
2695
|
+
const acknowledge = async (outcome: {
|
|
2696
|
+
note?: string;
|
|
2697
|
+
error?: string;
|
|
2698
|
+
reason?: string;
|
|
2699
|
+
severity?: "warning";
|
|
2700
|
+
}): Promise<Response> => {
|
|
2701
|
+
await completeWebhook(
|
|
2702
|
+
d1,
|
|
2703
|
+
eventRowId,
|
|
2704
|
+
outcome.error !== undefined
|
|
2705
|
+
? { at: now, error: outcome.error }
|
|
2706
|
+
: outcome.note !== undefined
|
|
2707
|
+
? { at: now, note: outcome.note }
|
|
2708
|
+
: { at: now },
|
|
2709
|
+
);
|
|
2710
|
+
await c.var.emit({
|
|
2711
|
+
action: PaymentsAuditActions.webhookReceived,
|
|
2712
|
+
outcome: outcome.reason === undefined ? "success" : "failure",
|
|
2713
|
+
...(outcome.severity === undefined ? {} : { severity: outcome.severity }),
|
|
2714
|
+
actorType: "service",
|
|
2715
|
+
actorId: rail,
|
|
2716
|
+
metadata: {
|
|
2717
|
+
rail,
|
|
2718
|
+
providerEventId: notification.providerEventId,
|
|
2719
|
+
projected: false,
|
|
2720
|
+
...(outcome.reason === undefined ? {} : { reason: outcome.reason }),
|
|
2721
|
+
},
|
|
2722
|
+
});
|
|
2723
|
+
return c.json({ received: true, projected: false }, 200);
|
|
2724
|
+
};
|
|
2725
|
+
|
|
2726
|
+
// Bind the store account to a user, when the notification carried both halves — before anything else,
|
|
2727
|
+
// because the pairing is worth keeping even for a delivery that projects nothing. On a rail Pithy
|
|
2728
|
+
// initiates, this is the *only* place the map is ever written: a Stripe webhook arrives carrying `cus_…`,
|
|
2729
|
+
// and the `client_reference_id` beside it is the reference `/checkout` set from the authenticated buyer.
|
|
2730
|
+
// `linkProviderAccount` never rebinds, so the first pairing wins and a later session cannot steal it.
|
|
2731
|
+
//
|
|
2732
|
+
// **The reference decodes or it names nobody.** It is a string that made a round trip through somebody
|
|
2733
|
+
// else's system, so a bare id — the shape every pre-subject client sent — and a kind this build does
|
|
2734
|
+
// not know both answer `undefined`, and `undefined` writes no link at all. Reading a bare id as a user
|
|
2735
|
+
// would bind a store account to whoever happens to hold that id, which is the one guess this whole
|
|
2736
|
+
// design exists to refuse.
|
|
2737
|
+
const stamped =
|
|
2738
|
+
notification.accountReference === null || notification.accountReference === undefined
|
|
2739
|
+
? undefined
|
|
2740
|
+
: decodeSubjectReference(notification.accountReference);
|
|
2741
|
+
if (notification.providerAccountId && stamped) {
|
|
2742
|
+
await linkProviderAccount(d1, rail, notification.providerAccountId, stamped, { now });
|
|
2743
|
+
await repairOrphans(c, rail, now);
|
|
2744
|
+
// And the pairing is worth acting on, not only keeping. An orphan is a purchase that arrived before
|
|
2745
|
+
// its owner was knowable, and this link is the event that makes it knowable — the one signal no
|
|
2746
|
+
// store redelivers on. See `projection/orphans.ts`; #341 is the ten sweeps that never projected.
|
|
2747
|
+
}
|
|
2748
|
+
|
|
2749
|
+
/**
|
|
2750
|
+
* A refund reported by order id alone — Play's voided-purchase notification, the only one shaped this way.
|
|
2751
|
+
*
|
|
2752
|
+
* The rail cannot resolve it: Play's one-time lookup wants a product id the notification does not carry.
|
|
2753
|
+
* But the product is not Play's to supply. A Google purchase is keyed by its order id, so the row this
|
|
2754
|
+
* refund is about is one indexed read away, and it already knows its own product, owner, and family. The
|
|
2755
|
+
* notification is authentic and says the order was voided; that is the same statement Apple's `REFUND`
|
|
2756
|
+
* makes, and it needs no second opinion from the store.
|
|
2757
|
+
*
|
|
2758
|
+
* A void naming an order we never projected is genuinely unresolvable — a purchase from before this
|
|
2759
|
+
* capability was installed, or one that never reached us — so it is recorded with its reason, which is
|
|
2760
|
+
* what makes it findable later.
|
|
2761
|
+
*/
|
|
2762
|
+
if (!notification.event && notification.voidedOrderId) {
|
|
2763
|
+
const voided = await paymentsDatabase(d1)
|
|
2764
|
+
.selectFrom(PAYMENTS_PURCHASES_TABLE)
|
|
2765
|
+
.selectAll()
|
|
2766
|
+
.where("rail", "=", rail)
|
|
2767
|
+
.where("providerTransactionId", "=", notification.voidedOrderId)
|
|
2768
|
+
.executeTakeFirst();
|
|
2769
|
+
if (voided) {
|
|
2770
|
+
const row = PaymentsPurchase.parse(voided);
|
|
2771
|
+
const projection = await projectPurchase(
|
|
2772
|
+
d1,
|
|
2773
|
+
{
|
|
2774
|
+
...row,
|
|
2775
|
+
status: "refunded",
|
|
2776
|
+
revokedAt: now,
|
|
2777
|
+
// The store's own event time, so the monotonic rule orders this against whatever else arrives.
|
|
2778
|
+
providerEventAt: now,
|
|
2779
|
+
payload: notification.payload,
|
|
2780
|
+
},
|
|
2781
|
+
{ config, environment: deploymentEnvironment(c), now },
|
|
2782
|
+
);
|
|
2783
|
+
await fulfill(c, projection);
|
|
2784
|
+
await completeWebhook(d1, eventRowId, { at: now });
|
|
2785
|
+
await c.var.emit({
|
|
2786
|
+
action: PaymentsAuditActions.webhookReceived,
|
|
2787
|
+
outcome: "success",
|
|
2788
|
+
actorType: "service",
|
|
2789
|
+
actorId: rail,
|
|
2790
|
+
resourceType: "purchase",
|
|
2791
|
+
resourceId: projection.purchase.id,
|
|
2792
|
+
metadata: {
|
|
2793
|
+
rail,
|
|
2794
|
+
providerEventId: notification.providerEventId,
|
|
2795
|
+
productId: projection.product.id,
|
|
2796
|
+
status: projection.purchase.status,
|
|
2797
|
+
outcome: projection.outcome,
|
|
2798
|
+
projected: true,
|
|
2799
|
+
},
|
|
2800
|
+
});
|
|
2801
|
+
return c.json({ received: true, projected: true, outcome: projection.outcome }, 200);
|
|
2802
|
+
}
|
|
2803
|
+
return await acknowledge({
|
|
2804
|
+
error: `${noteText(notification.note) ?? "voided purchase"} — no purchase is stored under that order id.`,
|
|
2805
|
+
reason: "orphaned",
|
|
2806
|
+
severity: "warning",
|
|
2807
|
+
});
|
|
2808
|
+
}
|
|
2809
|
+
|
|
2810
|
+
// Authentic, and about no transaction. A test notification, a consumption request, a type the store
|
|
2811
|
+
// shipped after this package did. Where that needs an explanation the rail supplies one as a note.
|
|
2812
|
+
//
|
|
2813
|
+
// **A `note`, never an `error`, and these two branches are one state.** The rail has read the
|
|
2814
|
+
// notification and reported there is no transaction in it; a redelivery of the same bytes gets the
|
|
2815
|
+
// same answer, so the row is finished whether or not the rail explained itself. Recording the
|
|
2816
|
+
// explanation as an error was #339 — it left the row outstanding, which is this table's drift signal
|
|
2817
|
+
// and the guard's short-circuit both, on the deliveries that had nothing to repair.
|
|
2818
|
+
if (!notification.event) {
|
|
2819
|
+
const note = notification.note;
|
|
2820
|
+
if (note === null || note === undefined) return await acknowledge({});
|
|
2821
|
+
// **Which kind of note decides the state, and that is #341.** A note the delivered bytes state is
|
|
2822
|
+
// terminal, because the same bytes get the same answer from this build for ever. A note a *read*
|
|
2823
|
+
// produced is not: the read could have raced the purchase it asked about, or been made with a
|
|
2824
|
+
// credential that has since rotated, and finishing the row on it answered the redelivery that would
|
|
2825
|
+
// have carried the better answer with `duplicate`. See `NotificationNote`.
|
|
2826
|
+
return "stated" in note
|
|
2827
|
+
? await acknowledge({ note: note.stated, reason: "unresolvable", severity: "warning" })
|
|
2828
|
+
: await acknowledge({ error: note.read, reason: "unresolvable", severity: "warning" });
|
|
2829
|
+
}
|
|
2830
|
+
|
|
2831
|
+
const subject = await resolveNotificationOwner(paymentsDatabase(d1), rail, {
|
|
2832
|
+
providerAccountId: notification.providerAccountId,
|
|
2833
|
+
providerTransactionId: notification.event.providerTransactionId,
|
|
2834
|
+
originalTransactionId: notification.event.originalTransactionId,
|
|
2835
|
+
});
|
|
2836
|
+
if (!subject) {
|
|
2837
|
+
// Orphaned: nothing this server established names a holder, and no reference the store echoed back
|
|
2838
|
+
// decoded to one. No number of retries will conjure a link, so the row is what makes it repairable —
|
|
2839
|
+
// and nothing is projected, because the alternative to knowing is guessing, and a guess here grants
|
|
2840
|
+
// one customer's subscription to another.
|
|
2841
|
+
return await acknowledge({
|
|
2842
|
+
// The marker, not prose. An account linking has to be able to find exactly the rows that were
|
|
2843
|
+
// waiting on it, and this is the one condition it repairs — see `WEBHOOK_EVENT_ORPHANED`.
|
|
2844
|
+
error: `${WEBHOOK_EVENT_ORPHANED} no subject could be resolved for this notification`,
|
|
2845
|
+
reason: "orphaned",
|
|
2846
|
+
severity: "warning",
|
|
2847
|
+
});
|
|
2848
|
+
}
|
|
2849
|
+
|
|
2850
|
+
let projection: PurchaseProjection;
|
|
2851
|
+
try {
|
|
2852
|
+
projection = await projectPurchase(
|
|
2853
|
+
d1,
|
|
2854
|
+
{ ...notification.event, ...subject },
|
|
2855
|
+
{ config, environment: deploymentEnvironment(c), now },
|
|
2856
|
+
);
|
|
2857
|
+
} catch (cause) {
|
|
2858
|
+
const reason = cause instanceof PithyError ? cause.payload.code : "unknown";
|
|
2859
|
+
return await acknowledge({
|
|
2860
|
+
error:
|
|
2861
|
+
cause instanceof PithyError
|
|
2862
|
+
? `${reason}: ${cause.payload.detail ?? cause.payload.message}`
|
|
2863
|
+
: "projection failed",
|
|
2864
|
+
reason,
|
|
2865
|
+
severity: "warning",
|
|
2866
|
+
});
|
|
2867
|
+
}
|
|
2868
|
+
|
|
2869
|
+
// A second event the same notification implied — the subscription's standing, where the one above was a
|
|
2870
|
+
// charge. Only Lemon Squeezy sends one, and only on a refund: the invoice row goes `refunded` so the
|
|
2871
|
+
// ledger claws back, and this stops the subscription granting, because the buyer has their money back.
|
|
2872
|
+
//
|
|
2873
|
+
// Projected with the same owner, and outside the catch above on purpose: it is the half that revokes
|
|
2874
|
+
// access, so a failure must reach the store as a non-2xx and be redelivered rather than be acknowledged
|
|
2875
|
+
// as handled. Never fulfilled — a `state` row refuses both credit and clawback by role.
|
|
2876
|
+
if (notification.stateEvent) {
|
|
2877
|
+
await projectPurchase(
|
|
2878
|
+
d1,
|
|
2879
|
+
{ ...notification.stateEvent, ...subject },
|
|
2880
|
+
{ config, environment: deploymentEnvironment(c), now },
|
|
2881
|
+
);
|
|
2882
|
+
}
|
|
2883
|
+
|
|
2884
|
+
// Fulfillment sits outside that catch, and the asymmetry is deliberate. Every refusal the catch answers
|
|
2885
|
+
// is deterministic — an unmapped SKU, a sandbox transaction — so a retry reproduces it and a 200 is the
|
|
2886
|
+
// only sane answer. A fulfillment fault is transient by construction: a refused clawback is an outcome
|
|
2887
|
+
// rather than a throw, and a missing ledger fails at assembly. So it must reach the provider as a
|
|
2888
|
+
// non-2xx and be redelivered, which the stable grant ref makes free.
|
|
2889
|
+
await fulfill(c, projection);
|
|
2890
|
+
|
|
2891
|
+
await completeWebhook(d1, eventRowId, { at: now });
|
|
2892
|
+
await c.var.emit({
|
|
2893
|
+
action: PaymentsAuditActions.webhookReceived,
|
|
2894
|
+
outcome: "success",
|
|
2895
|
+
actorType: "service",
|
|
2896
|
+
actorId: rail,
|
|
2897
|
+
resourceType: "purchase",
|
|
2898
|
+
resourceId: projection.purchase.id,
|
|
2899
|
+
metadata: {
|
|
2900
|
+
rail,
|
|
2901
|
+
providerEventId: notification.providerEventId,
|
|
2902
|
+
productId: projection.product.id,
|
|
2903
|
+
status: projection.purchase.status,
|
|
2904
|
+
outcome: projection.outcome,
|
|
2905
|
+
projected: true,
|
|
2906
|
+
},
|
|
2907
|
+
});
|
|
2908
|
+
return c.json({ received: true, projected: true, outcome: projection.outcome }, 200);
|
|
2909
|
+
};
|
|
2910
|
+
}
|
|
2911
|
+
}
|