Repository navigation
feat(payments): ✨ Payments foundation with a Stripe adapter - #845
aXenDeveloper wants to merge 13 commits into
Conversation
|
React Doctor found 10 new issues in 5 files · 10 warnings · score 90 / 100 (Great) · 0 fixed · vs 10 warnings
Reviewed by React Doctor for commit |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d02c7d14c5
ℹ️ 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".
| const provider = providerForRow(c, purchase); | ||
| const checkout = await latestCheckout(c, purchase.id); | ||
|
|
||
| if (checkout?.externalId && checkout.status === "open") { |
There was a problem hiding this comment.
Serialize cancellation with checkout creation
When cancellation races a provider create call, the latest attempt is still creating and has no externalId, so this branch is skipped and the purchase is marked canceled. The in-flight openCheckout call can then store the newly created session and return its URL without rechecking the purchase; the original tab redirects to a payable checkout after another tab reported a successful cancellation, and a resulting webhook promotes the canceled purchase to paid. Coordinate cancellation with creating/unknown attempts and recheck the purchase before exposing a newly returned checkout URL.
Useful? React with 👍 / 👎.
| const manage = () => { | ||
| portal.mutate(undefined, { |
There was a problem hiding this comment.
Open the portal for the subscription's provider
In a multi-provider installation, each subscription's Manage button is shown based on subscription.provider, but portal.mutate(undefined) omits that provider and the server therefore uses defaultProvider. For subscriptions purchased through another provider—or after changing the default—this either returns no_billing_account or opens an unrelated provider portal, preventing the user from managing the displayed subscription.
Useful? React with 👍 / 👎.
| async () => | ||
| await Promise.all([ | ||
| stripe.paymentIntents.retrieve(paymentId), | ||
| stripe.refunds.list({ limit: 100, payment_intent: paymentId }), |
There was a problem hiding this comment.
Page through all refunds before computing totals
For a payment with more than 100 partial refund records, Stripe's list response is paginated and this reads only the first page. amountRefunded and the normalized refund list are then incomplete, so core can leave a fully refunded purchase marked partial and omit fulfillment callbacks for refunds outside that page. Follow pagination until has_more is false before calculating the total.
Useful? React with 👍 / 👎.
| .from(core_payments_purchases) | ||
| .where(eq(core_payments_purchases.disputeStatus, "open")), |
There was a problem hiding this comment.
Include invoice disputes in the attention count
When a renewal payment is disputed, syncPayment stores the dispute status on core_payments_invoices, not on the originating purchase. This overview counts only purchase rows, so disputesOpen can remain zero for every recurring-payment dispute even though those invoice records require administrator attention; include open invoice disputes in the total as well.
Useful? React with 👍 / 👎.
…ion and schema - Integer minor-unit Money with ISO 4217 exponents and Intl formatting - Separate payment, fulfillment, refund, checkout and subscription states - Narrow provider contract with normalized results and errors - Typed offer registration collected from the whole module tree - Optional, validated `payments` API config and public projection - Billing tables: customers, purchases, checkouts, subscriptions, invoices, adjustments, webhook inbox and fulfillments Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S1hrAnTnFPh8ph9hjsPhW5
… Stripe adapter - @vitnode/stripe: hosted Checkout, subscriptions, Customer Portal and raw-body webhook verification, pinned to API version 2026-09-30.endive - Authenticated, idempotent checkout: server-derived prices, per-offer lock, provider calls outside transactions with per-attempt idempotency keys - Webhook inbox: verify, store and queue in one transaction, ack after that - Queue-driven processing that re-reads provider state; monotonic statuses - Fulfillment rows unique per business effect; handlers run in a locked tx - Refund and dispute sync attributed to purchases or recurring invoices - Bounded reconciliation cron and re-queueing of stuck work - Example plugin: lifetime pass and Pro plan offers with an access table - Conditional Stripe config in both API deployment modes, opt-in in-process cron for the embedded API, migrations for core and example tables Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S1hrAnTnFPh8ph9hjsPhW5
… and reconciliation Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S1hrAnTnFPh8ph9hjsPhW5
…ests - Overview with per-currency totals, attention counters and worker health - Purchase and subscription lists with status, currency, provider and period filters; purchase detail with checkouts, fulfillments, refunds, disputes and invoices; webhook inbox list - Idempotent retry of failed fulfillments and webhook events (can_retry) - Tests: ownership, no-store headers, server-derived prices, refresh on return, portal isolation, staff permissions, browser-safe boundaries Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S1hrAnTnFPh8ph9hjsPhW5
…the example page - /settings/billing: purchases and subscriptions with server-side paging, per-currency formatting and Customer Portal access (shown when enabled) - AdminCP Payments: overview with per-currency totals and worker health, purchases and subscriptions with currency/provider/period filters, webhook inbox with retry, purchase detail with fulfillment retry - Shared money formatter, state badges, checkout and portal hooks and a bounded, backing-off return poller in @vitnode/core/tanstack/payments - /example/payments: lifetime pass and monthly/yearly Pro plan with server-checked features, currency choice separate from the UI language and graceful handling when Payments is disabled Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S1hrAnTnFPh8ph9hjsPhW5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S1hrAnTnFPh8ph9hjsPhW5
…, tutorials and reference - Overview of what core, the provider and a plugin own, and current scope - API-only configuration for both deployment modes, env vars, currencies and the queue worker - Stripe setup: test keys, webhook forwarding, the exact event list, Customer Portal settings, test clocks and test/live separation - One-time and subscription tutorials built from the example plugin - Reliability explanation, provider/API reference and troubleshooting - Payment events in the built-in events reference - Screenshots captured from the running app Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S1hrAnTnFPh8ph9hjsPhW5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S1hrAnTnFPh8ph9hjsPhW5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S1hrAnTnFPh8ph9hjsPhW5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S1hrAnTnFPh8ph9hjsPhW5
- Close a checkout that is about to expire at the provider before opening a new one, and ignore late expired/failed events from a superseded session. - Keep the original payment when a second checkout for a purchase was also paid, and flag it for a refund. - Scope the billing customer idempotency key to the install's web origin. - Never let an older subscription read overwrite a newer one, never revive an ended subscription, and flag a second provider subscription for the same purchase instead of overwriting the first. - Lock the purchase before the subscription, fulfillment or invoice row in every writer. - Never move a succeeded refund back to pending; only a failed refund may lower the refunded total. - Throttle buyer-triggered refreshes with an atomic row claim (refreshCheckedAt) so it holds across instances without Redis. - Require checkoutExpiresInMinutes of at least 45. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S1hrAnTnFPh8ph9hjsPhW5
- Regenerate the core and example payments migrations after canary's newest migration, so the snapshot chain stays linear. - Allow a null queue id from QueueModel.dispatch in the webhook inbox. - Move @vitnode/stripe from ESLint to oxlint like the other packages. - Add the payments flag to the new middleware config test fixtures. - Keep the opt-in Postgres test helper the payments integration tests use; they skip unless VITNODE_TEST_POSTGRES_URL is set. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S1hrAnTnFPh8ph9hjsPhW5
d02c7d1 to
d988768
Compare
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: d9887686f7
ℹ️ 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".
| lastError: "Abandoned: the provider never confirmed this checkout.", | ||
| status: "failed", |
There was a problem hiding this comment.
Keep uncertain attempts exclusive until they expire
When the provider creates a checkout but its response is lost, retrying after replayWindowMs but before the original expiresAt reaches this branch, labels the unknown attempt failed, and creates another attempt with a new idempotency key. The provider's first session can still be open—for the minimum 45-minute lifetime this can happen after 10 minutes—so the buyer receives a second payable session and can be charged twice. Continue replaying the original key, or prevent a replacement until the uncertain session can no longer be paid.
Useful? React with 👍 / 👎.
| externalCustomerId: state.customerId, | ||
| externalPriceId: state.priceId ?? before?.externalPriceId ?? null, |
There was a problem hiding this comment.
Persist subscription terms changed through the portal
When a provider portal allows a subscriber to switch price, interval, or currency, retrieve() returns the new terms but the update path stores only the new price ID; amount, currency, and interval are populated only on insert. Consequently billing keeps displaying the original terms, and because changedForPlugin also ignores these fields, onSubscriptionChanged is not rerun for plugins whose entitlement depends on the selected interval or price.
Useful? React with 👍 / 👎.
| code: string, | ||
| message: string, | ||
| extra: Record<string, unknown> = {}, | ||
| ): HTTPException => |
There was a problem hiding this comment.
Return translatable payment error keys
Every payment rejection serialized here sends an English message, and PaymentsRequestError surfaces that message directly in checkout and billing toasts. In a non-English UI, failures such as disabled payments, checkout conflicts, or portal errors therefore appear in English; return a stable i18n key/code and translate it with use-intl in the web client instead.
AGENTS.md reference: AGENTS.md:L62-L62
Useful? React with 👍 / 👎.
…ders - An empty `payments.providers` list turns Payments off instead of failing at startup, so apps can register providers only when their keys exist. - With Payments off, Settings -> Billing, AdminCP -> Payments and plugin payment pages answer 404, and their navigation entries are hidden. `requirePaymentsEnabled` lets a plugin page do the same. - Admin navigation items can name a feature they need (`feature`). - Checkout, offers and billing portal hooks accept an optional `provider`; Manage billing opens the portal of the subscription's own provider. - Docs: new Providers section (overview with multiple providers, Stripe setup with install and options), screenshots converted to WebP. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S1hrAnTnFPh8ph9hjsPhW5
| for (const purchase of due) { | ||
| try { | ||
| const provider = providerForRow(c, purchase); | ||
| const refreshed = await refreshPurchase(c, provider, purchase); |
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.
|
|
||
| for (const subscription of due) { | ||
| try { | ||
| await syncSubscription( |
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.
| .returning({ id: core_payments_webhook_events.id }); | ||
|
|
||
| for (const { id } of fulfillments) { | ||
| await c.get("queue").dispatch({ |
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.
| } | ||
|
|
||
| for (const { id } of events) { | ||
| await c.get("queue").dispatch({ |
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.
| .where(eq(core_payments_subscriptions.id, subscription.id)) | ||
| .for("update"); | ||
|
|
||
| const [existing] = 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 adjustment of adjustments) { | ||
| const [before] = await tx |
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.
|
|
||
| if (purchase.mode === "one_time") { | ||
| for (const refund of newRefunds) { | ||
| await scheduleFulfillment(c, tx, { |
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.
| } from "./config"; | ||
| import { definePaymentOffer } from "./offer"; | ||
|
|
||
| const SECRET = "sk_test_must_never_leave_the_server"; |
There was a problem hiding this comment.
React Doctor · react-doctor/no-secrets-in-client-code (warning)
This hardcoded secret is a security vulnerability: it ships to the browser where anyone can read it.
Fix → Move secrets to server-only code. In TanStack Start, only VITE_* env vars are exposed to the browser, and they must not contain secrets
| </CardHeader> | ||
| <CardContent className="overflow-x-auto"> | ||
| <Table> | ||
| <TableHeader> |
There was a problem hiding this comment.
React Doctor · react-doctor/duplicate-jsx-subtree (warning)
2 copies of this 6-node JSX tree appear across 2 files, repeating about 8 lines. Composition path: PurchaseDetailContent > div > Card > CardContent > Table > TableHeader.
Fix → Consider extracting a shared component if these trees represent the same UI concept. Keep them separate when the resemblance is incidental or the variants are likely to evolve independently.
| ); | ||
| }; | ||
|
|
||
| const PaymentsPage = ({ |
There was a problem hiding this comment.
React Doctor · react-doctor/no-high-complexity-react-function (warning)
PaymentsPage has cyclomatic complexity 30, cognitive complexity 31, and maximum nesting depth 2, 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.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c2e17a2a94
ℹ️ 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".
| status: "failed", | ||
| }) | ||
| .where(eq(core_payments_fulfillments.id, fulfillment.id)); |
There was a problem hiding this comment.
Preserve a concurrently completed fulfillment
When two workers execute the same fulfillment—possible after a queue lease expires or reconciliation requeues work—one handler can fail while the other succeeds after waiting for the row lock. The successful worker commits completedGeneration and status: "completed", but the failing worker then calls this unconditional update outside its rolled-back transaction and overwrites the row and purchase as failed. Since the generation is already complete, the AdminCP retry is no longer eligible, leaving a successfully granted purchase permanently reported as failed; condition the failure write on the generation still being incomplete.
Useful? React with 👍 / 👎.
| portal.mutate( | ||
| { | ||
| offerId: EXAMPLE_OFFERS.plan, | ||
| pluginId: CONFIG_PLUGIN.pluginId, | ||
| }, |
There was a problem hiding this comment.
Pass the subscription provider to the offer portal
When this plan's existing subscription belongs to a non-default provider, or the installation's default provider changes after purchase, this Manage action still omits provider and createPortalSession consequently selects the current default. The billing screen now passes subscription.provider, but this separate offer-page flow still opens an unrelated portal or returns no_billing_account; carry the provider from the subscription selected by usePlanSubscription into this mutation.
Useful? React with 👍 / 👎.
Description
What?
This PR adds the first complete version of Payments to VitNode.
Core (
@vitnode/core/payments)vitnode.api.config.ts(payments?: PaymentsConfig) and validated at startup. It is on when at least one provider is registered.paymentskey or an emptyproviderslist, Payments is off:/example/payments) answer 404, and their menu entries are hidden.requirePaymentsEnabled(queryClient)lets a plugin page do the same.featurethey need.defaultProviderpicks the one checkouts use. The checkout, offers and portal hooks accept an optionalprovider, and Manage billing opens the portal of the subscription's own provider.GET /payments/settings, which never includes a secret.Intl.NumberFormat, and the display currency is separate from the UI language.PaymentProvidercontract with normalized types and error kinds. Core does not import the Stripe SDK.definePaymentOfferon a module'spaymentOffers: one-time, ormonth/yearsubscriptions with one price per currency.unknownand can be replayed safely.200. Processing reads the current state back from the provider, and statuses only move forward.grant,refund:<id>,subscription) and runs inside a locked transaction, so it happens exactly once in the database.built-in-events.mdx.@vitnode/stripe(the first provider)2026-09-30.endive.STRIPE_SECRET_KEYis set.UI
payments: can_view/can_retry./example/paymentsin@vitnode/examplehas a lifetime pass and a monthly/yearly Pro plan. Its features are protected on the server, and it supports several currencies.Docs
docs/dev/payments/*: overview, configuration, Providers (overview with multiple providers, plus Stripe setup), one-time and subscription tutorials, reliability, reference and troubleshooting.Migrations
20261011150917_add_core_paymentsand20261011150922_add_example_payments, regenerated after canary's newest migration.The self-review commit fixes these problems:
expiredevent from an old session is ignored, and a checkout that was paid twice is flagged.Test setup
src/tests/postgres.tsand the CI Postgres service. This PR keeps that small helper because the payments tests need real transactions, row locks and advisory locks, whichmemory-dbcannot imitate.VITNODE_TEST_POSTGRES_URLis set, so CI does not run them. Run them withVITNODE_TEST_POSTGRES_URL=postgresql://root:root@localhost:5432/postgres npx vitest run src/api/models/payments src/api/modules/paymentsinpackages/vitnode.Why?
This is the payments foundation plugins can build on, without the shop, SaaS, coupons, wallet, trials, proration, tax or invoicing (all out of scope).
Validation
pnpm testpasses: core 8,768 passed and 65 skipped (the Postgres payments tests), stripe 29, example 10, blog 35, create-vitnode-app 134.pnpm test:typespasses (405).VITNODE_TEST_POSTGRES_URLset: 136 of 136.pnpm lint:fix,pnpm lintandpnpm build(including the docs) pass.vitnode migrateworks on a fresh database. Applying the two payments migrations on top of canary's schema keeps the existing rows.drizzle-kit generatereports no drift."enabled": false;/settings/billing,/example/paymentsand/admin/core/paymentsanswer 404; and neither Settings nor the AdminCP sidebar shows a payments entry.Not verified: Stripe's API is blocked by this environment's network and there were no credentials, so no real Stripe checkout, webhook or payment was made. The Stripe adapter is covered by unit tests against recorded API shapes only. Please run the test-mode flow in
docs/dev/payments/providers/stripebefore release.🤖 Generated with Claude Code
https://claude.ai/code/session_01S1hrAnTnFPh8ph9hjsPhW5