diff --git a/apps/web/content/docs/dev/payments/custom-provider.mdx b/apps/web/content/docs/dev/payments/custom-provider.mdx index 8d55979e3..a04d42dd6 100644 --- a/apps/web/content/docs/dev/payments/custom-provider.mdx +++ b/apps/web/content/docs/dev/payments/custom-provider.mdx @@ -23,14 +23,14 @@ import type { | Member | Required | Behavior | | ----------------- | -------- | ----------------------------------------------------------------------------------------------------------------- | -| `id` | Yes | Instance id: 1-100 lowercase letters, digits, `-`, `_`. Unique; names the webhook URL | +| `id` | Yes | Instance id: 1-100 lowercase letters, digits, `-`, `_`, starting with a letter or digit. Unique; names the webhook URL | | `type` | Yes | Adapter kind, e.g. `"stripe"`. Several instances may share it | | `name` | Yes | Display name, safe for the browser | | `createCheckout` | Yes | Creates the hosted checkout; must be idempotent on `idempotencyKey`. Returns `PaymentCheckoutState & { url }` | | `verifyWebhook` | Yes | Verifies `{ body: Uint8Array, headers: Headers }` and returns a `PaymentWebhookEvent` | | `retrieveCheckout`| No | Reads a checkout's current state. Without it, a missed webhook is only fixed by a redelivery | | `expireCheckout` | No | Closes an open checkout and returns its state. Without it, an open checkout blocks a replacement until it expires | -| `validateAmount` | No | Throws `PaymentProviderError` (kind `rejected`) for an amount the provider cannot charge | +| `validateAmount` | No | Throws `PaymentProviderError` (kind `rejected`) for an amount the provider cannot charge. Core then answers `invalid_amount` and stores nothing | The factory should throw at startup when a required secret is missing, so an install never runs a half-configured provider. @@ -48,8 +48,12 @@ install never runs a half-configured provider. | `cancelUrl` | `string` | Absolute URL on `VITNODE_WEB_URL` | | `expiresAt` | `Date` | Pass it to the provider so the checkout never outlives its attempt | -Core may call `createCheckout` again with identical arguments after a timeout or -a lost response. The provider must answer with the same checkout, not a new one. +Core may call `createCheckout` again for the same attempt after a timeout or a +lost response, with the same `idempotencyKey`, amount, currency, description and +`expiresAt`. The provider must answer with the same checkout, not a new one. The +return URLs come from the latest call; if the provider refuses a reused key +because they changed, throw kind `rejected`, and the next call starts a fresh +attempt. Core only replays while more than 35 minutes remain before `expiresAt`, and `expiresAt` is at most 23 hours away, which keeps replays inside the 24-hour idempotency window most providers offer. After that, an attempt whose checkout @@ -113,9 +117,13 @@ message. | `kind` | Use for | Core does | | --------------- | ----------------------------------------------- | ------------------------------------------------------------------------- | -| `unavailable` | Network errors, timeouts, rate limits, outages | Keeps the attempt; the next call or the reconciler replays it | -| `rejected` | Invalid parameters, unsupported currency | Marks a new attempt `failed` with `code` as `errorCode` | -| `configuration` | Missing or refused credentials | Same as `unavailable`, and logs it as an error | +| `unavailable` | Network errors, timeouts, rate limits, outages | Keeps the attempt `pending` and answers `provider_unavailable`. The next `createCheckout` for the same reference replays it | +| `rejected` | Invalid parameters, unsupported currency | Marks the attempt `failed` with `code` as `errorCode` and answers `provider_rejected` | +| `configuration` | Missing or refused credentials | Same as `unavailable` | + +Core logs `rejected` as a warning and the other kinds as errors. The reconciler +never replays `createCheckout`: an attempt nobody retries is expired after 15 +minutes, as described under recovery. ## Recovery @@ -147,7 +155,7 @@ page and refuses to start when `NODE_ENV=production`. href="/docs/dev/payments/stripe" /> diff --git a/apps/web/content/docs/dev/payments/index.mdx b/apps/web/content/docs/dev/payments/index.mdx index 2213789b5..bca29e65e 100644 --- a/apps/web/content/docs/dev/payments/index.mdx +++ b/apps/web/content/docs/dev/payments/index.mdx @@ -1,6 +1,6 @@ --- title: Payments -description: How VitNode takes one-time payments through a provider's hosted checkout - provider instances, payment statuses, webhooks and what the MVP does not cover yet. +description: How VitNode takes one-time payments through a provider's hosted checkout - provider instances, payment statuses, webhooks and what payments do not cover yet. icon: CreditCard --- @@ -29,10 +29,11 @@ badge, access to a page. signature against the exact bytes received, then records the new status. 5. Your plugin reads the stored payment to decide what the member owns. -If a webhook never arrives, core asks the provider itself: when the customer -looks at the status page (at most every 15 seconds) and from the -`reconcile-payments` cron job (every 5 minutes, for payments quiet for 10 -minutes). +If a webhook never arrives, core asks the provider itself: when your plugin +reads the payment with `getForUser({ refresh: true })`, for example on its +status page (at most every 15 seconds per payment), and from the +`reconcile-payments` cron job (every 5 minutes, for open payments unchanged for +10 minutes). ## Statuses @@ -69,17 +70,18 @@ payments: { - With no providers, the rest of VitNode works and `createCheckout()` fails with `provider_not_found`. -`payments.checkoutExpiresInMinutes` sets how long a checkout accepts payment: -30 to 1380 minutes, 60 by default. +`payments.checkoutExpiresInMinutes` sets how long a checkout accepts payment. +It defaults to 60, and values outside 30 to 1380 minutes (23 hours) are clamped +to that range. ## Events Core emits `payment.processing`, `payment.succeeded`, `payment.failed` and `payment.expired` after the status change is committed. See [built-in events](/docs/dev/events/built-in-events#payment-events). The -default events adapter runs listeners inside the webhook request and never -retries them, so do not grant anything from a listener alone: read the stored -payment instead. +default events adapter runs listeners inside the request that changed the +status (a webhook, a status refresh or the cron job) and never retries them, so +do not grant anything from a listener alone: read the stored payment instead. ## Guides @@ -90,7 +92,7 @@ payment instead. href="/docs/dev/payments/stripe" /> diff --git a/apps/web/content/docs/dev/payments/plugin.mdx b/apps/web/content/docs/dev/payments/plugin.mdx index 25336b70d..8b61804c9 100644 --- a/apps/web/content/docs/dev/payments/plugin.mdx +++ b/apps/web/content/docs/dev/payments/plugin.mdx @@ -1,5 +1,5 @@ --- -title: Use payments in a plugin +title: Plugin checkout description: Charge a signed-in member a fixed, server-side price from your VitNode plugin, send them to a hosted checkout with the typed fetcher, and show a payment only once the provider confirmed it. icon: ShoppingCart --- @@ -126,10 +126,14 @@ handler: async c => { `createCheckout()` stores the payment before calling the provider, so a timeout never loses track of it. Calling it again for the same reference returns the -open checkout (`resumed: true`), or replaces it when the provider, amount or -description changed. Errors are `PaymentError`s from -`@vitnode/core/api/lib/payments/errors`; `provider_unavailable` is safe to -retry. +open checkout (`resumed: true`) while it has more than 5 minutes left. When the +provider, amount, currency or description changed, or the checkout is about to +close, core closes it at the provider and starts a new one. Another member's +open payment for the reference answers `reference_in_use`, and one that is +still settling answers `payment_processing`. + +Errors are `PaymentError`s from `@vitnode/core/api/lib/payments/errors`; +`provider_unavailable` is safe to retry. The browser's `providerId` is only a choice among configured instances: an unknown id fails with `provider_not_found`. diff --git a/apps/web/content/docs/dev/payments/stripe.mdx b/apps/web/content/docs/dev/payments/stripe.mdx index a3b02c192..8baf9defc 100644 --- a/apps/web/content/docs/dev/payments/stripe.mdx +++ b/apps/web/content/docs/dev/payments/stripe.mdx @@ -122,13 +122,29 @@ Dashboard endpoint gets, so production uses its own value. ### Create the tables Payments add the `core_payments` and `core_payments_webhook_events` tables, -and the demo plugin adds `payments_demo_purchases`. Apply the migration in the -app that owns the database: +and the demo plugin adds `payments_demo_purchases`. From the workspace root, +generate and apply the migration: -```bash -pnpm vitnode db migrate + + +```bash tab="bun" +bun run db:migrate +``` + +```bash tab="pnpm" +pnpm db:migrate ``` +```bash tab="npm" +npm run db:migrate +``` + + + +`db:migrate` writes the migration in the app that owns the database and applies +it; commit that folder. `pnpm dev` does the same when it starts. See +[database](/docs/dev/database) for the details. + @@ -226,6 +242,7 @@ for a Dashboard webhook endpoint. | Symptom | Cause | | --------------------------------------------- | ----------------------------------------------------------------------------------------- | | API does not start: `needs secretKey` | `STRIPE_SECRET_KEY` missing or not an `sk_`/`rk_` key | +| API does not start: `needs webhookSecret` | `STRIPE_WEBHOOK_SECRET` missing or not a `whsec_` secret | | Webhook answers `400` | Wrong `STRIPE_WEBHOOK_SECRET`, a body changed by a proxy, or a live event on test keys | | Webhook answers `404` | The last URL segment is not a configured instance id | | Buy answers `provider_unavailable` | Stripe unreachable or the key was rejected; the API log names the Stripe error | @@ -235,7 +252,7 @@ for a Dashboard webhook endpoint. diff --git a/apps/web/src/vitnode.api.config.ts b/apps/web/src/vitnode.api.config.ts index 5ee12a38f..97086d107 100644 --- a/apps/web/src/vitnode.api.config.ts +++ b/apps/web/src/vitnode.api.config.ts @@ -59,10 +59,6 @@ export const vitNodeApiConfig = buildApiConfig({ authorization: { passkeys: true, }, - /** - * Hosted-checkout payments for the `payments-demo` plugin. Each provider is - * added only when its secrets are set, so the app runs without them. - */ payments: { providers: [ ...(process.env.STRIPE_SECRET_KEY @@ -74,8 +70,6 @@ export const vitNodeApiConfig = buildApiConfig({ }), ] : []), - // Development only: an in-memory second provider for trying provider - // selection. It moves no money and refuses to start in production. ...(process.env.NODE_ENV !== 'production' && process.env.PAYMENTS_FAKE_WEBHOOK_SECRET ? [ diff --git a/packages/stripe/src/index.ts b/packages/stripe/src/index.ts index 9f0c38d0e..7e6e0eeef 100644 --- a/packages/stripe/src/index.ts +++ b/packages/stripe/src/index.ts @@ -16,9 +16,7 @@ import Stripe from "stripe"; */ export const STRIPE_API_VERSION = Stripe.API_VERSION; -/** Currencies whose amounts Stripe takes with three decimals but only in tens. */ const THREE_DECIMAL_CURRENCIES = new Set(["bhd", "jod", "kwd", "omr", "tnd"]); -/** Stripe's largest charge is eight digits in minor units for most currencies. */ const MAX_AMOUNT = 99_999_999; const KEY_PATTERN = /^(?:sk|rk)_(test|live)_[A-Za-z0-9]+$/; @@ -51,9 +49,6 @@ const toProviderError = (error: unknown): PaymentProviderError => { }); } - // Parameters that do not match the first request with this key: a new - // attempt is needed, retrying this one can never succeed. A request still - // running with the same key (409) is only a matter of waiting. if ( error instanceof Stripe.errors.StripeIdempotencyError && error.statusCode !== 409 && @@ -78,7 +73,6 @@ const toProviderError = (error: unknown): PaymentProviderError => { }); } - // Connection errors, rate limits, lock timeouts and Stripe outages. return new PaymentProviderError({ code: error.code ?? "stripe_unavailable", kind: "unavailable", @@ -86,11 +80,6 @@ const toProviderError = (error: unknown): PaymentProviderError => { }); }; -/** - * A Checkout Session's normalized status. `complete` is only `succeeded` once - * `payment_status` is `paid`; a delayed payment method leaves it `unpaid`, - * which is `processing` until Stripe settles it. - */ const statusOf = ( session: Session, paymentIntent: null | Stripe.PaymentIntent, @@ -99,8 +88,6 @@ const statusOf = ( if (session.status !== "complete") return "pending"; if (session.payment_status === "paid") return "succeeded"; - // A completed session whose delayed payment failed keeps - // `payment_status: "unpaid"`; only its PaymentIntent tells. if ( paymentIntent?.status === "requires_payment_method" || paymentIntent?.status === "canceled" @@ -203,16 +190,20 @@ export const StripePaymentAdapter = ({ return client; }; - const retrieve = async (checkoutId: string) => { - const session = await stripe().checkout.sessions.retrieve(checkoutId, { - expand: ["payment_intent"], - }); - const paymentIntent = - typeof session.payment_intent === "object" - ? session.payment_intent - : null; - - return stateOf(session, statusOf(session, paymentIntent)); + const retrieveCheckout = async (checkoutId: string) => { + try { + const session = await stripe().checkout.sessions.retrieve(checkoutId, { + expand: ["payment_intent"], + }); + const paymentIntent = + typeof session.payment_intent === "object" + ? session.payment_intent + : null; + + return stateOf(session, statusOf(session, paymentIntent)); + } catch (error) { + throw toProviderError(error); + } }; return { @@ -252,8 +243,6 @@ export const StripePaymentAdapter = ({ }, }, ], - // The charged amount and currency must stay the ones VitNode - // stored; Adaptive Pricing would convert them. adaptive_pricing: { enabled: false }, client_reference_id: args.paymentId, metadata: { @@ -290,13 +279,7 @@ export const StripePaymentAdapter = ({ } }, - retrieveCheckout: async checkoutId => { - try { - return await retrieve(checkoutId); - } catch (error) { - throw toProviderError(error); - } - }, + retrieveCheckout, expireCheckout: async checkoutId => { try { @@ -304,14 +287,8 @@ export const StripePaymentAdapter = ({ return stateOf(session, statusOf(session, null)); } catch (error) { - // Only an open session can be expired. One the customer already - // completed (or that expired on its own) is read back instead. if (error instanceof Stripe.errors.StripeInvalidRequestError) { - try { - return await retrieve(checkoutId); - } catch (retrieveError) { - throw toProviderError(retrieveError); - } + return await retrieveCheckout(checkoutId); } throw toProviderError(error); @@ -357,8 +334,6 @@ export const StripePaymentAdapter = ({ case "checkout.session.completed": case "checkout.session.expired": { const session = event.data.object; - // Sessions created outside VitNode, or by another instance sharing - // this Stripe account, are not this instance's to handle. if (session.metadata?.[METADATA_PROVIDER_ID] !== id) return ignored; const status = diff --git a/packages/vitnode/src/api/adapters/payments/fake.ts b/packages/vitnode/src/api/adapters/payments/fake.ts index 91e7c4007..0c13d8d70 100644 --- a/packages/vitnode/src/api/adapters/payments/fake.ts +++ b/packages/vitnode/src/api/adapters/payments/fake.ts @@ -131,7 +131,6 @@ export const FakePaymentAdapter = ({ createCheckout: async args => Promise.resolve().then(() => { assertAvailable(); - // Idempotent like a real provider: the same key returns the same checkout. const checkoutId = `fake_cs_${createHash("sha256") .update(`${id}:${args.idempotencyKey}`) .digest("hex") diff --git a/packages/vitnode/src/api/middlewares/global.middleware.ts b/packages/vitnode/src/api/middlewares/global.middleware.ts index 20f21aebd..407c570bd 100644 --- a/packages/vitnode/src/api/middlewares/global.middleware.ts +++ b/packages/vitnode/src/api/middlewares/global.middleware.ts @@ -553,8 +553,6 @@ export const globalMiddleware = ({ ), ); - // Refused at boot: a duplicated id would make two instances share stored - // payments and one webhook URL. const paymentsMetadata = { providers: validatePaymentProviders(payments?.providers ?? []), checkoutTtlMs: diff --git a/packages/vitnode/src/api/middlewares/rate-limiter.middleware.ts b/packages/vitnode/src/api/middlewares/rate-limiter.middleware.ts index 1bd5ece7c..c13811d2d 100644 --- a/packages/vitnode/src/api/middlewares/rate-limiter.middleware.ts +++ b/packages/vitnode/src/api/middlewares/rate-limiter.middleware.ts @@ -87,12 +87,6 @@ const AUTH_RATE_LIMITED_PATHS = [ const READ_METHODS = new Set(["GET", "HEAD"]); -/** - * Provider webhooks: server-to-server, authenticated by their signature, and - * sent in bursts from a handful of provider IPs. Counting them against the - * per-IP write budget would refuse legitimate deliveries, so only the safety - * cap applies. - */ const PAYMENT_WEBHOOK_PATH = /\/@vitnode\/core\/payments\/webhooks\/[^/]+$/; export const rateLimiterCategoriesFor = ( diff --git a/packages/vitnode/src/api/models/payments.ts b/packages/vitnode/src/api/models/payments.ts index c1b853de9..cc3da33cc 100644 --- a/packages/vitnode/src/api/models/payments.ts +++ b/packages/vitnode/src/api/models/payments.ts @@ -94,23 +94,11 @@ export type PaymentWebhookOutcome = | "processed" | "unmatched"; -interface PaymentTransition { - from: PaymentStatus; - payment: PaymentRow; - to: PaymentStatus; -} - -/** Stripe's minimum lifetime for a hosted checkout is 30 minutes; a replayed create must stay above it. */ const MIN_REPLAY_REMAINING_MS = 35 * 60 * 1000; -/** A resumed checkout must leave the customer enough time to pay. */ const MIN_RESUME_REMAINING_MS = 5 * 60 * 1000; -/** A checkout that was never handed to anyone is abandoned after this long. */ const ORPHAN_ATTEMPT_MS = 15 * 60 * 1000; -/** How often a waiting customer may make core ask the provider for news. */ const REFRESH_INTERVAL_MS = 15 * 1000; -/** How long an open attempt is left alone before the reconciler asks the provider. */ const RECONCILE_AFTER_MS = 10 * 60 * 1000; -/** Provider events are kept this long, well past any provider's redelivery window. */ const WEBHOOK_EVENT_RETENTION_MS = 30 * 24 * 60 * 60 * 1000; export const PAYMENT_DEFAULT_CHECKOUT_MINUTES = 60; @@ -153,7 +141,6 @@ const UUID_PATTERN = */ export const resolvePaymentReturnUrl = (path: string): string => { if ( - typeof path !== "string" || !path.startsWith("/") || path.startsWith("//") || path.includes("\\") || @@ -179,7 +166,7 @@ export const resolvePaymentReturnUrl = (path: string): string => { export const paymentIdempotencyKey = (paymentId: string): string => `vitnode-payment-${paymentId}`; -const describeProviderError = (error: unknown): string => +const describeError = (error: unknown): string => error instanceof Error ? `${error.name}: ${error.message}` : String(error); /** @@ -218,7 +205,6 @@ export class PaymentsModel { return this.c.get("core").payments.providers; } - /** Applies provider state outside a webhook, under a row lock. Returns the row afterwards. */ private async applyCheckoutState( row: PaymentRow, state: PaymentCheckoutState, @@ -232,29 +218,28 @@ export class PaymentsModel { return row; } - const { after, transition } = await this.db.transaction(async tx => { + const { after, moved } = await this.db.transaction(async tx => { const [locked] = await tx .select() .from(core_payments) .where(eq(core_payments.id, row.id)) .for("update"); - if (!locked) return { after: row, transition: null }; + if (!locked) return { after: row, moved: null }; - const transition = await this.writeCheckoutState(tx, locked, state); + const moved = await this.writeCheckoutState(tx, locked, state); const [after] = await tx .select() .from(core_payments) .where(eq(core_payments.id, row.id)); - return { after: after ?? locked, transition }; + return { after: after ?? locked, moved }; }); - if (transition) await this.emitTransitions([transition]); + if (moved) await this.emitStatusChange(moved); return after; } - /** Why provider state cannot belong to this payment, or `null` when it can. */ private checkoutMismatch( payment: PaymentRow, state: PaymentCheckoutState, @@ -273,8 +258,7 @@ export class PaymentsModel { } if ( payment.livemode !== null && - state.livemode !== null && - state.livemode !== undefined && + typeof state.livemode === "boolean" && payment.livemode !== state.livemode ) { return "live and test mode differ"; @@ -283,24 +267,45 @@ export class PaymentsModel { return null; } - private async emitTransitions(transitions: PaymentTransition[]) { - for (const { from, payment, to } of transitions) { - if (to === "pending" || from === to) continue; - - await this.c.get("events").emit( - PAYMENT_EVENT_NAMES[to], - { - amount: payment.amount, - currency: payment.currency, - paymentId: payment.id, - pluginId: payment.pluginId, - providerId: payment.providerId, - reference: payment.reference, - userId: payment.userId, - }, - { pluginId: "@vitnode/core" }, - ); - } + private async claimRefresh( + paymentId: string, + ): Promise { + const [claimed] = await this.db + .update(core_payments) + .set({ syncedAt: new Date() }) + .where( + and( + eq(core_payments.id, paymentId), + or( + isNull(core_payments.syncedAt), + lt( + core_payments.syncedAt, + new Date(Date.now() - REFRESH_INTERVAL_MS), + ), + ), + ), + ) + .returning(); + + return claimed; + } + + private async emitStatusChange(payment: PaymentRow) { + if (payment.status === "pending") return; + + await this.c.get("events").emit( + PAYMENT_EVENT_NAMES[payment.status], + { + amount: payment.amount, + currency: payment.currency, + paymentId: payment.id, + pluginId: payment.pluginId, + providerId: payment.providerId, + reference: payment.reference, + userId: payment.userId, + }, + { pluginId: "@vitnode/core" }, + ); } private async expireUnstarted(row: PaymentRow, errorCode: string) { @@ -316,11 +321,7 @@ export class PaymentsModel { ) .returning(); - if (expired) { - await this.emitTransitions([ - { from: "pending", payment: expired, to: "expired" }, - ]); - } + if (expired) await this.emitStatusChange(expired); } private async lockPaymentForCheckout( @@ -340,9 +341,6 @@ export class PaymentsModel { .for("update"); if (byCheckout) return byCheckout; - // The event can beat the request that stores the checkout id. The payment - // id in the signed metadata finds the attempt, which must belong to this - // provider instance and not already hold a different checkout. if (!checkout.paymentId || !UUID_PATTERN.test(checkout.paymentId)) { return undefined; } @@ -374,7 +372,7 @@ export class PaymentsModel { await this.c .get("log") [level]( - `[payments] ${operation} on provider "${provider.id}" failed: ${describeProviderError(error)}`, + `[payments] ${operation} on provider "${provider.id}" failed: ${describeError(error)}`, ); } @@ -392,10 +390,6 @@ export class PaymentsModel { return provider; } - /** - * Returns the open attempt's checkout when it can be reused, or closes the - * attempt so a new one can start. Returns `null` once it is closed. - */ private async resumeOrClose( open: PaymentRow, input: PaymentCreateCheckoutInput, @@ -407,28 +401,28 @@ export class PaymentsModel { throw new PaymentError("payment_processing"); } - const now = Date.now(); - const remaining = open.expiresAt.getTime() - now; + const remaining = open.expiresAt.getTime() - Date.now(); const compatible = open.providerId === input.providerId && open.amount === input.amount && open.currency === input.currency && open.description === input.description; - if (compatible && open.checkoutId && open.checkoutUrl) { - if (remaining > MIN_RESUME_REMAINING_MS) { - return { - expiresAt: open.expiresAt, - paymentId: open.id, - redirectUrl: open.checkoutUrl, - resumed: true, - }; - } + if ( + compatible && + open.checkoutId && + open.checkoutUrl && + remaining > MIN_RESUME_REMAINING_MS + ) { + return { + expiresAt: open.expiresAt, + paymentId: open.id, + redirectUrl: open.checkoutUrl, + resumed: true, + }; } if (compatible && !open.checkoutId && remaining > MIN_REPLAY_REMAINING_MS) { - // The first call may have created the checkout and lost the response. - // The same idempotency key makes the provider return that checkout. return await this.startCheckout(open, this.provider(open.providerId), { cancelPath: input.cancelPath, successPath: input.successPath, @@ -437,14 +431,11 @@ export class PaymentsModel { } if (!open.checkoutId) { - // Its checkout URL never left the server, so nobody can pay it. await this.expireUnstarted(open, "checkout_not_created"); return null; } - // An open checkout the customer may still pay: close it at the provider - // first, and only start a new one once the provider agrees it is closed. const provider = this.provider(open.providerId); if (!provider.expireCheckout) { throw new PaymentError( @@ -507,17 +498,11 @@ export class PaymentsModel { ), ) .returning(); - if (failed) { - await this.emitTransitions([ - { from: "pending", payment: failed, to: "failed" }, - ]); - } + if (failed) await this.emitStatusChange(failed); throw new PaymentError("provider_rejected", error.message); } - // Unknown outcome: the attempt stays `pending` without a checkout, and - // the next call or the reconciler replays it with the same key. throw new PaymentError("provider_unavailable"); } @@ -535,7 +520,6 @@ export class PaymentsModel { const after = await this.applyCheckoutState(row, created); if (after.status !== "pending" || after.checkoutId !== created.checkoutId) { - // The replayed checkout already finished or expired. throw new PaymentError( after.status === "processing" ? "payment_processing" @@ -569,22 +553,16 @@ export class PaymentsModel { return await this.applyCheckoutState(row, state); } - /** - * Writes provider state to a locked row: attaches the checkout, and moves the - * status only along a legal transition. Returns the transition, if any. - */ private async writeCheckoutState( tx: Tx, payment: PaymentRow, state: PaymentCheckoutState, - ): Promise { + ): Promise { const to = state.status; const moves = to !== payment.status && canTransitionPayment(payment.status, to); const stillOpen = isOpenPaymentStatus(moves ? to : payment.status); - // A terminal payment keeps what it recorded; only a missing checkout id is - // filled in, so later events still match it. if (!moves && !isOpenPaymentStatus(payment.status)) { if (!payment.checkoutId) { await tx @@ -624,9 +602,9 @@ export class PaymentsModel { ) .returning(); - if (!updated || !moves) return null; + if (!moves) return null; - return { from: payment.status, payment: updated, to }; + return updated ?? null; } /** @@ -662,7 +640,6 @@ export class PaymentsModel { if (!reference || reference.length > 255) { throw new Error("A payment reference is 1-255 characters."); } - // Validated now so a bad path never reaches the provider. resolvePaymentReturnUrl(input.successPath); resolvePaymentReturnUrl(input.cancelPath); @@ -708,7 +685,6 @@ export class PaymentsModel { }) .returning(); } catch (error) { - // Another request opened an attempt for this reference first. if (isUniqueViolation(error)) { throw new PaymentError("payment_in_progress"); } @@ -765,21 +741,7 @@ export class PaymentsModel { return toPayment(row); } - // One refresh per interval per payment, whoever asks: claim it first. - const threshold = new Date(Date.now() - REFRESH_INTERVAL_MS); - const [claimed] = await this.db - .update(core_payments) - .set({ syncedAt: new Date() }) - .where( - and( - eq(core_payments.id, row.id), - or( - isNull(core_payments.syncedAt), - lt(core_payments.syncedAt, threshold), - ), - ), - ) - .returning(); + const claimed = await this.claimRefresh(row.id); if (!claimed) return toPayment(row); return toPayment(await this.syncFromProvider(claimed)); @@ -850,17 +812,17 @@ export class PaymentsModel { ), ); - const transition = await this.writeCheckoutState(tx, payment, checkout); + const moved = await this.writeCheckoutState(tx, payment, checkout); return { + moved, outcome: "processed" as const, paymentId: payment.id, - transition, }; }); - if ("transition" in result && result.transition) { - await this.emitTransitions([result.transition]); + if ("moved" in result && result.moved) { + await this.emitStatusChange(result.moved); } if (result.outcome === "unmatched") { await this.c @@ -934,29 +896,13 @@ export class PaymentsModel { continue; } - // Claimed so that two instances do not both call the provider. - const [claimed] = await this.db - .update(core_payments) - .set({ syncedAt: new Date() }) - .where( - and( - eq(core_payments.id, row.id), - or( - isNull(core_payments.syncedAt), - lt( - core_payments.syncedAt, - new Date(Date.now() - REFRESH_INTERVAL_MS), - ), - ), - ), - ) - .returning(); + const claimed = await this.claimRefresh(row.id); if (claimed) await this.syncFromProvider(claimed); } catch (error) { await this.c .get("log") .error( - `[payments] Reconciling payment ${row.id} failed: ${describeProviderError(error)}`, + `[payments] Reconciling payment ${row.id} failed: ${describeError(error)}`, ); } } diff --git a/packages/vitnode/src/api/modules/cron/routes/cron.route.ts b/packages/vitnode/src/api/modules/cron/routes/cron.route.ts index 797c3f55c..849cfa27d 100644 --- a/packages/vitnode/src/api/modules/cron/routes/cron.route.ts +++ b/packages/vitnode/src/api/modules/cron/routes/cron.route.ts @@ -12,9 +12,6 @@ import { updateCronJobs, } from "../helpers/process-cron-jobs"; -// Postgres keeps microseconds; the `Date` read back keeps milliseconds. A row -// written outside JavaScript (`now()` in SQL) would never equal the value read, -// so the compare-and-set matches the millisecond it was read as. const sameMillisecond = (lastRun: Date) => and( gte(core_cron.lastRun, lastRun), diff --git a/packages/vitnode/src/api/modules/payments/routes/webhook.route.ts b/packages/vitnode/src/api/modules/payments/routes/webhook.route.ts index 5b2fdbf83..53cd1d2f5 100644 --- a/packages/vitnode/src/api/modules/payments/routes/webhook.route.ts +++ b/packages/vitnode/src/api/modules/payments/routes/webhook.route.ts @@ -3,7 +3,6 @@ import { z } from "@hono/zod-openapi"; import { buildRoute } from "@/api/builders"; import { PaymentError, - PaymentProviderError, PaymentWebhookVerificationError, } from "@/api/lib/payments/errors"; @@ -42,13 +41,11 @@ export const paymentWebhookRoute = buildRoute({ }, handler: async c => { const { providerId } = c.req.valid("param"); - // The exact bytes received: signatures are computed over them, so the body - // must never be parsed and re-serialized before verification. - const body = new Uint8Array(await c.req.arrayBuffer()); + const rawBody = new Uint8Array(await c.req.arrayBuffer()); try { const result = await c.get("payments").handleWebhook({ - body, + body: rawBody, headers: c.req.raw.headers, providerId, }); @@ -69,9 +66,7 @@ export const paymentWebhookRoute = buildRoute({ .get("log") .error( `[payments] Webhook for provider "${providerId}" failed and will be redelivered: ${ - error instanceof PaymentProviderError || error instanceof Error - ? error.message - : String(error) + error instanceof Error ? error.message : String(error) }`, ); diff --git a/packages/vitnode/src/database/payments.ts b/packages/vitnode/src/database/payments.ts index feaab9fb4..a5d571873 100644 --- a/packages/vitnode/src/database/payments.ts +++ b/packages/vitnode/src/database/payments.ts @@ -26,8 +26,6 @@ export const PAYMENT_STATUSES = [ export const core_payments = camelCase.table.withRLS( "core_payments", t => ({ - // Generated before the insert, so it can key the provider idempotency key - // and the checkout metadata. id: t.uuid().primaryKey(), pluginId: t.varchar({ length: 100 }).notNull(), reference: t.varchar({ length: 255 }).notNull(), diff --git a/plugins/payments-demo/src/api/modules/purchases/purchase-status.ts b/plugins/payments-demo/src/api/modules/purchases/purchase-status.ts index 5a6851221..fffffb941 100644 --- a/plugins/payments-demo/src/api/modules/purchases/purchase-status.ts +++ b/plugins/payments-demo/src/api/modules/purchases/purchase-status.ts @@ -3,6 +3,7 @@ import type { Payment } from "@vitnode/core/api/models/payments"; import type { Context } from "hono"; import { z } from "@hono/zod-openapi"; +import { PAYMENT_STATUSES } from "@vitnode/core/database/payments"; import { DEMO_ITEMS, type DemoItemId, purchaseReference } from "@/catalog"; @@ -19,13 +20,7 @@ export const zodPurchase = z.object({ expiresAt: z.string(), id: z.string(), providerId: z.string(), - status: z.enum([ - "pending", - "processing", - "succeeded", - "failed", - "expired", - ]), + status: z.enum(PAYMENT_STATUSES), }) .nullable(), title: z.string(), diff --git a/plugins/payments-demo/src/api/modules/purchases/purchases.module.ts b/plugins/payments-demo/src/api/modules/purchases/purchases.module.ts index dce7bd2e5..b64e7a3e8 100644 --- a/plugins/payments-demo/src/api/modules/purchases/purchases.module.ts +++ b/plugins/payments-demo/src/api/modules/purchases/purchases.module.ts @@ -10,9 +10,6 @@ import { purchaseRoute, } from "./purchases.routes"; -// A side effect only. What a member owns is read from the stored payment, -// never from this event: the default events adapter runs listeners in the -// webhook request and does not retry them. const logPaidPurchase = buildEventListener({ event: "payment.succeeded", name: "log-paid-purchase", diff --git a/plugins/payments-demo/src/api/modules/purchases/purchases.routes.ts b/plugins/payments-demo/src/api/modules/purchases/purchases.routes.ts index f16590abe..be535606b 100644 --- a/plugins/payments-demo/src/api/modules/purchases/purchases.routes.ts +++ b/plugins/payments-demo/src/api/modules/purchases/purchases.routes.ts @@ -188,8 +188,6 @@ export const checkoutRoute = buildRoute({ const item = DEMO_ITEMS[itemId]; const db = c.get("db"); - // One purchase per member and item, reused when an earlier checkout - // expired or was abandoned. await db .insert(payments_demo_purchases) .values({ itemId, userId: user.id }) diff --git a/plugins/payments-demo/src/pages/queries.ts b/plugins/payments-demo/src/pages/queries.ts index 1edf7f486..43487c80d 100644 --- a/plugins/payments-demo/src/pages/queries.ts +++ b/plugins/payments-demo/src/pages/queries.ts @@ -89,6 +89,5 @@ export const formatMinorUnits = ( const decimal = digits === 0 ? raw : `${raw.slice(0, -digits)}.${raw.slice(-digits)}`; - // `format` takes decimal strings exactly (Intl.NumberFormat v3). return format.format(decimal as `${number}`); }; diff --git a/plugins/payments-demo/src/pages/shop-page.tsx b/plugins/payments-demo/src/pages/shop-page.tsx index 1a1064e7b..0b8eba83d 100644 --- a/plugins/payments-demo/src/pages/shop-page.tsx +++ b/plugins/payments-demo/src/pages/shop-page.tsx @@ -68,8 +68,6 @@ const ShopPage = () => { providerId: selectedProvider, }); if ("redirectUrl" in result) { - // Leaves the site for the provider's hosted page; keep the button - // busy until the browser navigates away. window.location.assign(result.redirectUrl); return; diff --git a/plugins/payments-demo/src/pages/start-checkout.ts b/plugins/payments-demo/src/pages/start-checkout.ts index f1d28c9ea..872c98d9f 100644 --- a/plugins/payments-demo/src/pages/start-checkout.ts +++ b/plugins/payments-demo/src/pages/start-checkout.ts @@ -1,25 +1,24 @@ import { fetcher } from "@vitnode/core/tanstack/fetcher"; +import type { DemoItemId } from "@/catalog"; + import { manifest } from "@/manifest.gen"; -export type CheckoutError = - | "already_owned" - | "payment_in_progress" - | "payment_processing" - | "provider_not_found" - | "provider_rejected" - | "provider_unavailable" - | "unauthorized" - | "unknown"; - -const KNOWN_ERRORS = new Set([ +const SERVER_ERRORS = [ "already_owned", "payment_in_progress", "payment_processing", "provider_not_found", "provider_rejected", "provider_unavailable", -]); +] as const; + +type ServerError = (typeof SERVER_ERRORS)[number]; + +export type CheckoutError = "unauthorized" | "unknown" | ServerError; + +const isServerError = (code: string): code is ServerError => + (SERVER_ERRORS as readonly string[]).includes(code); /** * Asks the server for a checkout and returns where to send the browser. Only @@ -29,7 +28,7 @@ export const startCheckout = async ({ itemId, providerId, }: { - itemId: "sticker-pack"; + itemId: DemoItemId; providerId: string; }): Promise<{ error: CheckoutError } | { redirectUrl: string }> => { const res = await fetcher({ @@ -53,7 +52,5 @@ export const startCheckout = async ({ ? String(body.error) : ""; - return { - error: KNOWN_ERRORS.has(code) ? (code as CheckoutError) : "unknown", - }; + return { error: isServerError(code) ? code : "unknown" }; }; diff --git a/plugins/payments-demo/src/routes.ts b/plugins/payments-demo/src/routes.ts index 17d555b04..5ea069fcd 100644 --- a/plugins/payments-demo/src/routes.ts +++ b/plugins/payments-demo/src/routes.ts @@ -6,8 +6,6 @@ export const routes = definePluginRoutes([ messages: ["payments-demo"], }), - // The success and cancel destination of the checkout. It shows the - // server's status, never the fact that the browser came back. page("/payments-demo/purchases/:purchaseId", { component: lazy(() => import("./pages/purchase-page")), messages: ["payments-demo"],