Skip to content

feat(payments): ✨ hosted-checkout payments, Stripe adapter and demo plugin - #906

Open
aXenDeveloper wants to merge 5 commits into
canaryfrom
feat/payments
Open

aXenDeveloper wants to merge 5 commits into
canaryfrom
feat/payments

Conversation

@aXenDeveloper

Copy link
Copy Markdown
Owner

Improving Documentation

  • pnpm lint:fix was run before opening this PR; it changed nothing beyond this diff.
  • New docs: docs/dev/payments (overview, Stripe, use in a plugin, custom provider), a Payment events section in built-in events, and a note in the rate limiter page.

Description

What?

A minimal, extensible payment foundation: one-time, positive-amount payments through a provider's hosted checkout.

Core (@vitnode/core)

  • PaymentProviderApiPlugin contract (api/lib/payments/provider.ts), configured as payments: { providers: [...] }. Several instances run at once, each with a unique id; missing, malformed or duplicate ids stop the API at startup. With no providers the rest of VitNode works and payment calls fail with provider_not_found.
  • Tables core_payments and core_payments_webhook_events, with a partial unique index allowing one open attempt per (pluginId, reference) and a unique (providerId, eventId).
  • c.get("payments"):
    • createCheckout() stores the attempt before calling the provider and sends a per-attempt idempotency key and the payment id in metadata. It resumes a matching open checkout, and replaces one whose provider, amount or description changed by expiring it at the provider first.
    • getForUser({ refresh }) checks plugin and owner, and re-reads an open payment from the provider at most every 15 s.
    • listByReference() returns all attempts for one business reference.
  • POST /api/@vitnode/core/payments/webhooks/{providerId} verifies the raw bytes with that instance's secret. It commits the event id and the status change in one transaction, so a redelivery answers duplicate and a database error answers 500 (the provider retries). An event that arrives before the checkout id is stored is matched through the signed metadata, with provider, amount, currency and mode checks.
  • Status machine: pending → processing → succeeded | failed and pending → succeeded | failed | expired. Final statuses never change, so late or duplicate events cannot undo a success.
  • reconcile-payments cron job (every 5 min) re-reads quiet open payments and expires attempts whose checkout was never created.
  • Events payment.processing, payment.succeeded, payment.failed, payment.expired are emitted after the commit.
  • FakePaymentAdapter for tests; it refuses to start when NODE_ENV=production.
  • Payment webhooks spend only the rate limiter's safety cap, not the per-IP write budget.
  • Fix (cron): the cron compare-and-set never matched a lastRun stored with microseconds (written outside JS), so that job was skipped on every tick. It now matches the millisecond.

@vitnode/stripe (new package): Stripe-hosted Checkout in payment mode on SDK 23.0.1, pinned to API 2026-09-30.endive. Adaptive Pricing is disabled so the charged amount and currency stay the stored ones. Live or test mode follows the key, and a webhook from the other mode is refused. completed + unpaid maps to processing; async success and failure and expiry are handled. Errors are mapped to unavailable / rejected / configuration.

payments-demo (new private plugin): one $5.00 item priced on the server. Authenticated checkout, an owner-only status page that polls until the server confirms, and a provider picker only when several providers exist. What a member owns is derived from the stored succeeded payment, which survives crashes and can't be granted twice; the payment.succeeded listener only logs. Registered in apps/web and apps/api; Stripe is added only when its keys are set, and the fake provider only in development with PAYMENTS_FAKE_WEBHOOK_SECRET.

Migration: apps/api/migrations/20261028175032_add_payments.

Why?

Plugins need a safe way to charge for something without each one handling provider secrets, webhook signatures, idempotency and recovery. The browser can never choose the price, currency, owner, return URL or credentials, and a redirect back from checkout is never treated as proof of payment.

Out of scope on purpose: catalog, cart, tax, invoices, refunds, subscriptions, saved cards, Connect, an AdminCP for providers, and a custom card form.

Verification

  • End-to-end script on the real Hono app and PostgreSQL: 66/66. It covers two instances, ownership, ignoring an amount sent by the browser, success, processing, failure and expiry, tampered body, wrong secret, old signature, live/test mismatch, concurrent and duplicate delivery, late events, lost provider response, an event arriving before the checkout id is stored, outage, cron reconciliation, a database failure followed by redelivery, and real Stripe-signed payloads. A separate script checks the follow-up fixes: 6/6.
  • Browser walkthrough of the demo (fake provider): pending → confirmed, a redelivery answers duplicate, no console errors, no horizontal scroll at 390px. The screenshots in the docs come from this run.
  • Core: tsc clean, 9,863 unit tests pass. The web production build passes (MDX included) and tsc is clean. The new docs pages render, images load, internal links resolve.
  • The migration applies on a fresh database, and db generate reports no drift afterwards.
  • The client bundle contains no Stripe SDK or webhook verification code.

Not verified

  • A real Stripe test-mode payment and stripe listen: the session that built this had no access to Stripe or keys. Stripe behaviour was checked against the SDK's generated types because docs.stripe.com was blocked.
  • Whether the Stripe Dashboard accepts the @ in /api/@vitnode/core/payments/webhooks/{id}. It is valid in a URL path; %40 does not route.
  • No regression tests yet: AGENTS.md asks for them in a separate session. Suggested: a webhook row in rateLimiterCategoriesFor's test table, the payments service, the Stripe adapter mapping and the cron claim.

Generated by Claude Code

Postgres keeps microseconds, the Date read back keeps milliseconds, so a
lastRun written outside JavaScript never equalled the value read and the
job was skipped on every tick. The claim now matches that millisecond.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CnAiFaNGAPcFw63CH3Yo77
- Provider contract (PaymentProviderApiPlugin) with several instances at
  once, validated at startup, configured in `payments.providers`
