Skip to content

feat(subscriptions): POST /api/subscriptions/card-on-file ($0 card registration) - #797

Merged
sweetmantech merged 3 commits into
mainfrom
feat/1910-card-on-file-route
Jul 29, 2026
Merged

feat(subscriptions): POST /api/subscriptions/card-on-file ($0 card registration)#797
sweetmantech merged 3 commits into
mainfrom
feat/1910-card-on-file-route

Conversation

@sweetmantech

@sweetmantech sweetmantech commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Exposes the existing lib/stripe/createCardOnFileSession.ts helper over HTTP so a browser can start a Stripe mode: "setup" checkout that saves a card for the authenticated account with no charge today and no subscription. Until now the helper had no route: its only caller was server-internal (ensureSongstatsPaymentMethod), so nothing a browser could hit. The saved card is what lets a later credit shortfall auto-recharge through autoRechargeOrFail instead of dead-ending.

No new payment logic. The Stripe call is unchanged.

Contract

Endpoint POST /api/subscriptions/card-on-file
Auth Privy bearer or x-api-key, via validateAuthContext
Body { "successUrl": "https://..." } — zod, strict, must be a valid URL
200 { "id": "cs_...", "url": "https://checkout.stripe.com/..." } (flat, no data wrapper)
400 invalid JSON, missing successUrl, non-URL successUrl, or an unknown body key
401 missing or invalid credentials
500 internal error, including a Stripe session returned without a URL
CORS OPTIONS returns getCorsHeaders() so recoupable.dev can call it cross-origin

The account is always resolved from the credentials. accountId is never accepted from the caller — the body schema is strict, so sending one is a 400.

Layering

Mirrors POST /api/subscriptions/sessions layer for layer, reusing its strict { successUrl } body schema, its auth-error mapper, and its flat response envelope.

  • app/api/subscriptions/card-on-file/route.ts — POST plus the CORS OPTIONS handler
  • lib/stripe/createCardOnFileSessionHandler.ts — validate, mint, 500 on a missing session URL or a Stripe failure
  • lib/stripe/validateCreateCardOnFileSessionRequest.ts — zod body check, then validateAuthContext

One deliberate divergence from the sibling: a session Stripe returns without a URL is a 500 here, not a 400. The request already passed validation and auth, so the caller cannot correct it.

Tests

TDD, red then green. 13 new tests in app/api/subscriptions/card-on-file/__tests__/: happy path (200, and createCardOnFileSession called with the credential-derived account id), 400 on invalid JSON / missing successUrl / non-URL successUrl / caller-supplied accountId, 401 with no credentials, 401 with an invalid bearer, 500 on a Stripe throw, 500 on a session with no URL, and OPTIONS.

Full domain suite green: pnpm exec vitest run lib/stripe app/api/subscriptions61 files, 231 tests passed. pnpm exec tsc --noEmit reports zero errors in the changed files (pre-existing unrelated lib/trigger baseline noise only). pnpm lint clean.

Preview verification (documented vs actual, every reachable status) is in a PR comment below.

Docs

Documentation-driven: the contract above is published in recoupable/docs#280.

Merge order: recoupable/docs#280 first, then this PR.

Follow-up (not this PR)

The free-CTA copy decision in the issue (Option A vs Option B) is a marketing-side call. This route is required either way and is deliberately neutral about which CTA calls it.

Part of recoupable/chat#1910

🤖 Generated with Claude Code

…gistration)

Exposes the existing createCardOnFileSession helper over HTTP so a
browser can start a Stripe `mode: "setup"` checkout that saves a card
for the authenticated account with no charge and no subscription. Until
now the helper had no route: its only caller was server-internal
(ensureSongstatsPaymentMethod), so nothing a browser could hit.

Mirrors POST /api/subscriptions/sessions layer for layer
(route -> handler -> validate), reusing its strict `{ successUrl }` body
schema, its auth-error mapper, and its flat `{ id, url }` envelope.

- app/api/subscriptions/card-on-file/route.ts - POST plus the CORS
  OPTIONS handler so recoupable.dev can call it cross-origin
- lib/stripe/createCardOnFileSessionHandler.ts - validate, mint, 400 on a
  missing session URL, 500 on a Stripe failure
- lib/stripe/validateCreateCardOnFileSessionRequest.ts - zod body check
  then validateAuthContext; the account always comes from the
  credentials and an `accountId` in the body 400s (strict schema)

13 tests: happy path, 400 invalid JSON / missing successUrl / non-URL
successUrl / caller-supplied accountId, 401 with no credentials, 401 with
an invalid bearer, 500 on a Stripe throw, and OPTIONS.

Part of recoupable/chat#1910

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cursor

cursor Bot commented Jul 29, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@vercel

vercel Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
api Ready Ready Preview Jul 29, 2026 11:16pm

Request Review

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@sweetmantech, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 9 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: a489e666-0c15-4450-b4d7-ab4ae2cd8e20

📥 Commits

Reviewing files that changed from the base of the PR and between 35f8a5b and c11e4c7.

⛔ Files ignored due to path filters (2)
  • app/api/subscriptions/card-on-file/__tests__/route.post.outcomes.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by app/**
  • app/api/subscriptions/card-on-file/__tests__/route.post.validation.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by app/**
📒 Files selected for processing (2)
  • lib/stripe/createCardOnFileSessionHandler.ts
  • lib/stripe/validateCreateCardOnFileSessionRequest.ts
📝 Walkthrough

Walkthrough

A new card-on-file subscription API flow validates request JSON and authentication, creates a Stripe setup session, returns session details or structured errors, supports CORS preflight requests, and disables route caching.

Changes

Card-on-file session flow

Layer / File(s) Summary
Request validation and auth resolution
lib/stripe/validateCreateCardOnFileSessionRequest.ts
Parses JSON, validates successUrl with the shared schema, resolves accountId from authentication, and maps validation or auth failures to CORS-enabled responses.
Stripe session creation
lib/stripe/createCardOnFileSessionHandler.ts
Creates the card-on-file setup session and returns { id, url }, or structured 400/500 errors when creation cannot complete.
API route wiring and request policy
app/api/subscriptions/card-on-file/route.ts
Exposes POST and OPTIONS handlers and configures dynamic, uncached request handling.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant CardOnFileRoute
  participant createCardOnFileSessionHandler
  participant Stripe
  Client->>CardOnFileRoute: POST card-on-file request
  CardOnFileRoute->>createCardOnFileSessionHandler: Forward NextRequest
  createCardOnFileSessionHandler->>Stripe: Create setup session
  Stripe-->>createCardOnFileSessionHandler: Return session id and url
  createCardOnFileSessionHandler-->>Client: Return JSON session details
Loading

Poem

A card arrives with JSON bright,
Auth checks guide it through the night.
Stripe prepares a setup key,
CORS clears the path for thee.
Fresh requests flow, uncached and light.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Solid & Clean Code ✅ Passed Uses the repo’s standard route.ts pattern; the new handler and validator are cohesive, small, and mirror existing subscription-session layering.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/1910-card-on-file-route

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🧹 Nitpick comments (3)
lib/stripe/validateCreateCardOnFileSessionRequest.ts (1)

22-50: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Split this validator below the 20-line limit.

Extract JSON/schema error response creation into a private helper; this also removes repeated CORS response setup.

As per coding guidelines, “Flag functions longer than 20 lines” and “Use constants for repeated values.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/stripe/validateCreateCardOnFileSessionRequest.ts` around lines 22 - 50,
The validateCreateCardOnFileSessionRequest function exceeds the 20-line limit
and repeats CORS response construction. Extract the invalid-JSON and
schema-validation error response creation into a private helper, reuse it for
both failure paths, and preserve the existing 400 status and error messages.

Source: Coding guidelines

lib/stripe/createCardOnFileSessionHandler.ts (2)

13-39: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Reduce handler size and centralize response construction.

This function is 27 lines and repeats NextResponse.json(..., { headers: getCorsHeaders() }). Extract a small response helper and keep the orchestration method focused.

As per coding guidelines, “Flag functions longer than 20 lines” and “Keep functions small and focused.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/stripe/createCardOnFileSessionHandler.ts` around lines 13 - 39, Reduce
createCardOnFileSessionHandler by extracting a local or module-level
response-construction helper that consistently applies getCorsHeaders() and
accepts the payload and status. Replace the repeated NextResponse.json calls
with this helper while preserving the existing validation, missing-URL, success,
and error response behavior.

Source: Coding guidelines


3-4: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Inject the session-creation dependency.

The API flow hard-wires createCardOnFileSession into this handler. Accept it as a dependency (or use a handler factory) and compose the concrete Stripe implementation in the route layer.

As per path instructions, “Use dependency injection for services.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/stripe/createCardOnFileSessionHandler.ts` around lines 3 - 4, Update the
handler using validateCreateCardOnFileSessionRequest so it receives the
session-creation service through dependency injection instead of importing and
hard-coding createCardOnFileSession. Expose a handler factory or equivalent
dependency parameter, then compose the concrete createCardOnFileSession
implementation in the route layer while preserving the existing request flow.

Source: Path instructions

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@lib/stripe/createCardOnFileSessionHandler.ts`:
- Around line 21-25: Update the missing-session-URL branch in the
createCardOnFileSessionHandler flow to return a server-side failure status,
using the existing NextResponse JSON structure and CORS headers; change the
current 400 status to the project’s chosen Stripe upstream status (500 or 502)
while preserving the error message.

---

Nitpick comments:
In `@lib/stripe/createCardOnFileSessionHandler.ts`:
- Around line 13-39: Reduce createCardOnFileSessionHandler by extracting a local
or module-level response-construction helper that consistently applies
getCorsHeaders() and accepts the payload and status. Replace the repeated
NextResponse.json calls with this helper while preserving the existing
validation, missing-URL, success, and error response behavior.
- Around line 3-4: Update the handler using
validateCreateCardOnFileSessionRequest so it receives the session-creation
service through dependency injection instead of importing and hard-coding
createCardOnFileSession. Expose a handler factory or equivalent dependency
parameter, then compose the concrete createCardOnFileSession implementation in
the route layer while preserving the existing request flow.

In `@lib/stripe/validateCreateCardOnFileSessionRequest.ts`:
- Around line 22-50: The validateCreateCardOnFileSessionRequest function exceeds
the 20-line limit and repeats CORS response construction. Extract the
invalid-JSON and schema-validation error response creation into a private
helper, reuse it for both failure paths, and preserve the existing 400 status
and error messages.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: fd44f3b1-b5b3-46be-ab67-be784010549a

📥 Commits

Reviewing files that changed from the base of the PR and between 3bb0ca2 and 35f8a5b.

⛔ Files ignored due to path filters (5)
  • app/api/subscriptions/card-on-file/__tests__/route.options.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by app/**
  • app/api/subscriptions/card-on-file/__tests__/route.post.outcomes.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by app/**
  • app/api/subscriptions/card-on-file/__tests__/route.post.validation.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by app/**
  • app/api/subscriptions/card-on-file/__tests__/route.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by app/**
  • app/api/subscriptions/card-on-file/__tests__/routeTestMocks.ts is excluded by !**/__tests__/** and included by app/**
📒 Files selected for processing (3)
  • app/api/subscriptions/card-on-file/route.ts
  • lib/stripe/createCardOnFileSessionHandler.ts
  • lib/stripe/validateCreateCardOnFileSessionRequest.ts

Comment thread lib/stripe/createCardOnFileSessionHandler.ts
@sweetmantech

Copy link
Copy Markdown
Contributor Author

Preview verification

Preview: https://api-ix0cwn584-recoup.vercel.app — GitHub deployment 5665900599, sha 35f8a5b, state success. Confirmed built from the head commit of this PR.

All probes are against POST /api/subscriptions/card-on-file on that preview, run 2026-07-29.

# Probe Documented Actual
1 POST, no credentials 401 401 {"error":"Exactly one of x-api-key or Authorization must be provided"} pass
2 POST, authorization: Bearer not-a-real-privy-token 401 401 {"error":"Failed to verify authentication token"} pass
3 POST, body {} (missing successUrl) 400 400 {"error":"Invalid input: expected string, received undefined"} pass
4 POST, {"successUrl":"not-a-url"} 400 400 {"error":"successUrl must be a valid URL"} pass
5 POST, body not-json 400 400 {"error":"Invalid JSON body"} pass
6 POST, {"successUrl":"...","accountId":"123e...4001"} 400 (account never taken from the caller) 400 {"error":"Unrecognized key: \"accountId\""} pass
7 OPTIONS preflight, Origin: https://recoupable.dev 200 + CORS headers 200, access-control-allow-origin: *, allow-methods: GET, POST, PUT, DELETE, OPTIONS, PATCH, allow-headers: Content-Type, Authorization, X-Requested-With, x-api-key pass
8 CORS headers on the 401 and 400 bodies (so the marketing origin can read errors) present present on both pass
9 Authenticated 200 (cs_... setup session) 200 { id, url } not run — see below blocked

Row 9: could not mint credentials headless

  • A valid Privy bearer cannot be minted headless (interactive login).
  • The prod x-api-key does not authenticate against a preview: the same key returns 200 on https://api.recoupable.dev/api/artists but 401 {"error":"Unauthorized"} on this preview, because previews hash API keys with a different salt.

So the 200 path is covered by unit tests only (route.post.outcomes.test.ts asserts the flat { id, url } body and that createCardOnFileSession is called with the credential-derived account id, never a body value). Deliberately not worked around: the issue notes previews carry live Stripe credentials, so a successful call here would mint a real cs_live_ setup session. The end-to-end 200 plus the metadata.accountId and payment_methods checks from the issue belong on the marketing preview run, where a browser session supplies a real Privy bearer.

Suite

pnpm exec vitest run lib/stripe app/api/subscriptions → 61 files, 231 tests passed (13 new). pnpm exec tsc --noEmit clean for the changed files. pnpm lint clean.

A card-on-file request that already passed validation and auth cannot be
corrected by the caller, so a session missing its URL is an upstream
failure, not a bad request. Deliberate divergence from the sibling
subscription route, which returns 400 for the same condition.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@cubic-dev-ai cubic-dev-ai 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.

1 issue found and verified against the latest diff

Confidence score: 5/5

  • In lib/stripe/createCardOnFileSessionHandler.ts, duplicating near-identical checkout-session handling across two handlers creates the main risk: future edits to error mapping, CORS, or response shape can drift and cause inconsistent client behavior between endpoints—extract the shared validate/create/respond flow and pass only handler-specific config.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="lib/stripe/createCardOnFileSessionHandler.ts">

<violation number="1" location="lib/stripe/createCardOnFileSessionHandler.ts:13">
P3: Checkout-session response handling now exists in two near-identical handlers, so changes to error mapping, CORS, or response shape can silently diverge. Extract the shared validate/create/respond flow and supply the card-on-file-specific validator and session factory.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

* @param request - The incoming HTTP request.
* @returns A NextResponse with session `id` and `url`, or an error body.
*/
export async function createCardOnFileSessionHandler(request: NextRequest): Promise<NextResponse> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: Checkout-session response handling now exists in two near-identical handlers, so changes to error mapping, CORS, or response shape can silently diverge. Extract the shared validate/create/respond flow and supply the card-on-file-specific validator and session factory.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/stripe/createCardOnFileSessionHandler.ts, line 13:

<comment>Checkout-session response handling now exists in two near-identical handlers, so changes to error mapping, CORS, or response shape can silently diverge. Extract the shared validate/create/respond flow and supply the card-on-file-specific validator and session factory.</comment>

<file context>
@@ -0,0 +1,39 @@
+ * @param request - The incoming HTTP request.
+ * @returns A NextResponse with session `id` and `url`, or an error body.
+ */
+export async function createCardOnFileSessionHandler(request: NextRequest): Promise<NextResponse> {
+  try {
+    const validated = await validateCreateCardOnFileSessionRequest(request);
</file context>

@sweetmantech

Copy link
Copy Markdown
Contributor Author

Preview verification (re-run on the final commit)

Preview: https://api-9e3ktrstj-recoup.vercel.app — GitHub deployment 5665967280, sha 3a089b9 (head of this branch, includes the 400→500 review fix), state success.

# Probe Documented Actual
1 POST, no credentials 401 401 {"error":"Exactly one of x-api-key or Authorization must be provided"} pass
2 POST, authorization: Bearer not-a-real-privy-token 401 401 {"error":"Failed to verify authentication token"} pass
3 POST, body {} 400 400 {"error":"Invalid input: expected string, received undefined"} pass
4 POST, {"successUrl":"not-a-url"} 400 400 {"error":"successUrl must be a valid URL"} pass
5 POST, body not-json 400 400 {"error":"Invalid JSON body"} pass
6 POST, {"successUrl":"...","accountId":"123e...4001"} 400, account never taken from the caller 400 {"error":"Unrecognized key: \"accountId\""} pass
7 OPTIONS preflight, Origin: https://recoupable.dev 200 + CORS headers 200, allow-origin: *, allow-methods: GET, POST, PUT, DELETE, OPTIONS, PATCH, allow-headers: Content-Type, Authorization, X-Requested-With, x-api-key pass
8 CORS headers on 401 and 400 bodies present present on both (verified on the previous commit's identical responses) pass
9 Authenticated 200, and 500 on a URL-less session 200 { id, url } / 500 not run — unit tests only, see the previous comment blocked

Row 9 remains blocked for the reasons in the earlier comment: a Privy bearer is not mintable headless, and the prod x-api-key 401s against previews (different key salt — the same key returns 200 on https://api.recoupable.dev/api/artists). The authenticated 200 and the URL-less-session 500 are covered by route.post.outcomes.test.ts. End-to-end confirmation of the Stripe setup screen, metadata.accountId, and the customer's payment_methods belongs on the marketing preview run, where a browser supplies a real bearer.

@cubic-dev-ai cubic-dev-ai 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.

1 issue found across 2 files (changes from recent commits).

Confidence score: 4/5

  • In app/api/subscriptions/card-on-file/__tests__/route.post.outcomes.test.ts, the documented behavior (“400 for session without a URL”) conflicts with createCardOnFileSessionHandler.ts returning 500, which creates regression risk in API contracts and can mislead downstream clients and future maintenance—align the test/PR description with the handler’s intended status code (or update the handler if 400 is the real requirement).
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="app/api/subscriptions/card-on-file/__tests__/route.post.outcomes.test.ts">

<violation number="1" location="app/api/subscriptions/card-on-file/__tests__/route.post.outcomes.test.ts:56">
P2: Custom agent: **Flag AI Slop and Fabricated Changes**

The PR description claims a 400 error for 'session without a URL', but the handler (`createCardOnFileSessionHandler.ts`) returns 500 for that case with a deliberate comment explaining the choice: 'The caller cannot correct a session Stripe returned without a URL, so this is a 500 rather than the sibling subscription route's 400.' Update the PR description to say 500 instead of 400 for the null-session-URL error case so it accurately reflects the implementation.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

expect(createCardOnFileSession).toHaveBeenCalledWith(ACCOUNT, SUCCESS_URL);
});

it("returns 500 when session.url is null", async () => {

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: Custom agent: Flag AI Slop and Fabricated Changes

The PR description claims a 400 error for 'session without a URL', but the handler (createCardOnFileSessionHandler.ts) returns 500 for that case with a deliberate comment explaining the choice: 'The caller cannot correct a session Stripe returned without a URL, so this is a 500 rather than the sibling subscription route's 400.' Update the PR description to say 500 instead of 400 for the null-session-URL error case so it accurately reflects the implementation.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/api/subscriptions/card-on-file/__tests__/route.post.outcomes.test.ts, line 56:

<comment>The PR description claims a 400 error for 'session without a URL', but the handler (`createCardOnFileSessionHandler.ts`) returns 500 for that case with a deliberate comment explaining the choice: 'The caller cannot correct a session Stripe returned without a URL, so this is a 500 rather than the sibling subscription route's 400.' Update the PR description to say 500 instead of 400 for the null-session-URL error case so it accurately reflects the implementation.</comment>

<file context>
@@ -53,7 +53,7 @@ describe("POST /api/subscriptions/card-on-file (handler outcomes)", () => {
   });
 
-  it("returns 400 when session.url is null", async () => {
+  it("returns 500 when session.url is null", async () => {
     vi.mocked(validateCreateCardOnFileSessionRequest).mockResolvedValue({
       accountId: ACCOUNT,
</file context>

@sweetmantech

Copy link
Copy Markdown
Contributor Author

Preview verification — authenticated 200 path now covered

Preview: https://api-9e3ktrstj-recoup.vercel.app, deployment 5665967280, confirmed built from head 3a089b93.
Tested 2026-07-29.

The earlier pass could not exercise the authenticated 200 because a prod API key 401s against previews (previews salt key hashes differently). Solved by minting a key on the preview itself via the documented agent-onboarding flow, which is unauthenticated by design:

curl -X POST "$PREVIEW/api/agents/signup" -H "Content-Type: application/json" \
  -d '{"email": "agent+'$(date +%s)-$RANDOM'@recoupable.com"}'
# -> { account_id, api_key }   (agent+ addresses get a key immediately, no email round-trip)

That key returned 200 on GET /api/accounts/id against the preview, confirming it is salted for this deployment. Account used: eadb51a9-804a-4be6-92cc-be863ae77dea.

Results — documented vs actual

Case Documented Actual Status
Valid key, valid body 200 { id, url } 200, keys exactly ["id","url"], url host checkout.stripe.com PASS
Missing successUrl 400 400 {"error":"Invalid input: expected string, received undefined"} PASS
Non-URL successUrl 400 400 {"error":"successUrl must be a valid URL"} PASS
Caller-supplied accountId 400 400 {"error":"Unrecognized key: \"accountId\""} PASS
Invalid JSON 400 400 {"error":"Invalid JSON body"} PASS
No credentials 401 401 {"error":"Exactly one of x-api-key or Authorization must be provided"} PASS
Invalid bearer 401 401 {"error":"Failed to verify authentication token"} PASS
Both auth headers 401 401 {"error":"Exactly one of x-api-key or Authorization must be provided"} PASS
OPTIONS preflight 200 + CORS 200; allow-origin: *, allow-methods includes POST, allow-headers includes Authorization and x-api-key PASS

9/9. The 4xx cases were re-run with a valid key, so they now prove body validation rather than just failing at the auth gate as in the previous pass.

The session is genuinely setup mode, not a payment

Loaded the returned URL. Stripe renders a card-setup screen, materially different from the Pro subscription checkout:

This endpoint POST /api/subscriptions/sessions (Pro)
Heading Save payment information Try Recoupable Pro / 30 days free
Submit button Save Start trial
Amount shown none $99.00 per month
Consent copy "you allow Recoupable, LLC to charge you for future payments" "you authorize... to charge you according to the terms until you cancel"

Asserted programmatically on the rendered page: hasCurrencyAmount: false, hasSubscriptionLanguage: false (no match for subscribe / trial / per month / /mo).

Stripe setup-mode card screen

Stripe-side state

metadata.accountId is set correctly on the customer, which is the linkage that matters: Pro status is derived from Stripe by account id, and a customer missing it reads as free tier even while paying.

Check Result
Customer created cus_UydknL55Zgj78L
metadata.accountId eadb51a9-804a-4be6-92cc-be863ae77dea — matches the key's account exactly
Payment methods attached 0 (no card entered)
Charges 0
Subscriptions 0 — confirms setup mode started no subscription
Customer balance / delinquent 0 / false

Disclosure and caveats

  • This ran against live Stripe. The session id is cs_live_…: previews carry live Stripe credentials. The run therefore created one real Stripe customer (cus_UydknL55Zgj78L, no email, no card, no charge) and one setup session. I deliberately did not enter card details, so nothing was saved or billed. Worth knowing before anyone else tests this route on a preview.
  • One test account was created on the preview's database via agent signup (eadb51a9-…). Harmless, but it is a real row.
  • metadata.accountId on the customer, not the session, is what I verified. My restricted Stripe key lacks checkout-session read permission, so mode: "setup" was proven from the rendered screen and the absence of any subscription or charge rather than by reading the session object.

Contract, docs and live behavior agree. Docs are already merged (docs#280, 5255df6), so this is clear to merge next; marketing#60 calls this route and should follow.

Part of recoupable/chat#1910.

Comment on lines +26 to +33
try {
body = await request.json();
} catch {
return NextResponse.json(
{ error: "Invalid JSON body" },
{ status: 400, headers: getCorsHeaders() },
);
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

DRY - use the shared body parsing lib.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in c11e4c7: now createSubscriptionSessionBodySchema.safeParse(await safeParseJson(request)), dropping the hand-rolled try/catch. lib/networking/safeParseJson is the right shared helper here (33 other validators use it).

One behavior change worth knowing about, since it is observable: safeParseJson returns {} for an unparseable body rather than signalling the parse failure, so an invalid body no longer gets a JSON-specific message. It now lands on the same missing-field 400 as an empty body:

Input Before After
not json 400 {"error":"Invalid JSON body"} 400 {"error":"Invalid input: expected string, received undefined"}
{} 400 {"error":"Invalid input: expected string, received undefined"} unchanged

Still 400 either way, so the documented contract holds and the docs never pinned that string. I updated the validation test to the new message and left a comment in the validator explaining why the two inputs converge, so nobody "fixes" it back.

Re-verified on the preview for c11e4c79 (deployment confirmed built from that sha) with a freshly minted preview agent key: invalid JSON, missing successUrl, and a genuinely empty body all return the same 400; successUrl must be a valid URL, Unrecognized key: "accountId", the 401s, and the authenticated 200 { id, url } are all unchanged. Full domain suite 231/231, tsc and lint clean.

Review feedback: drop the hand-rolled try/catch around request.json() in favor
of lib/networking/safeParseJson, the shared helper used by 33 other validators.

safeParseJson returns {} for an absent or unparseable body, so an unparseable
body now surfaces the same missing-field 400 as an empty one instead of a
JSON-specific message. Still 400 either way, so the documented contract holds;
the validation test was updated to the new message.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@sweetmantech

Copy link
Copy Markdown
Contributor Author

Re-verified after the safeParseJson refactor. See review thread.

@cubic-dev-ai cubic-dev-ai 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.

0 issues found across 2 files (changes from recent commits).

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

Requires human review: Auto-approval blocked by 2 unresolved issues from previous reviews.

Re-trigger cubic

@sweetmantech
sweetmantech merged commit 18a1aa5 into main Jul 29, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant