Skip to content

Phase 5: USD currency + Stripe prepaid ad billing - #27

Open
denrod25-del wants to merge 15 commits into
mainfrom
feat/usd-billing
Open

Phase 5: USD currency + Stripe prepaid ad billing#27
denrod25-del wants to merge 15 commits into
mainfrom
feat/usd-billing

Conversation

@denrod25-del

@denrod25-del denrod25-del commented Aug 24, 2026

Copy link
Copy Markdown
Owner

Summary

The ad platform can now take money. Advertisers prepay a balance by card, each click deducts their bid, and ads stop serving at zero.

Currency migration (Plan A). The ad platform priced everything in £ despite being US-operated — harmless while the numbers were decorative, painful once Stripe is charging real cards. Added a single formatUsd(cents) helper and moved the five ad-platform display sites onto it. The QuoteIQ CRM's £ pricing is deliberately untouched — different product, not this PR's call.

Billing (Plan B). Prepaid wallet model:

  • advertisers.balance_cents is a cached figure, always derivable from a new append-only billing_transactions ledger (signed amounts, balance_after_cents snapshot, unique stripe_session_id)
  • Top-ups go through Stripe's hosted Checkout; card data never touches the server
  • A signature-verified webhook is the sole source of truth for crediting, so an interrupted redirect can never mean "money taken, balance not credited"
  • Clicks debit synchronously; selectAds gates on a positive balance
  • /advertise/billing shows balance, top-up ($25/$50/$100 + custom, $10–$500), and full transaction history
  • Low-balance and out-of-funds states on the dashboard; balance column in the admin advertisers list

Why prepaid rather than postpaid

No debt, no dunning, no failed-payment retries, no collections — and an advertiser cannot overspend, which caps the platform's exposure too. Postpaid is the grown-up model but it is a lot of failure-mode machinery for a platform with no advertisers yet, and the ledger makes migrating later straightforward.

Money-safety properties (reviewed and tested)

  • Idempotent credit — Stripe retries webhooks; a replay cannot move the balance. Concurrent replays lose the unique-constraint race and roll back atomically.
  • Signature verified before any write — HMAC-SHA256 over the raw body with a 5-minute tolerance. Without it, anyone could POST themselves free balance.
  • Amount comes from Stripe's payload, never the client. createTopUpSession re-validates bounds server-side regardless.
  • Integer cents end to end — no float arithmetic touches stored money.
  • The click redirect always fires, even if the billing write throws. A failed charge is our problem, not the visitor's.

Test Plan

  • 117 tests, tsc clean, lint clean, i18n complete (en + fr)
  • Ledger: atomicity, duplicate-session rejection, cached balance equals ledger sum
  • Webhook: valid signature credits once; invalid signature credits nothing; replay credits once; unpaid/other events ignored
  • Serving: zero-balance advertiser's approved+active ad does not serve
  • Stripe test mode first — card 4242 4242 4242 4242; confirm balance credits within seconds, and that resending the event from the Stripe dashboard does not double-credit
  • Then live keys

Deploy steps (manual, before this can take real money)

  1. Stripe API key in the VPS .env.local as STRIPE_SECRET_KEY (start with sk_test_...)
  2. Stripe dashboard → Webhooks → add https://bsymbolic.com/api/stripe/webhook for checkout.session.completed → copy the signing secret into STRIPE_WEBHOOK_SECRET
  3. Rebuild + pm2 restart symbolic --update-env

The migration (0015_youthful_gressill.sql) applies automatically via the deploy workflow. With no Stripe key set, top-ups fall back to a simulated link and no money moves.

Known debt, deliberately not fixed here

The bid field is still named bidPounds internally (AdWizard.tsx, adActions.ts). It predates this branch; renaming touches a Zod schema and its tests for zero user-visible benefit, so it stays as-is rather than risking the bid path for tidiness.

🤖 Generated with Claude Code

Greptile Summary

The PR adds USD formatting and a prepaid Stripe wallet backed by an append-only billing ledger, then gates ad selection and debits advertisers for clicks.

  • Adds Stripe Checkout creation, signature-verified webhook processing, and advertiser top-up controls.
  • Adds cached advertiser balances and transaction history through a new migration and ledger library.
  • Adds balance-aware serving, dashboard/admin balance displays, and USD formatting across the ad platform.

Confidence Score: 2/5

The PR is not safe to merge until failed webhook credits remain retryable and the public click endpoint cannot be replayed to drain advertiser balances.

Genuine top-up database failures are acknowledged as successful with no recovery path, while the newly monetized public click URL permits unrestricted repeated debits and negative balances; the transaction descriptions also need non-blocking localization cleanup.

Files Needing Attention: src/app/api/stripe/webhook/route.ts, src/app/api/ads/click/route.ts, src/libs/billing.ts

Security Review

The public click redirect now performs an unrestricted monetary debit. Because requests carry no authentication, impression token, idempotency key, deduplication, or sufficient-balance predicate, replaying an active ad's URL can repeatedly charge its advertiser and drive the balance arbitrarily negative. How this was verified: The rendered URL exposes the ad ID, and tracing the unauthenticated GET through chargeForClick reaches an unconditional balance decrement with no intervening guard.

Important Files Changed

Filename Overview
src/app/api/stripe/webhook/route.ts Adds verified Stripe webhook credits, but acknowledges genuine database failures and can permanently lose paid top-ups.
src/app/api/ads/click/route.ts Adds per-request billing to a public replayable redirect without eligibility, idempotency, or balance-floor protections.
src/libs/billing.ts Adds atomic ledger credits and debits; credit idempotency is present, but click debits remain unconditional.
src/libs/payments.ts Adds bounded Stripe Checkout top-up sessions, with return URLs currently fixed to the only configured locale.
src/libs/ads.ts Joins advertisers and excludes zero-balance ads during selection, but this serving-time gate cannot protect the later public debit endpoint.
migrations/0015_youthful_gressill.sql Adds the cached balance and append-only transaction schema with Stripe-session uniqueness and foreign keys.
src/app/[locale]/(portal)/advertise/billing/page.tsx Adds the balance, top-up, and transaction-history UI; persisted transaction descriptions bypass localization.

Sequence Diagram

sequenceDiagram
  participant A as Advertiser
  participant S as Server
  participant Stripe
  participant DB as Billing ledger
  participant V as Ad visitor

  A->>S: Request top-up
  S->>Stripe: Create Checkout session
  Stripe-->>A: Hosted checkout
  Stripe->>S: Signed checkout.session.completed
  S->>DB: Credit balance and append ledger row
  S-->>Stripe: Processing response
  S->>DB: Select ads with positive balance
  V->>S: "GET /api/ads/click?id=..."
  S->>DB: Record click and debit bid
  S-->>V: Redirect to advertiser URL
Loading

Fix all with Greploop Fix All in Cursor Fix All in Claude Code Fix All in Codex Fix All in Conductor

Reviews (1): Last reviewed commit: "fix: translate billing errors and acknow..." | Re-trigger Greptile

Greptile also left 3 inline comments on this PR.

Context used:

  • Context used - AGENTS.md (source)

@coderabbitai

coderabbitai Bot commented Aug 24, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 24e83e57-9178-47f1-bae9-9b9d6ad54e6c


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.

Comment on lines +82 to +85
} catch {
// A concurrent replay of the same event lost the unique-constraint race.
// The balance is already correct, so acknowledge rather than make Stripe retry.
}

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 Failed credits are acknowledged

When creditTopUp fails because of a transient database or ledger error, this unrestricted catch still returns HTTP 200. Stripe therefore stops retrying the paid event, permanently omitting the customer's top-up from both the balance and ledger.

Fix in Cursor Fix in Claude Code Fix in Codex Fix in Conductor

Comment on lines +26 to +33
if (ad.advertiserId) {
await chargeForClick({
advertiserId: ad.advertiserId,
amountCents: ad.bidAmount,
adId: ad.id,
description: `Click on "${ad.title}"`,
});
}

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 security Public click replay drains balances

The public GET endpoint charges every request for any active ad without an impression token, idempotency guard, approval check, or sufficient-balance predicate. Replaying an exposed ad ID therefore creates unlimited charge rows and can drive the advertiser's prepaid balance arbitrarily negative. How this was verified: The rendered ad URL exposes the ID, and the unauthenticated route reaches an unconditional balance decrement with no intervening guard.

Fix in Cursor Fix in Claude Code Fix in Codex Fix in Conductor

advertiserId: ad.advertiserId,
amountCents: ad.bidAmount,
adId: ad.id,
description: `Click on "${ad.title}"`,

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 Ledger descriptions bypass localization

This English click description, along with the hard-coded Top-up description, is persisted and rendered verbatim on the localized billing page. Translators therefore cannot localize transaction history when another locale is enabled.

Context Used: AGENTS.md (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Cursor Fix in Claude Code Fix in Codex Fix in Conductor

@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: d7ae77e6ab

ℹ️ 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 +82 to +85
} catch {
// A concurrent replay of the same event lost the unique-constraint race.
// The balance is already correct, so acknowledge rather than make Stripe retry.
}

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 Retry transient credit failures

When creditTopUp throws because of a transient database outage, deadlock, migration mismatch, or any error other than the expected duplicate-session race, this catch still returns HTTP 200. Stripe therefore considers the paid event delivered and does not retry it, permanently leaving the customer charged without receiving balance. Only the verified duplicate constraint case should be acknowledged; other failures need to propagate or return a non-2xx response.

Useful? React with 👍 / 👎.

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