Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 16 additions & 8 deletions apps/web/content/docs/dev/payments/custom-provider.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -147,7 +155,7 @@ page and refuses to start when `NODE_ENV=production`.
href="/docs/dev/payments/stripe"
/>
<Card
title="Use payments in a plugin"
title="Plugin checkout"
description="createCheckout, getForUser and listByReference in practice"
href="/docs/dev/payments/plugin"
/>
Expand Down
24 changes: 13 additions & 11 deletions apps/web/content/docs/dev/payments/index.mdx
Original file line number Diff line number Diff line change
@@ -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
---

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand All @@ -90,7 +92,7 @@ payment instead.
href="/docs/dev/payments/stripe"
/>
<Card
title="Use payments in a plugin"
title="Plugin checkout"
description="Charge a fixed price and show a confirmed result"
href="/docs/dev/payments/plugin"
/>
Expand Down
14 changes: 9 additions & 5 deletions apps/web/content/docs/dev/payments/plugin.mdx
Original file line number Diff line number Diff line change
@@ -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
---
Expand Down Expand Up @@ -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`.
Expand Down
27 changes: 22 additions & 5 deletions apps/web/content/docs/dev/payments/stripe.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
<Tabs groupId='package-manager' persist items={['bun', 'pnpm', 'npm']} label='Migrate the database'>

```bash tab="bun"
bun run db:migrate
```

```bash tab="pnpm"
pnpm db:migrate
```

```bash tab="npm"
npm run db:migrate
```

</Tabs>

`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.

</Step>
</Steps>

Expand Down Expand Up @@ -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 |
Expand All @@ -235,7 +252,7 @@ for a Dashboard webhook endpoint.

<Cards>
<Card
title="Use payments in a plugin"
title="Plugin checkout"
description="The code behind the demo: trusted prices, checkout and status"
href="/docs/dev/payments/plugin"
/>
Expand Down
6 changes: 0 additions & 6 deletions apps/web/src/vitnode.api.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
? [
Expand Down
57 changes: 16 additions & 41 deletions packages/stripe/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]+$/;
Expand Down Expand Up @@ -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 &&
Expand All @@ -78,19 +73,13 @@ const toProviderError = (error: unknown): PaymentProviderError => {
});
}

// Connection errors, rate limits, lock timeouts and Stripe outages.
return new PaymentProviderError({
code: error.code ?? "stripe_unavailable",
kind: "unavailable",
message,
});
};

/**
* 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,
Expand All @@ -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"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -290,28 +279,16 @@ export const StripePaymentAdapter = ({
}
},

retrieveCheckout: async checkoutId => {
try {
return await retrieve(checkoutId);
} catch (error) {
throw toProviderError(error);
}
},
retrieveCheckout,

expireCheckout: async checkoutId => {
try {
const session = await stripe().checkout.sessions.expire(checkoutId);

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);
Expand Down Expand Up @@ -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 =
Expand Down
1 change: 0 additions & 1 deletion packages/vitnode/src/api/adapters/payments/fake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
2 changes: 0 additions & 2 deletions packages/vitnode/src/api/middlewares/global.middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down
Loading
Loading