Repository navigation
feat(payments): ✨ hosted-checkout payments, Stripe adapter and demo plugin - #906
aXenDeveloper wants to merge 5 commits into
Conversation
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
|
React Doctor found 4 new issues in 2 files · 4 warnings · score 91 / 100 (Great) · 0 fixed · vs 4 warnings
Reviewed by React Doctor for commit |
| if (!locked) return { after: row, transition: null }; | ||
|
|
||
| const transition = await this.writeCheckoutState(tx, locked, state); | ||
| const [after] = await tx |
There was a problem hiding this comment.
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.
| for (const { from, payment, to } of transitions) { | ||
| if (to === "pending" || from === to) continue; | ||
|
|
||
| await this.c.get("events").emit( |
There was a problem hiding this comment.
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.
| try { | ||
| if (!row.checkoutId) { | ||
| if (row.createdAt.getTime() < now - ORPHAN_ATTEMPT_MS) { | ||
| await this.expireUnstarted(row, "checkout_not_created"); |
There was a problem hiding this comment.
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.
| }), | ||
| }); | ||
|
|
||
| const ShopPage = () => { |
There was a problem hiding this comment.
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.
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 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".
| if (isOpenPaymentStatus(after.status)) { | ||
| throw new PaymentError("payment_in_progress"); | ||
| } | ||
|
|
||
| return null; |
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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 👍 / 👎.
Improving Documentation
pnpm lint:fixwas run before opening this PR; it changed nothing beyond this diff.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)PaymentProviderApiPlugincontract (api/lib/payments/provider.ts), configured aspayments: { providers: [...] }. Several instances run at once, each with a uniqueid; missing, malformed or duplicate ids stop the API at startup. With no providers the rest of VitNode works and payment calls fail withprovider_not_found.core_paymentsandcore_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 answersduplicateand a database error answers500(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.pending → processing → succeeded | failedandpending → succeeded | failed | expired. Final statuses never change, so late or duplicate events cannot undo a success.reconcile-paymentscron job (every 5 min) re-reads quiet open payments and expires attempts whose checkout was never created.payment.processing,payment.succeeded,payment.failed,payment.expiredare emitted after the commit.FakePaymentAdapterfor tests; it refuses to start whenNODE_ENV=production.lastRunstored 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 inpaymentmode on SDK 23.0.1, pinned to API2026-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+unpaidmaps toprocessing; async success and failure and expiry are handled. Errors are mapped tounavailable/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 storedsucceededpayment, which survives crashes and can't be granted twice; thepayment.succeededlistener only logs. Registered inapps/webandapps/api; Stripe is added only when its keys are set, and the fake provider only in development withPAYMENTS_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
duplicate, no console errors, no horizontal scroll at 390px. The screenshots in the docs come from this run.tscclean, 9,863 unit tests pass. The web production build passes (MDX included) andtscis clean. The new docs pages render, images load, internal links resolve.db generatereports no drift afterwards.Not verified
stripe listen: the session that built this had no access to Stripe or keys. Stripe behaviour was checked against the SDK's generated types becausedocs.stripe.comwas blocked.@in/api/@vitnode/core/payments/webhooks/{id}. It is valid in a URL path;%40does not route.rateLimiterCategoriesFor's test table, the payments service, the Stripe adapter mapping and the cron claim.Generated by Claude Code