- core_payments and core_payments_webhook_events tables
- c.get("payments"): createCheckout (stored before the provider call,
  per-attempt idempotency key, resume or replace an open attempt),
  getForUser with a rate-limited provider refresh, listByReference
- POST /payments/webhooks/{providerId}: raw-body verification, event id
  and status committed in one transaction, legal transitions only
- reconcile-payments cron job for missed webhooks
- payment.processing/succeeded/failed/expired events
- FakePaymentAdapter for tests, refused in production
- Payment webhooks spend only the rate limiter's safety cap

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CnAiFaNGAPcFw63CH3Yo77
Stripe-hosted Checkout in payment mode, pinned to the SDK's API version,
with signature verification on raw bytes, live/test mode checks and
normalized session states for completed, async and expired events.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CnAiFaNGAPcFw63CH3Yo77
Server-side price, authenticated checkout, ownership-checked status page
that stays pending until the provider confirms. What a member owns is
derived from the stored payment. Includes the migration for the payment
and demo tables, and registers the plugin and providers in both apps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CnAiFaNGAPcFw63CH3Yo77
…rovider reference

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CnAiFaNGAPcFw63CH3Yo77
@github-actions github-actions Bot added the 💡 Feature A new feature label Oct 28, 2026
@github-actions

Copy link
Copy Markdown

React Doctor found 4 new issues in 2 files · 4 warnings · score 91 / 100 (Great) · 0 fixed · vs canary

4 warnings

src/api/models/payments.ts

  • ⚠️ L244 Sequential independent awaits server-sequential-independent-await
  • ⚠️ L290 await inside a loop async-await-in-loop
  • ⚠️ L932 await inside a loop async-await-in-loop

src/pages/shop-page.tsx

  • ⚠️ L40 React function has high control-flow complexity no-high-complexity-react-function

Reviewed by React Doctor for commit 9b3a4f9. See inline comments for fixes.

if (!locked) return { after: row, transition: null };

const transition = await this.writeCheckoutState(tx, locked, state);
const [after] = await tx

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Doctor · react-doctor/server-sequential-independent-await (warning)

This awaited initializer does not read the previous result. If the operations are independent asynchronous work, they may overlap; await syntax alone does not establish a speedup.

Fix → Consider Promise.all([...]) only for independent asynchronous work. Shared queues or synchronous work may not benefit. Preserve resource limits, transaction ordering, and failure/cancellation semantics.

Docs

for (const { from, payment, to } of transitions) {
if (to === "pending" || from === to) continue;

await this.c.get("events").emit(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Doctor · react-doctor/async-await-in-loop (warning)

This for…of loop waits before starting the next iteration. If iterations perform independent asynchronous work, consider bounded concurrency; await syntax alone does not establish a speedup.

Fix → Consider concurrent calls only for independent asynchronous work. Shared queues or synchronous work may not benefit. Preserve resource limits, transaction ordering, and failure/cancellation semantics; observe all callback promises.

Docs

try {
if (!row.checkoutId) {
if (row.createdAt.getTime() < now - ORPHAN_ATTEMPT_MS) {
await this.expireUnstarted(row, "checkout_not_created");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Doctor · react-doctor/async-await-in-loop (warning)

This for…of loop waits before starting the next iteration. If iterations perform independent asynchronous work, consider bounded concurrency; await syntax alone does not establish a speedup.

Fix → Consider concurrent calls only for independent asynchronous work. Shared queues or synchronous work may not benefit. Preserve resource limits, transaction ordering, and failure/cancellation semantics; observe all callback promises.

Docs

}),
});

const ShopPage = () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Doctor · react-doctor/no-high-complexity-react-function (warning)

ShopPage has cyclomatic complexity 13, cognitive complexity 16, and maximum nesting depth 4, so its React logic is hard to understand and change. Extract independent branches into components or hooks.

Fix → Extract independent render branches and state logic into focused components or hooks until the control flow is easy to follow.

Docs

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-28T18:49:20.167832Z 9b3a4f9 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9b3a4f9c44

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +467 to +471
if (isOpenPaymentStatus(after.status)) {
throw new PaymentError("payment_in_progress");
}

return null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Stop replacement when the existing checkout has succeeded

If a customer finishes paying before the webhook arrives, then requests another checkout with a different provider or less than five minutes remaining, expireCheckout can return succeeded (the Stripe adapter explicitly retrieves sessions that cannot be expired). This branch then returns null, causing createCheckout to create another payable attempt for the same reference. The demo's already_owned check does not prevent this because it reads attempts before this synchronization. Reject replacement when the synchronized payment has succeeded, rather than allowing the customer to be charged again for the same purchase.

Useful? React with 👍 / 👎.

export const PAYMENT_DEFAULT_CHECKOUT_MINUTES = 60;
/** Stripe accepts 30 minutes to 24 hours; staying under 23 hours keeps the whole attempt inside its 24-hour idempotency window. */
export const PAYMENT_CHECKOUT_MINUTES_RANGE = {
min: 30,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Leave a safety margin above Stripe's minimum checkout lifetime

When checkoutExpiresInMinutes is configured as 30 (or clamped to 30), the expiration is calculated before inserting the payment and contacting Stripe, then rounded down to whole seconds by the adapter. Stripe therefore receives an expiration less than 30 minutes after session creation whenever that work crosses a second boundary, and rejects the checkout. Stripe's API reference requires at least 30 minutes from Checkout Session creation. Raise the effective minimum to include a safety margin so this documented configuration can create checkouts reliably.

Useful? React with 👍 / 👎.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

💡 Feature A new feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants