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"],