From e281aef3f6bc28233494330ea4468b2b61eb1d52 Mon Sep 17 00:00:00 2001 From: denrod25-del Date: Mon, 24 Aug 2026 16:28:50 -0400 Subject: [PATCH 01/15] docs: add ad billing design spec for Phase 5 --- .../specs/2026-08-24-ad-billing-design.md | 185 ++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-24-ad-billing-design.md diff --git a/docs/superpowers/specs/2026-08-24-ad-billing-design.md b/docs/superpowers/specs/2026-08-24-ad-billing-design.md new file mode 100644 index 0000000..ae70161 --- /dev/null +++ b/docs/superpowers/specs/2026-08-24-ad-billing-design.md @@ -0,0 +1,185 @@ +# Ad Billing Design (Phase 5) + +**Date:** 2026-08-24 +**Status:** Approved + +## Goal + +Let the Symbolic ad platform take money. Advertisers prepay a balance by card, +each ad click deducts their bid, and ads stop serving when the balance reaches +zero. Every movement is recorded in an append-only ledger. + +## Decisions + +| Decision | Choice | Why | +|---|---|---| +| Billing model | Prepaid balance (wallet) | Fits the existing per-click bid model; no debt, no dunning, no collections; advertiser cannot overspend | +| Currency | **USD** (migrated from GBP display) | US-based operator and advertisers; fix before real money moves | +| Top-up amounts | $25 / $50 / $100 presets + custom, min $10, max $500 | Below $10, Stripe's 2.9% + $0.30 eats roughly 9% | +| Debit timing | Synchronous at click time, with ledger | Accurate and immediate; one extra write on a request that already writes | +| Payment confirmation | Stripe webhook is source of truth | An interrupted redirect must never mean "money taken, balance not credited" | + +## Scope + +**In scope:** + +- `balanceCents` on advertisers; `billing_transactions` append-only ledger +- Top-up via Stripe Checkout (hosted); webhook credits the balance +- Click charges deducted at click time +- `selectAds` gates on positive balance +- `/advertise/billing` page: balance, top-up, transaction history +- Low-balance and out-of-funds states in the dashboard and ads list +- Balance column in the admin advertisers list +- Currency migration GBP to USD via a shared `formatUsd` helper + +**Out of scope (deferred):** + +- Auto-recharge / saved cards (that is most of postpaid's complexity) +- Emailed receipts (needs an email provider wired up) +- Admin-issued refunds and credits (schema supports it via `kind: 'adjustment'`) +- Impression billing, daily budgets, per-campaign spend caps + +## Data model + +`advertisers` gains: + +| Column | Type | Notes | +|---|---|---| +| `balanceCents` | integer notNull default 0 | Cached; always derivable from the ledger | + +New table `billing_transactions` (append-only; never updated or deleted): + +| Column | Type | Notes | +|---|---|---| +| `id` | serial PK | | +| `advertiserId` | integer FK to advertisers.id, notNull | | +| `kind` | text notNull | `topup` / `click_charge` / `adjustment` | +| `amountCents` | integer notNull | Signed: `+2500` top-up, `-50` click | +| `balanceAfterCents` | integer notNull | Snapshot for audit and drift detection | +| `adId` | integer FK to ads.id, nullable | Set on `click_charge` | +| `stripeSessionId` | text nullable, **unique** | Set on `topup`; the idempotency key | +| `description` | text notNull | Human-readable, e.g. "Top-up" | +| `createdAt` | timestamp notNull default now | | + +All money is integer cents. No floats. `ads.bidAmount` is already integer minor +units and does not change; only its display symbol changes. + +## Payment flow + +**Top-up** + +1. Advertiser picks a preset or custom amount on `/advertise/billing` +2. `createTopUpSession(amountCents)` server action validates the amount + (minimum 1000 cents, maximum 50000 cents) and creates a Stripe Checkout + Session, extending the existing provider pattern in `src/libs/payments.ts` + (raw `fetch`, form-encoded, no SDK dependency) +3. Session carries `client_reference_id` = advertiser id, + `metadata.advertiser_id`, `success_url` = `/advertise/billing?topup=pending`, + `cancel_url` = `/advertise/billing` +4. Advertiser pays on Stripe's hosted page; card data never touches our server +5. Stripe POSTs `checkout.session.completed` to `/api/stripe/webhook` + +**Webhook** — `src/app/api/stripe/webhook/route.ts` + +- Verifies the `Stripe-Signature` header via HMAC-SHA256 over + `timestamp + "." + rawBody` against `STRIPE_WEBHOOK_SECRET`, with a 5-minute + timestamp tolerance. Invalid signature returns 400 and writes nothing. +- Reads the **raw body** with `await request.text()`. Parsing and reserializing + the JSON breaks signature verification. +- Handles only `checkout.session.completed` where `payment_status` is `paid`. + Every other event returns 200 and does nothing. +- Credits in one DB transaction: insert the `topup` ledger row (carrying + `stripeSessionId`), then increment `advertisers.balanceCents`. +- A duplicate `stripeSessionId` (a Stripe retry) violates the unique index; + swallow it and return 200. The balance is credited exactly once. +- Unknown advertiser: log, return 200, write nothing. + +**Click charge** — `src/app/api/ads/click/route.ts` (already exists) + +- In one DB transaction: insert a `click_charge` row for `-ad.bidAmount` and + decrement `advertisers.balanceCents`. +- **The 307 redirect happens regardless.** A billing failure is logged and the + click goes unbilled; it never blocks the visitor. + +**Serving gate** + +- `selectAds` joins `advertisers` and adds `balanceCents > 0` to the existing + `status = 'approved'` and `active = true` conditions. No separate pause job + and no state to keep in sync. + +## UI + +**`/advertise/billing`** (new page; "Billing" added to the portal nav) + +- Balance card: large amount; amber under $5; red at $0 with the text + "Your ads are paused - add funds to resume." +- Top-up row: `$25`, `$50`, `$100` buttons plus a custom-amount input +- When the URL carries `?topup=pending`: "Payment received - your balance will + update in a few seconds", with a Refresh button. Honest about webhook latency + rather than pretending the credit is instant. +- Transaction history table: date, description, signed amount (green positive, + red negative), balance after. 50 most recent, newest first. Empty state before + any activity. + +**Advertiser dashboard** — a balance stat alongside the existing ones, plus a +warning banner under $5 or at $0 linking to the billing page. + +**Ads list** — at zero balance the status badges show a "Paused - no funds" +treatment, so it is clear why nothing is serving. + +**Admin advertisers list** — a read-only Balance column. + +**All amounts** render through a single `formatUsd(cents)` helper in +`src/utils/Money.ts`, replacing the scattered `(x / 100).toFixed(2)` calls. + +## Error handling + +| Failure | Behaviour | +|---|---| +| Stripe API down when creating a session | Action returns an error object; the page shows "Couldn't start checkout." No money moved. | +| Advertiser abandons checkout | Nothing written | +| Invalid webhook signature | 400, nothing written (blocks forged credit) | +| Stripe retries a webhook | Unique `stripeSessionId` rejects the duplicate; return 200 | +| Webhook for an unknown advertiser | Log, return 200, no write | +| Click-charge write fails | Visitor still redirected; error logged; click unbilled | +| Concurrent clicks dip the balance negative | Accepted; maximum exposure is one bid; serving stops immediately after | +| `STRIPE_SECRET_KEY` unset | The existing stub provider returns a simulated link, so local dev works without Stripe | + +## Testing + +Real PGLite with mocked `fetch`, matching every prior phase. + +- `billing.test.ts` — ledger insert and balance update are atomic; + `balanceAfterCents` is correct; a duplicate `stripeSessionId` is rejected; + the cached balance equals the sum of ledger amounts +- `stripeWebhook.test.ts` — a valid signature credits once; an **invalid + signature credits nothing**; a replayed event credits once, not twice; + non-completed and unpaid events are ignored +- `adClick` — a click both records and charges; a charge failure still redirects +- `ads.test.ts` — extend: a zero-balance advertiser's approved and active ad + does not serve +- `Money.test.ts` — `formatUsd` for cents, zero, negative, and thousands + +## Ops and manual setup + +1. Stripe account, then an API key (start with `sk_test_...`) +2. Stripe dashboard, Webhooks, add endpoint + `https://bsymbolic.com/api/stripe/webhook` for `checkout.session.completed`, + then copy the signing secret +3. Add `STRIPE_SECRET_KEY` and `STRIPE_WEBHOOK_SECRET` to the VPS `.env.local`, + rebuild, restart +4. Apply the new migration (the deploy workflow runs migrations automatically) +5. Verify in Stripe **test mode** with card `4242 4242 4242 4242` before going + live + +## Implementation split + +Two plans come from this one spec: + +- **Plan A, currency migration:** the `formatUsd` helper plus the six GBP + display sites. Small, independent, ships on its own. +- **Plan B, billing system:** schema, ledger, Stripe session, webhook, click + charging, serving gate, billing page, dashboard and admin surfaces. + +Plan A lands first so the display change is not tangled up with payment logic +during review. From 8e83c35c9d2653f7be0a89c42ad6dc49f3a38258 Mon Sep 17 00:00:00 2001 From: denrod25-del Date: Mon, 24 Aug 2026 16:37:31 -0400 Subject: [PATCH 02/15] docs: add currency migration and ad billing implementation plans --- .../plans/2026-08-24-ad-billing.md | 1578 +++++++++++++++++ .../plans/2026-08-24-currency-usd.md | 366 ++++ 2 files changed, 1944 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-24-ad-billing.md create mode 100644 docs/superpowers/plans/2026-08-24-currency-usd.md diff --git a/docs/superpowers/plans/2026-08-24-ad-billing.md b/docs/superpowers/plans/2026-08-24-ad-billing.md new file mode 100644 index 0000000..3f4e3e0 --- /dev/null +++ b/docs/superpowers/plans/2026-08-24-ad-billing.md @@ -0,0 +1,1578 @@ +# Ad Billing System (Phase 5, Plan B) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Advertisers prepay a balance via Stripe Checkout, each ad click deducts their bid through an append-only ledger, and ads stop serving at zero balance. + +**Architecture:** `advertisers.balanceCents` is a cached figure always derivable from a new append-only `billing_transactions` ledger. Top-ups go through Stripe's hosted Checkout; a signature-verified webhook is the sole source of truth for crediting. Clicks debit synchronously inside the existing click route. `selectAds` gates on a positive balance. + +**Tech Stack:** Next.js 15 App Router, Drizzle ORM (PostgreSQL/PGLite), Stripe REST API via raw `fetch` (no SDK), Node `crypto` for webhook signature verification, Clerk, next-intl, Vitest. + +**Depends on:** Plan A (currency migration) — this plan uses `formatUsd` from `src/utils/Money.ts`. + +--- + +## Environment notes for the implementer + +- `bun` is NOT installed. Use `./node_modules/.bin/tsc --noEmit`, + `./node_modules/.bin/vitest run `, `npx ultracite check --type-aware --type-check`. +- **The Bash tool's cwd resets between commands** — start every command with + `cd /c/Users/skyea/claude/symbolic &&`. +- DB tests need PGLite running: `npx pglite-server -m 100 --db=local.db` in the + background, then `npx dotenv -c -- drizzle-kit migrate`. If `local.db` has a + stale `postmaster.pid` causing a WASM abort, delete `local.db` and re-migrate. +- `npm run db:generate` must be run from **Git Bash** — drizzle-kit's prompts + break under PowerShell. +- Lint rules that bite (learned the hard way in earlier phases): no `.then` + chains (use async/await); no nested ternaries (extract a helper); + `no-unsafe-type-assertion` (use type guards or a narrow eslint-disable); + `require-await` and `promise-function-async` can contradict each other — if + they do, make the function fully synchronous and return plain values, since + `await` works on non-promises. `ultracite` reformats via a pre-commit hook. +- All money is **integer cents**. Never use floats for money anywhere. + +## File map + +| File | Action | Purpose | +|---|---|---| +| `src/models/Schema.ts` | Modify | `balanceCents` column, `billingTransactions` table | +| `migrations/00XX_*.sql` | Create (generated) | The above | +| `src/libs/Env.ts`, `.env` | Modify | `STRIPE_WEBHOOK_SECRET` | +| `src/libs/billing.ts` | Create | Ledger writes: credit, charge, read balance/history | +| `src/libs/billing.test.ts` | Create | Ledger atomicity, idempotency, balance integrity | +| `src/libs/payments.ts` | Modify | Add `createTopUpLink` to the existing provider pattern | +| `src/libs/billingActions.ts` | Create | `'use server'`: `createTopUpSession` | +| `src/libs/stripeWebhook.ts` | Create | Signature verification (pure, testable) | +| `src/libs/stripeWebhook.test.ts` | Create | Signature tests | +| `src/app/api/stripe/webhook/route.ts` | Create | The webhook endpoint | +| `src/app/api/ads/click/route.ts` | Modify | Charge on click | +| `src/libs/ads.ts` | Modify | Gate `selectAds` on balance | +| `src/libs/ads.test.ts` | Modify | Zero-balance ad does not serve | +| `src/app/[locale]/(portal)/advertise/billing/page.tsx` | Create | Billing page | +| `src/app/[locale]/(portal)/advertise/billing/TopUpButtons.tsx` | Create | Client top-up UI | +| `src/app/[locale]/(portal)/advertise/layout.tsx` | Modify | "Billing" nav link | +| `src/app/[locale]/(portal)/advertise/dashboard/page.tsx` | Modify | Balance stat + warning | +| `src/app/[locale]/(admin)/admin/advertisers/page.tsx` | Modify | Balance column | +| `src/locales/en.json`, `fr.json` | Modify | `BillingPage` namespace + nav/dashboard keys | + +--- + +## Task 1: Schema, migration, and env + +**Files:** +- Modify: `src/models/Schema.ts` +- Modify: `src/libs/Env.ts`, `.env` +- Create: `migrations/00XX_*.sql` (generated) + +- [ ] **Step 1: Add the balance column** + +In `src/models/Schema.ts`, the `advertisers` table currently ends with +`createdAt`. Add one column so it reads: + +```ts +export const advertisers = pgTable('advertisers', { + id: serial('id').primaryKey(), + clerkUserId: text('clerk_user_id').notNull().unique(), + email: text('email').notNull(), + name: text('name').notNull(), + balanceCents: integer('balance_cents').notNull().default(0), + createdAt: timestamp('created_at', { mode: 'date' }).notNull().defaultNow(), +}); +``` + +- [ ] **Step 2: Add the ledger table** + +Append to `src/models/Schema.ts`: + +```ts +// Append-only money ledger. Rows are never updated or deleted; the cached +// advertisers.balanceCents is always derivable from SUM(amount_cents). +export const billingTransactions = pgTable('billing_transactions', { + id: serial('id').primaryKey(), + advertiserId: integer('advertiser_id') + .notNull() + .references(() => advertisers.id), + kind: text('kind').notNull(), + amountCents: integer('amount_cents').notNull(), + balanceAfterCents: integer('balance_after_cents').notNull(), + adId: integer('ad_id').references(() => ads.id), + stripeSessionId: text('stripe_session_id').unique(), + description: text('description').notNull(), + createdAt: timestamp('created_at').notNull().defaultNow(), +}); +``` + +- [ ] **Step 3: Generate and apply the migration** + +Run from **Git Bash**: + +```bash +cd /c/Users/skyea/claude/symbolic && npm run db:generate +``` + +Expected: a new `migrations/00XX_.sql` containing `ALTER TABLE "advertisers" ADD COLUMN "balance_cents"` and `CREATE TABLE "billing_transactions"`. No manual edits needed — the new column has a default, so existing rows are fine. + +Apply it (PGLite server must be running): + +```bash +cd /c/Users/skyea/claude/symbolic && npx dotenv -c -- drizzle-kit migrate +``` + +Expected: `migrations applied successfully!` + +- [ ] **Step 4: Add the webhook secret env var** + +In `src/libs/Env.ts`, add to the `server` block (next to the existing +`STRIPE_SECRET_KEY`): + +```ts + STRIPE_WEBHOOK_SECRET: z.string().optional(), +``` + +and to `runtimeEnv`: + +```ts + STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET, +``` + +Append to `.env`: + +``` +# Stripe webhook signing secret (whsec_...). Real value in .env.local / production. +STRIPE_WEBHOOK_SECRET=whsec_dev_placeholder +``` + +- [ ] **Step 5: Type-check and commit** + +```bash +cd /c/Users/skyea/claude/symbolic && ./node_modules/.bin/tsc --noEmit +``` + +Expected: no output. + +```bash +cd /c/Users/skyea/claude/symbolic && git add src/models/Schema.ts migrations/ src/libs/Env.ts .env && git commit -m "feat: add billing ledger schema and webhook secret" +``` + +--- + +## Task 2: The billing ledger library (TDD) + +**Files:** +- Create: `src/libs/billing.ts` +- Create: `src/libs/billing.test.ts` + +This is the heart of the system. Every money movement goes through here. + +- [ ] **Step 1: Write the failing test** + +Create `src/libs/billing.test.ts`: + +```ts +import { eq, sql } from 'drizzle-orm'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { advertisers, billingTransactions } from '@/models/Schema'; +import { + chargeForClick, + creditTopUp, + getBalanceCents, + listTransactions, +} from './billing'; +import { db } from './DB'; + +describe('billing', () => { + let advertiserId: number; + + beforeEach(async () => { + const [row] = await db + .insert(advertisers) + .values({ + clerkUserId: `billing_test_${crypto.randomUUID()}`, + email: 'billing@example.com', + name: 'Billing Test', + }) + .returning(); + advertiserId = row!.id; + }); + + afterEach(async () => { + await db + .delete(billingTransactions) + .where(eq(billingTransactions.advertiserId, advertiserId)); + await db.delete(advertisers).where(eq(advertisers.id, advertiserId)); + }); + + describe('creditTopUp', () => { + it('increases the balance and writes a ledger row', async () => { + await creditTopUp({ + advertiserId, + amountCents: 2500, + stripeSessionId: `cs_test_${crypto.randomUUID()}`, + }); + + expect(await getBalanceCents(advertiserId)).toBe(2500); + + const rows = await listTransactions(advertiserId, 10); + expect(rows).toHaveLength(1); + expect(rows[0]?.kind).toBe('topup'); + expect(rows[0]?.amountCents).toBe(2500); + expect(rows[0]?.balanceAfterCents).toBe(2500); + }); + + it('accumulates across multiple top-ups', async () => { + await creditTopUp({ + advertiserId, + amountCents: 2500, + stripeSessionId: `cs_a_${crypto.randomUUID()}`, + }); + await creditTopUp({ + advertiserId, + amountCents: 1000, + stripeSessionId: `cs_b_${crypto.randomUUID()}`, + }); + + expect(await getBalanceCents(advertiserId)).toBe(3500); + }); + + it('ignores a duplicate stripe session id', async () => { + const sessionId = `cs_dup_${crypto.randomUUID()}`; + + const first = await creditTopUp({ + advertiserId, + amountCents: 2500, + stripeSessionId: sessionId, + }); + const second = await creditTopUp({ + advertiserId, + amountCents: 2500, + stripeSessionId: sessionId, + }); + + expect(first).toBe('credited'); + expect(second).toBe('duplicate'); + expect(await getBalanceCents(advertiserId)).toBe(2500); + expect(await listTransactions(advertiserId, 10)).toHaveLength(1); + }); + }); + + describe('chargeForClick', () => { + it('decreases the balance and records a negative amount', async () => { + await creditTopUp({ + advertiserId, + amountCents: 1000, + stripeSessionId: `cs_c_${crypto.randomUUID()}`, + }); + + await chargeForClick({ + advertiserId, + amountCents: 50, + adId: null, + description: 'Click on Test ad', + }); + + expect(await getBalanceCents(advertiserId)).toBe(950); + + const rows = await listTransactions(advertiserId, 10); + expect(rows[0]?.kind).toBe('click_charge'); + expect(rows[0]?.amountCents).toBe(-50); + expect(rows[0]?.balanceAfterCents).toBe(950); + }); + }); + + it('keeps the cached balance equal to the ledger sum', async () => { + await creditTopUp({ + advertiserId, + amountCents: 2500, + stripeSessionId: `cs_d_${crypto.randomUUID()}`, + }); + await chargeForClick({ + advertiserId, + amountCents: 50, + adId: null, + description: 'Click', + }); + await chargeForClick({ + advertiserId, + amountCents: 75, + adId: null, + description: 'Click', + }); + + const [summed] = await db + .select({ total: sql`coalesce(sum(${billingTransactions.amountCents}), 0)`.mapWith(Number) }) + .from(billingTransactions) + .where(eq(billingTransactions.advertiserId, advertiserId)); + + expect(await getBalanceCents(advertiserId)).toBe(summed?.total); + expect(summed?.total).toBe(2375); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cd /c/Users/skyea/claude/symbolic && ./node_modules/.bin/vitest run src/libs/billing.test.ts` +Expected: FAIL — cannot find module `./billing`. + +- [ ] **Step 3: Write the implementation** + +Create `src/libs/billing.ts`: + +```ts +import { desc, eq, sql } from 'drizzle-orm'; +import { advertisers, billingTransactions } from '@/models/Schema'; +import { db } from './DB'; + +export type CreditTopUpInput = { + advertiserId: number; + amountCents: number; + stripeSessionId: string; +}; + +export type ChargeForClickInput = { + advertiserId: number; + amountCents: number; + adId: number | null; + description: string; +}; + +export type CreditResult = 'credited' | 'duplicate'; + +/** + * Returns an advertiser's cached balance in cents. + * @param advertiserId - The advertiser's database ID. + * @returns The balance in cents, or 0 when the advertiser is unknown. + */ +export async function getBalanceCents(advertiserId: number): Promise { + const [row] = await db + .select({ balance: advertisers.balanceCents }) + .from(advertisers) + .where(eq(advertisers.id, advertiserId)) + .limit(1); + return row?.balance ?? 0; +} + +/** + * Returns an advertiser's most recent ledger entries, newest first. + * @param advertiserId - The advertiser's database ID. + * @param limit - Maximum rows to return. + * @returns The matching ledger rows. + */ +export function listTransactions(advertiserId: number, limit: number) { + return db + .select() + .from(billingTransactions) + .where(eq(billingTransactions.advertiserId, advertiserId)) + .orderBy(desc(billingTransactions.createdAt), desc(billingTransactions.id)) + .limit(limit); +} + +/** + * Credits a completed Stripe top-up, keyed by session ID so that Stripe's + * webhook retries cannot double-credit. + * @param input - The advertiser, amount, and Stripe session ID. + * @returns `credited` on success, or `duplicate` when the session was already applied. + */ +export async function creditTopUp( + input: CreditTopUpInput +): Promise { + return db.transaction(async (tx) => { + const [updated] = await tx + .update(advertisers) + .set({ + balanceCents: sql`${advertisers.balanceCents} + ${input.amountCents}`, + }) + .where(eq(advertisers.id, input.advertiserId)) + .returning({ balance: advertisers.balanceCents }); + + if (!updated) { + throw new Error(`Unknown advertiser ${input.advertiserId}`); + } + + const inserted = await tx + .insert(billingTransactions) + .values({ + advertiserId: input.advertiserId, + kind: 'topup', + amountCents: input.amountCents, + balanceAfterCents: updated.balance, + stripeSessionId: input.stripeSessionId, + description: 'Top-up', + }) + .onConflictDoNothing({ target: billingTransactions.stripeSessionId }) + .returning({ id: billingTransactions.id }); + + if (inserted.length === 0) { + // Stripe replayed the webhook. Undo the balance bump and report it. + tx.rollback(); + } + + return 'credited'; + }).catch((error: unknown) => { + if (error instanceof Error && error.message.includes('rollback')) { + return 'duplicate' as const; + } + throw error; + }); +} + +/** + * Debits an advertiser for one ad click. + * @param input - The advertiser, amount, ad, and description. + * @returns Nothing; throws when the advertiser is unknown. + */ +export async function chargeForClick( + input: ChargeForClickInput +): Promise { + await db.transaction(async (tx) => { + const [updated] = await tx + .update(advertisers) + .set({ + balanceCents: sql`${advertisers.balanceCents} - ${input.amountCents}`, + }) + .where(eq(advertisers.id, input.advertiserId)) + .returning({ balance: advertisers.balanceCents }); + + if (!updated) { + throw new Error(`Unknown advertiser ${input.advertiserId}`); + } + + await tx.insert(billingTransactions).values({ + advertiserId: input.advertiserId, + kind: 'click_charge', + amountCents: -input.amountCents, + balanceAfterCents: updated.balance, + adId: input.adId, + description: input.description, + }); + }); +} +``` + +**Implementation note for the engineer:** Drizzle's `tx.rollback()` throws a +`TransactionRollbackError` to unwind the transaction. The `.catch` above turns +that into the `'duplicate'` return value. If the installed Drizzle version's +rollback error does not match `error.message.includes('rollback')`, inspect the +actual error (log `error.constructor.name` in a scratch run) and match on that +instead — the required behaviour is fixed by the tests: a duplicate session ID +must leave the balance unchanged and write no second row. An acceptable +alternative implementation is to check for an existing row with that +`stripeSessionId` *before* touching the balance, and return `'duplicate'` +early; the unique index still protects against the race. + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `cd /c/Users/skyea/claude/symbolic && ./node_modules/.bin/vitest run src/libs/billing.test.ts` +Expected: PASS — 6 tests. + +- [ ] **Step 5: Type-check, lint, commit** + +```bash +cd /c/Users/skyea/claude/symbolic && ./node_modules/.bin/tsc --noEmit && npx ultracite check --type-aware --type-check +``` + +If knip flags `billing.ts` exports as unused (consumers land in later tasks), +add `'src/libs/billing.ts'` to the `ignore` array in `knip.config.ts`. + +```bash +cd /c/Users/skyea/claude/symbolic && git add src/libs/billing.ts src/libs/billing.test.ts knip.config.ts && git commit -m "feat: add billing ledger with idempotent top-up credit" +``` + +--- + +## Task 3: Stripe webhook signature verification (TDD) + +**Files:** +- Create: `src/libs/stripeWebhook.ts` +- Create: `src/libs/stripeWebhook.test.ts` + +Kept as a pure function so it is testable without HTTP. + +- [ ] **Step 1: Write the failing test** + +Create `src/libs/stripeWebhook.test.ts`: + +```ts +import { createHmac } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import { verifyStripeSignature } from './stripeWebhook'; + +const SECRET = 'whsec_test_secret'; +const BODY = '{"id":"evt_1","type":"checkout.session.completed"}'; + +function sign(body: string, timestamp: number, secret = SECRET): string { + const signature = createHmac('sha256', secret) + .update(`${timestamp}.${body}`) + .digest('hex'); + return `t=${timestamp},v1=${signature}`; +} + +describe('verifyStripeSignature', () => { + it('accepts a correctly signed recent payload', () => { + const now = Math.floor(Date.now() / 1000); + expect( + verifyStripeSignature({ + body: BODY, + header: sign(BODY, now), + secret: SECRET, + nowSeconds: now, + }) + ).toBe(true); + }); + + it('rejects a payload signed with the wrong secret', () => { + const now = Math.floor(Date.now() / 1000); + expect( + verifyStripeSignature({ + body: BODY, + header: sign(BODY, now, 'whsec_wrong'), + secret: SECRET, + nowSeconds: now, + }) + ).toBe(false); + }); + + it('rejects a tampered body', () => { + const now = Math.floor(Date.now() / 1000); + expect( + verifyStripeSignature({ + body: '{"id":"evt_evil"}', + header: sign(BODY, now), + secret: SECRET, + nowSeconds: now, + }) + ).toBe(false); + }); + + it('rejects a timestamp older than the tolerance', () => { + const now = Math.floor(Date.now() / 1000); + const old = now - 600; + expect( + verifyStripeSignature({ + body: BODY, + header: sign(BODY, old), + secret: SECRET, + nowSeconds: now, + }) + ).toBe(false); + }); + + it('rejects a malformed header', () => { + const now = Math.floor(Date.now() / 1000); + expect( + verifyStripeSignature({ + body: BODY, + header: 'garbage', + secret: SECRET, + nowSeconds: now, + }) + ).toBe(false); + }); + + it('rejects an empty secret', () => { + const now = Math.floor(Date.now() / 1000); + expect( + verifyStripeSignature({ + body: BODY, + header: sign(BODY, now), + secret: '', + nowSeconds: now, + }) + ).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cd /c/Users/skyea/claude/symbolic && ./node_modules/.bin/vitest run src/libs/stripeWebhook.test.ts` +Expected: FAIL — cannot find module `./stripeWebhook`. + +- [ ] **Step 3: Write the implementation** + +Create `src/libs/stripeWebhook.ts`: + +```ts +import { createHmac, timingSafeEqual } from 'node:crypto'; + +const TOLERANCE_SECONDS = 300; + +export type VerifyInput = { + body: string; + header: string | null; + secret: string; + nowSeconds: number; +}; + +/** + * Parses Stripe's `Stripe-Signature` header into its timestamp and v1 digests. + * @param header - The raw header value. + * @returns The timestamp and signatures, or null when malformed. + */ +function parseHeader( + header: string +): { timestamp: number; signatures: string[] } | null { + let timestamp = Number.NaN; + const signatures: string[] = []; + + for (const part of header.split(',')) { + const [key, value] = part.split('='); + if (key === 't' && value) { + timestamp = Number(value); + } + if (key === 'v1' && value) { + signatures.push(value); + } + } + + if (!Number.isFinite(timestamp) || signatures.length === 0) { + return null; + } + return { timestamp, signatures }; +} + +/** + * Compares two hex digests without leaking timing information. + * @param a - First hex digest. + * @param b - Second hex digest. + * @returns True when the digests match. + */ +function safeEqualHex(a: string, b: string): boolean { + const left = Buffer.from(a, 'hex'); + const right = Buffer.from(b, 'hex'); + if (left.length === 0 || left.length !== right.length) { + return false; + } + return timingSafeEqual(left, right); +} + +/** + * Verifies a Stripe webhook signature over the raw request body. + * @param input - Raw body, signature header, signing secret, and current time. + * @returns True when the payload is authentic and recent. + */ +export function verifyStripeSignature(input: VerifyInput): boolean { + if (!(input.secret && input.header)) { + return false; + } + + const parsed = parseHeader(input.header); + if (!parsed) { + return false; + } + + if (Math.abs(input.nowSeconds - parsed.timestamp) > TOLERANCE_SECONDS) { + return false; + } + + const expected = createHmac('sha256', input.secret) + .update(`${parsed.timestamp}.${input.body}`) + .digest('hex'); + + return parsed.signatures.some((candidate) => + safeEqualHex(expected, candidate) + ); +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `cd /c/Users/skyea/claude/symbolic && ./node_modules/.bin/vitest run src/libs/stripeWebhook.test.ts` +Expected: PASS — 6 tests. + +- [ ] **Step 5: Type-check, lint, commit** + +```bash +cd /c/Users/skyea/claude/symbolic && ./node_modules/.bin/tsc --noEmit && git add src/libs/stripeWebhook.ts src/libs/stripeWebhook.test.ts knip.config.ts && git commit -m "feat: add stripe webhook signature verification" +``` + +(Include `knip.config.ts` only if you had to add an ignore entry.) + +--- + +## Task 4: Stripe Checkout session for top-ups + +**Files:** +- Modify: `src/libs/payments.ts` +- Create: `src/libs/billingActions.ts` + +- [ ] **Step 1: Add a top-up link creator to the payments library** + +`src/libs/payments.ts` already has a `PaymentProvider` pattern with a `stub` +provider (used when `STRIPE_SECRET_KEY` is unset) and a `stripeProvider`, plus +helpers `appBaseUrl()` and `readString()`. Add a parallel top-up function at +the end of the file, reusing those helpers: + +```ts +export type CreateTopUpInput = { + advertiserId: number; + amountCents: number; +}; + +export type CreateTopUpResult = + | { status: 'created'; url: string } + | { status: 'failed' }; + +/** + * Creates a hosted Stripe Checkout session for an advertiser balance top-up. + * Falls back to a simulated link when no Stripe key is configured. + * @param input - The advertiser and amount in cents. + * @returns The checkout URL, or a failed result. + */ +export async function createTopUpLink( + input: CreateTopUpInput +): Promise { + const base = appBaseUrl(); + + if (!Env.STRIPE_SECRET_KEY) { + return { + status: 'created', + url: `${base}/en/advertise/billing?topup=simulated`, + }; + } + + try { + const response = await fetch( + 'https://api.stripe.com/v1/checkout/sessions', + { + method: 'POST', + headers: { + Authorization: `Bearer ${Env.STRIPE_SECRET_KEY}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + mode: 'payment', + client_reference_id: String(input.advertiserId), + 'metadata[advertiser_id]': String(input.advertiserId), + success_url: `${base}/en/advertise/billing?topup=pending`, + cancel_url: `${base}/en/advertise/billing`, + 'line_items[0][quantity]': '1', + 'line_items[0][price_data][currency]': 'usd', + 'line_items[0][price_data][unit_amount]': String(input.amountCents), + 'line_items[0][price_data][product_data][name]': + 'Symbolic Ads balance top-up', + }).toString(), + } + ); + + if (!response.ok) { + return { status: 'failed' }; + } + + const data: unknown = await response.json(); + const url = readString(data, 'url'); + return url ? { status: 'created', url } : { status: 'failed' }; + } catch { + return { status: 'failed' }; + } +} +``` + +- [ ] **Step 2: Create the server action** + +Create `src/libs/billingActions.ts`: + +```ts +'use server'; + +import { currentUser } from '@clerk/nextjs/server'; +import { eq } from 'drizzle-orm'; +import { advertisers } from '@/models/Schema'; +import { db } from './DB'; +import { createTopUpLink } from './payments'; + +const MIN_TOPUP_CENTS = 1000; +const MAX_TOPUP_CENTS = 50_000; + +/** + * Starts a Stripe Checkout session to top up the signed-in advertiser's balance. + * @param amountCents - The amount to add, in cents. + * @returns The checkout URL to redirect to, or an error message. + */ +export async function createTopUpSession( + amountCents: number +): Promise<{ url: string } | { error: string }> { + const user = await currentUser(); + if (!user) { + return { error: 'Not signed in' }; + } + + if ( + !Number.isInteger(amountCents) + || amountCents < MIN_TOPUP_CENTS + || amountCents > MAX_TOPUP_CENTS + ) { + return { error: 'Enter an amount between $10 and $500' }; + } + + const [advertiser] = await db + .select({ id: advertisers.id }) + .from(advertisers) + .where(eq(advertisers.clerkUserId, user.id)) + .limit(1); + + if (!advertiser) { + return { error: 'Advertiser account not found' }; + } + + const result = await createTopUpLink({ + advertiserId: advertiser.id, + amountCents, + }); + + if (result.status === 'failed') { + return { error: "Couldn't start checkout" }; + } + + return { url: result.url }; +} +``` + +- [ ] **Step 3: Type-check, lint, commit** + +```bash +cd /c/Users/skyea/claude/symbolic && ./node_modules/.bin/tsc --noEmit && npx ultracite check --type-aware --type-check +``` + +Add `'src/libs/billingActions.ts'` to the `knip.config.ts` ignore list if knip +flags it (its consumer lands in Task 7). + +```bash +cd /c/Users/skyea/claude/symbolic && git add src/libs/payments.ts src/libs/billingActions.ts knip.config.ts && git commit -m "feat: add stripe checkout session for balance top-ups" +``` + +--- + +## Task 5: The webhook endpoint + +**Files:** +- Create: `src/app/api/stripe/webhook/route.ts` + +- [ ] **Step 1: Create the route** + +```ts +import { eq } from 'drizzle-orm'; +import { NextResponse } from 'next/server'; +import { advertisers } from '@/models/Schema'; +import { creditTopUp } from '@/libs/billing'; +import { db } from '@/libs/DB'; +import { Env } from '@/libs/Env'; +import { verifyStripeSignature } from '@/libs/stripeWebhook'; + +type StripeSession = { + id?: unknown; + payment_status?: unknown; + amount_total?: unknown; + client_reference_id?: unknown; +}; + +type StripeEvent = { + type?: unknown; + data?: { object?: StripeSession }; +}; + +export async function POST(request: Request) { + // The raw body is required: reserializing the JSON breaks the signature. + const body = await request.text(); + + const valid = verifyStripeSignature({ + body, + header: request.headers.get('stripe-signature'), + secret: Env.STRIPE_WEBHOOK_SECRET ?? '', + nowSeconds: Math.floor(Date.now() / 1000), + }); + + if (!valid) { + return new NextResponse('Invalid signature', { status: 400 }); + } + + let event: StripeEvent; + try { + event = JSON.parse(body) as StripeEvent; + } catch { + return new NextResponse('Bad payload', { status: 400 }); + } + + // Acknowledge anything we do not act on so Stripe stops retrying. + if (event.type !== 'checkout.session.completed') { + return NextResponse.json({ received: true }); + } + + const session = event.data?.object; + if (!session || session.payment_status !== 'paid') { + return NextResponse.json({ received: true }); + } + + const sessionId = typeof session.id === 'string' ? session.id : null; + const amountCents = + typeof session.amount_total === 'number' ? session.amount_total : null; + const advertiserId = + typeof session.client_reference_id === 'string' + ? Number(session.client_reference_id) + : Number.NaN; + + if (!(sessionId && amountCents) || !Number.isInteger(advertiserId)) { + return NextResponse.json({ received: true }); + } + + const [advertiser] = await db + .select({ id: advertisers.id }) + .from(advertisers) + .where(eq(advertisers.id, advertiserId)) + .limit(1); + + if (!advertiser) { + return NextResponse.json({ received: true }); + } + + await creditTopUp({ advertiserId, amountCents, stripeSessionId: sessionId }); + + return NextResponse.json({ received: true }); +} +``` + +- [ ] **Step 2: Confirm the route is reachable through middleware** + +`src/middleware.ts` already passes `/api/*` straight through without the i18n +rewrite (added in an earlier fix), so no middleware change is needed. Verify by +reading the file and confirming the `req.nextUrl.pathname.startsWith('/api/')` +early return is present. If it is missing, stop and report — the webhook would +404 without it. + +- [ ] **Step 3: Type-check, lint, commit** + +```bash +cd /c/Users/skyea/claude/symbolic && ./node_modules/.bin/tsc --noEmit && git add src/app/api/stripe && git commit -m "feat: add stripe webhook endpoint for top-up credits" +``` + +--- + +## Task 6: Charge on click and gate serving + +**Files:** +- Modify: `src/app/api/ads/click/route.ts` +- Modify: `src/libs/ads.ts` +- Modify: `src/libs/ads.test.ts` + +- [ ] **Step 1: Add the zero-balance serving test first** + +In `src/libs/ads.test.ts`, the existing `insertAd` helper creates ads for a +single advertiser created in `beforeEach`. Add this test inside the +`describe('selectAds', ...)` block: + +```ts + it('excludes an ad whose advertiser has no balance', async () => { + await db + .update(advertisers) + .set({ balanceCents: 0 }) + .where(eq(advertisers.id, advertiserId)); + + const id = await insertAd('approved', true); + const result = await selectAds('running'); + expect(result.map((ad) => ad.id)).not.toContain(id); + }); + + it('includes an ad whose advertiser has a balance', async () => { + await db + .update(advertisers) + .set({ balanceCents: 500 }) + .where(eq(advertisers.id, advertiserId)); + + const id = await insertAd('approved', true); + const result = await selectAds('running'); + expect(result.map((ad) => ad.id)).toContain(id); + }); +``` + +The existing tests in this file create advertisers without setting +`balanceCents`, so they default to `0` — which means **they will now fail** +once the gate is added. Fix them by setting a balance in the file's +`beforeEach`, immediately after the advertiser insert: + +```ts + await db + .update(advertisers) + .set({ balanceCents: 10_000 }) + .where(eq(advertisers.id, advertiserId)); +``` + +Ensure `advertisers` and `eq` are imported in the test file. + +- [ ] **Step 2: Run the tests to verify the new ones fail** + +Run: `cd /c/Users/skyea/claude/symbolic && ./node_modules/.bin/vitest run src/libs/ads.test.ts` +Expected: the "excludes an ad whose advertiser has no balance" test FAILS +(the ad still serves, because no gate exists yet). + +- [ ] **Step 3: Add the balance gate to `selectAds`** + +In `src/libs/ads.ts`, `selectAds` currently selects from `ads` alone. Change it +to join `advertisers` and require a positive balance. Replace the whole +function body's query with: + +```ts + const result = await db + .select() + .from(ads) + .innerJoin(advertisers, eq(ads.advertiserId, advertisers.id)) + .where( + and( + eq(ads.status, 'approved'), + eq(ads.active, true), + gt(advertisers.balanceCents, 0), + sql`${ads.keywords} && ARRAY[${sql.join( + tokens.map((t) => sql`${t}`), + sql`, ` + )}]::text[]` + ) + ) + .orderBy(desc(ads.bidAmount)) + .limit(2); + + return result.map((row) => row.ads); +``` + +Add `gt` to the `drizzle-orm` import and `advertisers` to the `@/models/Schema` +import. **The `.map((row) => row.ads)` is essential** — a join makes Drizzle +return `{ ads: ..., advertisers: ... }` per row, and every caller expects a +bare `Ad[]`. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cd /c/Users/skyea/claude/symbolic && ./node_modules/.bin/vitest run src/libs/ads.test.ts` +Expected: PASS — all tests including the two new ones. + +- [ ] **Step 5: Charge on click** + +In `src/app/api/ads/click/route.ts`, replace the existing best-effort click +insert block: + +```ts + try { + await db.insert(adClicks).values({ adId: ad.id, query }); + } catch { + // Best-effort: record the click if possible, but don't block the redirect + } +``` + +with a version that also charges: + +```ts + try { + await db.insert(adClicks).values({ adId: ad.id, query }); + + if (ad.advertiserId) { + await chargeForClick({ + advertiserId: ad.advertiserId, + amountCents: ad.bidAmount, + adId: ad.id, + description: `Click on "${ad.title}"`, + }); + } + } catch { + // Best-effort: never block the visitor's redirect on a billing failure. + } +``` + +Add `import { chargeForClick } from '@/libs/billing';` to the imports. + +- [ ] **Step 6: Full suite, then commit** + +```bash +cd /c/Users/skyea/claude/symbolic && ./node_modules/.bin/tsc --noEmit && ./node_modules/.bin/vitest run +``` + +Expected: all tests pass. + +```bash +cd /c/Users/skyea/claude/symbolic && git add src/libs/ads.ts src/libs/ads.test.ts src/app/api/ads/click && git commit -m "feat: charge advertisers per click and gate serving on balance" +``` + +--- + +## Task 7: i18n keys and the billing page + +**Files:** +- Modify: `src/locales/en.json`, `src/locales/fr.json` +- Create: `src/app/[locale]/(portal)/advertise/billing/TopUpButtons.tsx` +- Create: `src/app/[locale]/(portal)/advertise/billing/page.tsx` +- Modify: `src/app/[locale]/(portal)/advertise/layout.tsx` + +- [ ] **Step 1: Add English keys** + +In `src/locales/en.json`, add a `BillingPage` namespace immediately after the +`AdsPage` namespace: + +```json + "BillingPage": { + "title": "Billing", + "balance_label": "Current balance", + "low_balance": "Your balance is running low.", + "no_funds": "Your ads are paused - add funds to resume.", + "topup_title": "Add funds", + "topup_custom_placeholder": "Custom amount", + "topup_button": "Add funds", + "topup_pending": "Payment received - your balance will update in a few seconds.", + "topup_simulated": "Simulated top-up (no Stripe key configured).", + "refresh": "Refresh", + "history_title": "Transaction history", + "history_empty": "No transactions yet.", + "col_date": "Date", + "col_description": "Description", + "col_amount": "Amount", + "col_balance": "Balance", + "error_generic": "Couldn't start checkout." + }, +``` + +Also add `"nav_billing": "Billing"` to the existing `AdvertiseLayout` namespace. + +- [ ] **Step 2: Add French keys** + +Mirror into `src/locales/fr.json` in the same positions: + +```json + "BillingPage": { + "title": "Facturation", + "balance_label": "Solde actuel", + "low_balance": "Votre solde est faible.", + "no_funds": "Vos annonces sont en pause - ajoutez des fonds pour reprendre.", + "topup_title": "Ajouter des fonds", + "topup_custom_placeholder": "Montant personnalisé", + "topup_button": "Ajouter des fonds", + "topup_pending": "Paiement reçu - votre solde sera mis à jour dans quelques secondes.", + "topup_simulated": "Rechargement simulé (aucune clé Stripe configurée).", + "refresh": "Actualiser", + "history_title": "Historique des transactions", + "history_empty": "Aucune transaction pour le moment.", + "col_date": "Date", + "col_description": "Description", + "col_amount": "Montant", + "col_balance": "Solde", + "error_generic": "Impossible de démarrer le paiement." + }, +``` + +and `"nav_billing": "Facturation"` in `AdvertiseLayout`. + +- [ ] **Step 3: Create the top-up client component** + +Create `src/app/[locale]/(portal)/advertise/billing/TopUpButtons.tsx`: + +```tsx +'use client'; + +import { useState } from 'react'; +import { createTopUpSession } from '@/libs/billingActions'; + +const PRESETS_CENTS = [2500, 5000, 10_000]; + +export function TopUpButtons(props: { + labels: { custom: string; submit: string; error: string }; +}) { + const [custom, setCustom] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + + async function start(amountCents: number) { + setBusy(true); + setError(''); + const result = await createTopUpSession(amountCents); + if ('error' in result) { + setError(result.error); + setBusy(false); + return; + } + window.location.href = result.url; + } + + const customCents = Math.round(Number(custom) * 100); + const customValid = Number.isFinite(customCents) && customCents >= 1000; + + return ( +
+
+ {PRESETS_CENTS.map((cents) => ( + + ))} +
+
+ { + setCustom(event.target.value); + }} + placeholder={props.labels.custom} + value={custom} + /> + +
+ {error &&

{error}

} +
+ ); +} +``` + +- [ ] **Step 4: Create the billing page** + +Create `src/app/[locale]/(portal)/advertise/billing/page.tsx`: + +```tsx +import { currentUser } from '@clerk/nextjs/server'; +import { eq } from 'drizzle-orm'; +import { getTranslations, setRequestLocale } from 'next-intl/server'; +import { redirect } from 'next/navigation'; +import { listTransactions } from '@/libs/billing'; +import { db } from '@/libs/DB'; +import { advertisers } from '@/models/Schema'; +import { formatUsd } from '@/utils/Money'; +import { TopUpButtons } from './TopUpButtons'; + +const LOW_BALANCE_CENTS = 500; + +function balanceClass(balanceCents: number): string { + if (balanceCents <= 0) { + return 'text-red-400'; + } + if (balanceCents < LOW_BALANCE_CENTS) { + return 'text-amber-400'; + } + return 'text-white'; +} + +export default async function BillingPage(props: { + params: Promise<{ locale: string }>; + searchParams: Promise<{ topup?: string }>; +}) { + const { locale } = await props.params; + setRequestLocale(locale); + const { topup } = await props.searchParams; + const t = await getTranslations('BillingPage'); + + const user = await currentUser(); + if (!user) { + redirect(`/${locale}/advertise/sign-in`); + } + + const [advertiser] = await db + .select() + .from(advertisers) + .where(eq(advertisers.clerkUserId, user.id)) + .limit(1); + + if (!advertiser) { + redirect(`/${locale}/advertise/sign-in`); + } + + const transactions = await listTransactions(advertiser.id, 50); + + return ( +
+

{t('title')}

+ + {topup === 'pending' && ( +
+ {t('topup_pending')}{' '} + + {t('refresh')} + +
+ )} + {topup === 'simulated' && ( +
+ {t('topup_simulated')} +
+ )} + +
+
+ {t('balance_label')} +
+
+ {formatUsd(advertiser.balanceCents)} +
+ {advertiser.balanceCents <= 0 && ( +

{t('no_funds')}

+ )} + {advertiser.balanceCents > 0 + && advertiser.balanceCents < LOW_BALANCE_CENTS && ( +

{t('low_balance')}

+ )} +
+ +
+

{t('topup_title')}

+ +
+ +

{t('history_title')}

+ {transactions.length === 0 ? ( +

+ {t('history_empty')} +

+ ) : ( +
+
+ {t('col_date')} + {t('col_description')} + {t('col_amount')} + {t('col_balance')} +
+ {transactions.map((row) => ( +
+ + {row.createdAt.toISOString().slice(0, 10)} + + {row.description} + = 0 ? 'text-green-400' : 'text-red-400' + } + > + {row.amountCents >= 0 ? '+' : ''} + {formatUsd(row.amountCents)} + + + {formatUsd(row.balanceAfterCents)} + +
+ ))} +
+ )} +
+ ); +} +``` + +- [ ] **Step 5: Add the nav link** + +In `src/app/[locale]/(portal)/advertise/layout.tsx`, after the existing +"My ads" link, add: + +```tsx + + {t('nav_billing')} + +``` + +- [ ] **Step 6: Protect the route** + +In `src/middleware.ts`, add `'/:locale/advertise/billing(.*)'` to the +`createRouteMatcher` array so the page requires sign-in. + +- [ ] **Step 7: Verify and commit** + +```bash +cd /c/Users/skyea/claude/symbolic && ./node_modules/.bin/tsc --noEmit && npm run check:i18n && ./node_modules/.bin/vitest run +``` + +Expected: no type errors; "No missing keys found!"; all tests pass. + +Remove any temporary `knip.config.ts` ignore entries for `billing.ts` and +`billingActions.ts` now that they have real consumers, and confirm +`npx knip` is happy. + +```bash +cd /c/Users/skyea/claude/symbolic && git add "src/app/\[locale\]/\(portal\)/advertise" src/middleware.ts src/locales knip.config.ts && git commit -m "feat: add advertiser billing page with top-up and history" +``` + +--- + +## Task 8: Dashboard and admin surfaces + +**Files:** +- Modify: `src/app/[locale]/(portal)/advertise/dashboard/page.tsx` +- Modify: `src/app/[locale]/(admin)/admin/advertisers/page.tsx` +- Modify: `src/locales/en.json`, `src/locales/fr.json` + +- [ ] **Step 1: Add dashboard keys** + +In `src/locales/en.json`, add to the `AdvertiseDashboardPage` namespace: + +```json + "balance_label": "Balance", + "low_balance_warning": "Your balance is low - top up to keep your ads running.", + "no_funds_warning": "Your ads are paused - you're out of funds.", + "manage_billing": "Manage billing" +``` + +In `src/locales/fr.json`, the same keys: + +```json + "balance_label": "Solde", + "low_balance_warning": "Votre solde est faible - rechargez pour que vos annonces continuent.", + "no_funds_warning": "Vos annonces sont en pause - vous n'avez plus de fonds.", + "manage_billing": "Gérer la facturation" +``` + +- [ ] **Step 2: Show balance and warnings on the advertiser dashboard** + +Open `src/app/[locale]/(portal)/advertise/dashboard/page.tsx`. It already loads +the advertiser row and renders stat cards. Add these imports: + +```tsx +import Link from 'next/link'; +import { formatUsd } from '@/utils/Money'; +``` + +(`Link` may already be imported — do not duplicate it.) + +Add a balance stat card alongside the existing cards, using the advertiser's +`balanceCents`: + +```tsx +
+
+ {t('balance_label')} +
+
+ {formatUsd(advertiser?.balanceCents ?? 0)} +
+
+``` + +And immediately above the stat grid, a warning banner: + +```tsx + {(advertiser?.balanceCents ?? 0) <= 0 && ( +
+ {t('no_funds_warning')}{' '} + + {t('manage_billing')} + +
+ )} + {(advertiser?.balanceCents ?? 0) > 0 + && (advertiser?.balanceCents ?? 0) < 500 && ( +
+ {t('low_balance_warning')}{' '} + + {t('manage_billing')} + +
+ )} +``` + +Adapt the variable name if the page calls the advertiser row something else — +read the file first and match what is there. + +- [ ] **Step 3: Add a Balance column to the admin advertisers list** + +In `src/app/[locale]/(admin)/admin/advertisers/page.tsx`: + +- Add `import { formatUsd } from '@/utils/Money';` +- Add `"col_balance": "Balance"` to the `AdminAdvertisersPage` namespace in both + locale files +- The table uses a `grid-cols-[1.5fr_2fr_60px_70px_110px_80px]` layout in both + the header row and the body rows. Change **both** to + `grid-cols-[1.5fr_2fr_60px_70px_90px_110px_80px]`, add a + `{t('col_balance')}` header cell after the clicks header, and add + a matching body cell after the clicks cell: + +```tsx + {formatUsd(advertiser.balanceCents)} +``` + +Both grids must have the same number of columns or the table will misalign. + +- [ ] **Step 4: Verify and commit** + +```bash +cd /c/Users/skyea/claude/symbolic && ./node_modules/.bin/tsc --noEmit && npm run check:i18n && ./node_modules/.bin/vitest run +``` + +```bash +cd /c/Users/skyea/claude/symbolic && git add "src/app/\[locale\]" src/locales && git commit -m "feat: surface advertiser balance in dashboard and admin list" +``` + +--- + +## Task 9: Ops documentation + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: Append a billing section** + +```markdown +## Billing ops + +Advertisers prepay a balance; each ad click deducts their bid. Money moves are +recorded in the append-only `billing_transactions` ledger, and +`advertisers.balance_cents` is a cached figure always equal to +`SUM(amount_cents)` for that advertiser. + +Production env vars: + +- `STRIPE_SECRET_KEY` - start with `sk_test_...` and verify the whole flow + before switching to a live key +- `STRIPE_WEBHOOK_SECRET` - the `whsec_...` signing secret from the Stripe + dashboard webhook endpoint + +Stripe dashboard setup: add a webhook endpoint at +`https://bsymbolic.com/api/stripe/webhook` subscribed to +`checkout.session.completed`. + +Test the flow in Stripe test mode with card `4242 4242 4242 4242`, any future +expiry, any CVC. Confirm the balance credits within a few seconds of paying and +that a second webhook delivery (Stripe dashboard, "Resend") does not +double-credit. + +With no `STRIPE_SECRET_KEY` set, top-ups fall back to a simulated link and no +money moves - useful for local development. +``` + +- [ ] **Step 2: Commit** + +```bash +cd /c/Users/skyea/claude/symbolic && git add README.md && git commit -m "docs: add billing ops notes" +``` + +--- + +## Done + +Advertisers can fund a balance, clicks bill against it, and ads stop when the +money runs out — with a ledger that can prove every cent. + +**Before this can take real money, on the VPS:** + +1. `STRIPE_SECRET_KEY` and `STRIPE_WEBHOOK_SECRET` in `.env.local` +2. The Stripe dashboard webhook endpoint pointing at + `https://bsymbolic.com/api/stripe/webhook` +3. A rebuild and `pm2 restart symbolic --update-env` +4. An end-to-end test in Stripe **test mode** before switching to live keys diff --git a/docs/superpowers/plans/2026-08-24-currency-usd.md b/docs/superpowers/plans/2026-08-24-currency-usd.md new file mode 100644 index 0000000..ded0bad --- /dev/null +++ b/docs/superpowers/plans/2026-08-24-currency-usd.md @@ -0,0 +1,366 @@ +# Currency Migration to USD (Phase 5, Plan A) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Display all ad-platform money in USD through one shared `formatUsd` helper, replacing the scattered `£{(x / 100).toFixed(2)}` expressions. + +**Architecture:** Add `src/utils/Money.ts` with a single `formatUsd(cents)` function, then replace the five ad-platform display sites and two i18n strings that currently show `£`. No stored data changes — the integers stay identical, only the symbol and formatting change. + +**Tech Stack:** TypeScript, `Intl.NumberFormat`, Vitest. + +--- + +## Environment notes for the implementer + +- `bun` is NOT installed. Use `./node_modules/.bin/tsc --noEmit`, + `./node_modules/.bin/vitest run `, `npx ultracite check --type-aware --type-check`. +- **The Bash tool's cwd resets between commands** — start every command with + `cd /c/Users/skyea/claude/symbolic &&`. +- If `node_modules` is missing, run `cmd.exe /c "npm ci"` first (~2 min). +- Lint rules that bite: no `.then` chains, no nested ternaries, no unsafe casts + without a narrow eslint-disable, `require-await`. The formatter (`ultracite`) + reformats on commit via a pre-commit hook — let it. +- Line-ending (CRLF) warnings from git are noise; ignore them. + +## IMPORTANT: what NOT to touch + +`£` also appears in the **QuoteIQ CRM**, which is a separate product in this +repo. Leave these files completely alone: + +- `src/app/[locale]/(portal)/crm/automations/WorkflowForm.tsx` +- `src/app/[locale]/(portal)/crm/invoices/InvoiceCostField.tsx` +- `src/app/[locale]/(portal)/crm/pipeline/PipelineBoard.tsx` +- the `unit_price` key in `src/locales/en.json` / `fr.json` + +Only the ad platform migrates to USD. + +## File map + +| File | Action | Purpose | +|---|---|---| +| `src/utils/Money.ts` | Create | `formatUsd(cents)` helper | +| `src/utils/Money.test.ts` | Create | Unit tests for the helper | +| `src/app/[locale]/(portal)/advertise/ads/page.tsx` | Modify | Bid column | +| `src/app/[locale]/(admin)/admin/ads/page.tsx` | Modify | Bid column | +| `src/app/[locale]/(admin)/admin/queue/page.tsx` | Modify | Bid line | +| `src/app/[locale]/(admin)/admin/dashboard/page.tsx` | Modify | Revenue stat | +| `src/components/AdWizard.tsx` | Modify | Bid input prefix | +| `src/locales/en.json`, `fr.json` | Modify | `budget_placeholder`, `bid_minimum` | +| `src/libs/adActions.test.ts` | Modify | Two stale `£` comments | + +--- + +## Task 1: The `formatUsd` helper (TDD) + +**Files:** +- Create: `src/utils/Money.ts` +- Create: `src/utils/Money.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `src/utils/Money.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import { formatUsd } from './Money'; + +describe('formatUsd', () => { + it('formats whole dollars', () => { + expect(formatUsd(2500)).toBe('$25.00'); + }); + + it('formats sub-dollar amounts', () => { + expect(formatUsd(50)).toBe('$0.50'); + }); + + it('formats zero', () => { + expect(formatUsd(0)).toBe('$0.00'); + }); + + it('formats negative amounts', () => { + expect(formatUsd(-50)).toBe('-$0.50'); + }); + + it('adds a thousands separator', () => { + expect(formatUsd(123_456)).toBe('$1,234.56'); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cd /c/Users/skyea/claude/symbolic && ./node_modules/.bin/vitest run src/utils/Money.test.ts` +Expected: FAIL — cannot find module `./Money`. + +- [ ] **Step 3: Write the implementation** + +Create `src/utils/Money.ts`: + +```ts +const usdFormatter = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', +}); + +/** + * Formats an integer cent amount as a USD string. + * @param cents - The amount in whole cents; may be negative. + * @returns The formatted amount, e.g. `$25.00`. + */ +export function formatUsd(cents: number): string { + return usdFormatter.format(cents / 100); +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `cd /c/Users/skyea/claude/symbolic && ./node_modules/.bin/vitest run src/utils/Money.test.ts` +Expected: PASS — 5 tests. + +- [ ] **Step 5: Type-check and lint** + +Run: `cd /c/Users/skyea/claude/symbolic && ./node_modules/.bin/tsc --noEmit` +Expected: no output. + +If the pre-commit knip check flags `src/utils/Money.ts` as an unused export +(its consumers arrive in Task 2), add `'src/utils/Money.ts'` to the `ignore` +array in `knip.config.ts` and remove it again at the end of Task 2. + +- [ ] **Step 6: Commit** + +```bash +cd /c/Users/skyea/claude/symbolic && git add src/utils/Money.ts src/utils/Money.test.ts knip.config.ts && git commit -m "feat: add formatUsd money helper" +``` + +(Include `knip.config.ts` only if you changed it.) + +--- + +## Task 2: Replace the five display sites + +**Files:** +- Modify: `src/app/[locale]/(portal)/advertise/ads/page.tsx` +- Modify: `src/app/[locale]/(admin)/admin/ads/page.tsx` +- Modify: `src/app/[locale]/(admin)/admin/queue/page.tsx` +- Modify: `src/app/[locale]/(admin)/admin/dashboard/page.tsx` +- Modify: `src/components/AdWizard.tsx` + +- [ ] **Step 1: Advertiser ads list** + +In `src/app/[locale]/(portal)/advertise/ads/page.tsx`, add the import +`import { formatUsd } from '@/utils/Money';` alongside the existing imports, +then replace this line (currently around line 105): + +```tsx + £{(ad.bidAmount / 100).toFixed(2)} +``` + +with: + +```tsx + {formatUsd(ad.bidAmount)} +``` + +- [ ] **Step 2: Admin ads list** + +In `src/app/[locale]/(admin)/admin/ads/page.tsx`, add +`import { formatUsd } from '@/utils/Money';`, then replace (around line 107): + +```tsx + £{(ad.bidAmount / 100).toFixed(2)} +``` + +with: + +```tsx + {formatUsd(ad.bidAmount)} +``` + +- [ ] **Step 3: Admin review queue** + +In `src/app/[locale]/(admin)/admin/queue/page.tsx`, add +`import { formatUsd } from '@/utils/Money';`, then replace (around line 48): + +```tsx + {t('bid_label')}: £{(ad.bidAmount / 100).toFixed(2)} +``` + +with: + +```tsx + {t('bid_label')}: {formatUsd(ad.bidAmount)} +``` + +- [ ] **Step 4: Admin dashboard revenue stat** + +In `src/app/[locale]/(admin)/admin/dashboard/page.tsx`, add +`import { formatUsd } from '@/utils/Money';`. There is currently a variable +computed as `const revenuePounds = (stats.revenuePenceLast30Days / 100).toFixed(2);` +— delete that line entirely, and replace the render line (around line 63): + +```tsx +
£{revenuePounds}
+``` + +with: + +```tsx +
+ {formatUsd(stats.revenuePenceLast30Days)} +
+``` + +Note: leave the `revenuePenceLast30Days` field name in `src/libs/adminStats.ts` +alone. Renaming it is churn across the stats type for no behavioural gain; the +value is minor currency units either way. + +- [ ] **Step 5: AdWizard bid input** + +In `src/components/AdWizard.tsx`, replace the currency prefix (around line 266): + +```tsx + £ +``` + +with: + +```tsx + $ +``` + +This one is a bare symbol next to an input, not a formatted amount, so it does +not use `formatUsd`. + +- [ ] **Step 6: Verify no ad-platform `£` remains** + +Run: + +```bash +cd /c/Users/skyea/claude/symbolic && grep -rn '£' src --include=*.tsx --include=*.ts | grep -v crm | grep -v locales +``` + +Expected output: only the two comment lines in `src/libs/adActions.test.ts` +(fixed in Task 3). No CRM files should appear because of the `grep -v crm`. + +- [ ] **Step 7: Type-check, lint, and test** + +```bash +cd /c/Users/skyea/claude/symbolic && ./node_modules/.bin/tsc --noEmit && ./node_modules/.bin/vitest run +``` + +Expected: no type errors; all tests pass. + +If you added `src/utils/Money.ts` to the knip ignore list in Task 1, remove that +entry now and confirm `npx knip` does not complain. + +- [ ] **Step 8: Commit** + +```bash +cd /c/Users/skyea/claude/symbolic && git add "src/app/\[locale\]" src/components/AdWizard.tsx knip.config.ts && git commit -m "refactor: display ad platform amounts in USD" +``` + +(Include `knip.config.ts` only if you changed it.) + +--- + +## Task 3: i18n strings and stale test comments + +**Files:** +- Modify: `src/locales/en.json` +- Modify: `src/locales/fr.json` +- Modify: `src/libs/adActions.test.ts` + +- [ ] **Step 1: English strings** + +In `src/locales/en.json`: + +- In the `AdvertiseDashboardPage` namespace, change + `"budget_placeholder": "£0",` to `"budget_placeholder": "$0",` +- In the `AdWizard` namespace, change + `"bid_minimum": "Minimum £0.10 per click",` to + `"bid_minimum": "Minimum $0.10 per click",` + +Do **not** touch `unit_price` — that belongs to the CRM. + +- [ ] **Step 2: French strings** + +In `src/locales/fr.json`: + +- `"budget_placeholder": "£0",` becomes `"budget_placeholder": "0 $",` +- `"bid_minimum": "Minimum £0.10 par clic",` becomes + `"bid_minimum": "Minimum 0,10 $ par clic",` + +French convention puts the currency symbol after the amount and uses a comma +decimal separator, which is why these differ in shape from the English values. + +Again, leave `unit_price` alone. + +- [ ] **Step 3: Fix the stale comments in the ad actions test** + +In `src/libs/adActions.test.ts`, two comments still say pence: + +```ts + expect(rows[0]?.bidAmount).toBe(50); // £0.50 = 50 pence +``` + +becomes + +```ts + expect(rows[0]?.bidAmount).toBe(50); // $0.50 = 50 cents +``` + +and + +```ts + it('returns error for bid below minimum (£0.10 = 10 pence)', async () => { +``` + +becomes + +```ts + it('returns error for bid below minimum ($0.10 = 10 cents)', async () => { +``` + +These are comments and a test title only — no assertion values change. + +- [ ] **Step 4: Verify i18n integrity** + +```bash +cd /c/Users/skyea/claude/symbolic && npm run check:i18n +``` + +Expected: "No missing keys found!" and "No invalid translations found!". A +non-zero exit from the pre-existing "unused keys" report is fine. + +- [ ] **Step 5: Confirm only CRM `£` remains** + +```bash +cd /c/Users/skyea/claude/symbolic && grep -rn '£' src | grep -v crm +``` + +Expected: only the `unit_price` lines in `en.json` and `fr.json` (a CRM key that +lives in the shared locale files). Nothing else. + +- [ ] **Step 6: Full suite** + +```bash +cd /c/Users/skyea/claude/symbolic && ./node_modules/.bin/vitest run +``` + +Expected: all tests pass. Start the PGLite server first if DB tests cannot +connect: `npx pglite-server -m 100 --db=local.db` in the background, then +`npx dotenv -c -- drizzle-kit migrate`. + +- [ ] **Step 7: Commit** + +```bash +cd /c/Users/skyea/claude/symbolic && git add src/locales/en.json src/locales/fr.json src/libs/adActions.test.ts && git commit -m "refactor: switch ad platform copy to USD" +``` + +--- + +## Done + +The ad platform reads in USD everywhere, all amounts flow through one helper, +and the CRM's GBP pricing is untouched. Stored values are unchanged — `50` was +50 pence and is now 50 cents. From 1fb8db405cb309ba045f40e3f7b85bdaa77f62c1 Mon Sep 17 00:00:00 2001 From: denrod25-del Date: Mon, 24 Aug 2026 16:40:45 -0400 Subject: [PATCH 03/15] feat: add formatUsd money helper --- src/utils/Money.test.ts | 24 ++++++++++++++++++++++++ src/utils/Money.ts | 13 +++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 src/utils/Money.test.ts create mode 100644 src/utils/Money.ts diff --git a/src/utils/Money.test.ts b/src/utils/Money.test.ts new file mode 100644 index 0000000..ce74dc8 --- /dev/null +++ b/src/utils/Money.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { formatUsd } from './Money'; + +describe('formatUsd', () => { + it('formats whole dollars', () => { + expect(formatUsd(2500)).toBe('$25.00'); + }); + + it('formats sub-dollar amounts', () => { + expect(formatUsd(50)).toBe('$0.50'); + }); + + it('formats zero', () => { + expect(formatUsd(0)).toBe('$0.00'); + }); + + it('formats negative amounts', () => { + expect(formatUsd(-50)).toBe('-$0.50'); + }); + + it('adds a thousands separator', () => { + expect(formatUsd(123_456)).toBe('$1,234.56'); + }); +}); diff --git a/src/utils/Money.ts b/src/utils/Money.ts new file mode 100644 index 0000000..6764616 --- /dev/null +++ b/src/utils/Money.ts @@ -0,0 +1,13 @@ +const usdFormatter = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', +}); + +/** + * Formats an integer cent amount as a USD string. + * @param cents - The amount in whole cents; may be negative. + * @returns The formatted amount, e.g. `$25.00`. + */ +export function formatUsd(cents: number): string { + return usdFormatter.format(cents / 100); +} From b17f1adc0a4d1d06bfa1b3d84515399c858a781e Mon Sep 17 00:00:00 2001 From: denrod25-del Date: Mon, 24 Aug 2026 16:44:31 -0400 Subject: [PATCH 04/15] refactor: display ad platform amounts in USD --- src/app/[locale]/(admin)/admin/ads/page.tsx | 3 ++- src/app/[locale]/(admin)/admin/dashboard/page.tsx | 6 ++++-- src/app/[locale]/(admin)/admin/queue/page.tsx | 3 ++- src/app/[locale]/(portal)/advertise/ads/page.tsx | 3 ++- src/components/AdWizard.tsx | 2 +- 5 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/app/[locale]/(admin)/admin/ads/page.tsx b/src/app/[locale]/(admin)/admin/ads/page.tsx index daceba1..801496d 100644 --- a/src/app/[locale]/(admin)/admin/ads/page.tsx +++ b/src/app/[locale]/(admin)/admin/ads/page.tsx @@ -3,6 +3,7 @@ import { getTranslations, setRequestLocale } from 'next-intl/server'; import Link from 'next/link'; import { db } from '@/libs/DB'; import { adClicks, ads } from '@/models/Schema'; +import { formatUsd } from '@/utils/Money'; import { AdminAdActions } from './AdminAdActions'; const FILTERS = ['all', 'pending', 'approved', 'rejected', 'paused'] as const; @@ -104,7 +105,7 @@ export default async function AdminAdsPage(props: { {ad.advertiserName} {statusLabel(ad)} - £{(ad.bidAmount / 100).toFixed(2)} + {formatUsd(ad.bidAmount)} {clicksByAd.get(ad.id) ?? 0} {ad.createdAt.toISOString().slice(0, 10)} diff --git a/src/app/[locale]/(admin)/admin/dashboard/page.tsx b/src/app/[locale]/(admin)/admin/dashboard/page.tsx index 1801c8c..c744f0b 100644 --- a/src/app/[locale]/(admin)/admin/dashboard/page.tsx +++ b/src/app/[locale]/(admin)/admin/dashboard/page.tsx @@ -1,6 +1,7 @@ import { getTranslations, setRequestLocale } from 'next-intl/server'; import Link from 'next/link'; import { getAdminStats } from '@/libs/adminStats'; +import { formatUsd } from '@/utils/Money'; export default async function AdminDashboardPage(props: { params: Promise<{ locale: string }>; @@ -10,7 +11,6 @@ export default async function AdminDashboardPage(props: { const t = await getTranslations('AdminDashboardPage'); const stats = await getAdminStats(); - const revenuePounds = (stats.revenuePenceLast30Days / 100).toFixed(2); return (
@@ -60,7 +60,9 @@ export default async function AdminDashboardPage(props: {
{t('stat_revenue')}
-
£{revenuePounds}
+
+ {formatUsd(stats.revenuePenceLast30Days)} +
diff --git a/src/app/[locale]/(admin)/admin/queue/page.tsx b/src/app/[locale]/(admin)/admin/queue/page.tsx index 2399ebf..d14c307 100644 --- a/src/app/[locale]/(admin)/admin/queue/page.tsx +++ b/src/app/[locale]/(admin)/admin/queue/page.tsx @@ -2,6 +2,7 @@ import { asc, eq } from 'drizzle-orm'; import { getTranslations, setRequestLocale } from 'next-intl/server'; import { db } from '@/libs/DB'; import { ads } from '@/models/Schema'; +import { formatUsd } from '@/utils/Money'; import { QueueRowActions } from './QueueRowActions'; export default async function AdminQueuePage(props: { @@ -45,7 +46,7 @@ export default async function AdminQueuePage(props: { {t('keywords_label')}: {ad.keywords.join(', ')}
- {t('bid_label')}: £{(ad.bidAmount / 100).toFixed(2)} + {t('bid_label')}: {formatUsd(ad.bidAmount)}
>>; @@ -102,7 +103,7 @@ export default async function AdsPage(props: { {ad.keywords.slice(0, 3).join(', ')} {ad.keywords.length > 3 ? '…' : ''}
- £{(ad.bidAmount / 100).toFixed(2)} + {formatUsd(ad.bidAmount)}

{t('bid_hint')}

- £ + $ Date: Mon, 24 Aug 2026 16:47:09 -0400 Subject: [PATCH 05/15] refactor: switch ad platform copy to USD --- src/libs/adActions.test.ts | 4 ++-- src/locales/en.json | 4 ++-- src/locales/fr.json | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/libs/adActions.test.ts b/src/libs/adActions.test.ts index 8db0fe2..693ed9a 100644 --- a/src/libs/adActions.test.ts +++ b/src/libs/adActions.test.ts @@ -59,7 +59,7 @@ describe('adActions', () => { .where(eq(ads.advertiserId, advertiserId)); expect(rows).toHaveLength(1); expect(rows[0]?.title).toBe('Best running shoes'); - expect(rows[0]?.bidAmount).toBe(50); // £0.50 = 50 pence + expect(rows[0]?.bidAmount).toBe(50); // $0.50 = 50 cents expect(rows[0]?.keywords).toEqual(['running', 'shoes', 'trainers']); expect(rows[0]?.active).toBe(true); }); @@ -75,7 +75,7 @@ describe('adActions', () => { expect(rows).toHaveLength(0); }); - it('returns error for bid below minimum (£0.10 = 10 pence)', async () => { + it('returns error for bid below minimum ($0.10 = 10 cents)', async () => { const result = await createAd({ ...validData, bidPounds: '0.05' }); expect(result).toHaveProperty('error'); diff --git a/src/locales/en.json b/src/locales/en.json index cf15864..bd597bc 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -233,7 +233,7 @@ "greeting": "Welcome, {name} 👋", "active_ads_label": "Active ads", "budget_label": "Budget", - "budget_placeholder": "£0", + "budget_placeholder": "$0", "create_first_ad": "+ Create your first ad" }, "AdWizard": { @@ -252,7 +252,7 @@ "bid_label": "Bid per click", "bid_hint": "Set how much you pay per click. Higher bids rank above lower bids.", "bid_per_click": "per click", - "bid_minimum": "Minimum £0.10 per click", + "bid_minimum": "Minimum $0.10 per click", "button_next": "Next →", "button_back": "← Back", "button_saving": "Saving...", diff --git a/src/locales/fr.json b/src/locales/fr.json index 2401f79..9b753c5 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -233,7 +233,7 @@ "greeting": "Bienvenue, {name} 👋", "active_ads_label": "Annonces actives", "budget_label": "Budget", - "budget_placeholder": "£0", + "budget_placeholder": "0 $", "create_first_ad": "+ Créer votre première annonce" }, "AdWizard": { @@ -252,7 +252,7 @@ "bid_label": "Enchère par clic", "bid_hint": "Définissez le montant que vous payez par clic. Les enchères plus élevées sont mieux classées.", "bid_per_click": "par clic", - "bid_minimum": "Minimum £0.10 par clic", + "bid_minimum": "Minimum 0,10 $ par clic", "button_next": "Suivant →", "button_back": "← Retour", "button_saving": "Enregistrement...", From 2c88a2e46416ddf705d5f015130fdde2852a6ed7 Mon Sep 17 00:00:00 2001 From: denrod25-del Date: Mon, 24 Aug 2026 16:50:23 -0400 Subject: [PATCH 06/15] feat: add billing ledger schema and webhook secret Adds advertisers.balanceCents (prepaid balance) and the append-only billing_transactions ledger table, plus the STRIPE_WEBHOOK_SECRET env var needed to verify incoming Stripe webhook events. --- .env | 3 + migrations/0015_youthful_gressill.sql | 16 + migrations/meta/0015_snapshot.json | 1319 +++++++++++++++++++++++++ migrations/meta/_journal.json | 7 + src/libs/Env.ts | 2 + src/models/Schema.ts | 17 + 6 files changed, 1364 insertions(+) create mode 100644 migrations/0015_youthful_gressill.sql create mode 100644 migrations/meta/0015_snapshot.json diff --git a/.env b/.env index db105ef..a2b4c34 100644 --- a/.env +++ b/.env @@ -43,3 +43,6 @@ OPENWEATHER_API_KEY=test_key_not_real # Shared secret for the news refresh cron endpoint. Real value in .env.local / production. CRON_SECRET=dev_cron_secret + +# Stripe webhook signing secret (whsec_...). Real value in .env.local / production. +STRIPE_WEBHOOK_SECRET=whsec_dev_placeholder diff --git a/migrations/0015_youthful_gressill.sql b/migrations/0015_youthful_gressill.sql new file mode 100644 index 0000000..2c4370c --- /dev/null +++ b/migrations/0015_youthful_gressill.sql @@ -0,0 +1,16 @@ +CREATE TABLE "billing_transactions" ( + "id" serial PRIMARY KEY NOT NULL, + "advertiser_id" integer NOT NULL, + "kind" text NOT NULL, + "amount_cents" integer NOT NULL, + "balance_after_cents" integer NOT NULL, + "ad_id" integer, + "stripe_session_id" text, + "description" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "billing_transactions_stripe_session_id_unique" UNIQUE("stripe_session_id") +); +--> statement-breakpoint +ALTER TABLE "advertisers" ADD COLUMN "balance_cents" integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE "billing_transactions" ADD CONSTRAINT "billing_transactions_advertiser_id_advertisers_id_fk" FOREIGN KEY ("advertiser_id") REFERENCES "public"."advertisers"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "billing_transactions" ADD CONSTRAINT "billing_transactions_ad_id_ads_id_fk" FOREIGN KEY ("ad_id") REFERENCES "public"."ads"("id") ON DELETE no action ON UPDATE no action; \ No newline at end of file diff --git a/migrations/meta/0015_snapshot.json b/migrations/meta/0015_snapshot.json new file mode 100644 index 0000000..4446148 --- /dev/null +++ b/migrations/meta/0015_snapshot.json @@ -0,0 +1,1319 @@ +{ + "id": "7aa5f76c-cee1-4026-a268-8f89a730eec9", + "prevId": "de345019-c5be-4257-ae1b-532f3e28f9ae", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.ad_clicks": { + "name": "ad_clicks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "ad_id": { + "name": "ad_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "query": { + "name": "query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "clicked_at": { + "name": "clicked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "ad_clicks_ad_id_ads_id_fk": { + "name": "ad_clicks_ad_id_ads_id_fk", + "tableFrom": "ad_clicks", + "tableTo": "ads", + "columnsFrom": [ + "ad_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ads": { + "name": "ads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "advertiser_id": { + "name": "advertiser_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "advertiser_name": { + "name": "advertiser_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_url": { + "name": "display_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cta_text": { + "name": "cta_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "keywords": { + "name": "keywords", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "bid_amount": { + "name": "bid_amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "reviewed_by": { + "name": "reviewed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "ads_advertiser_id_advertisers_id_fk": { + "name": "ads_advertiser_id_advertisers_id_fk", + "tableFrom": "ads", + "tableTo": "advertisers", + "columnsFrom": [ + "advertiser_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.advertisers": { + "name": "advertisers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "clerk_user_id": { + "name": "clerk_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "balance_cents": { + "name": "balance_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "advertisers_clerk_user_id_unique": { + "name": "advertisers_clerk_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "clerk_user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing_transactions": { + "name": "billing_transactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "advertiser_id": { + "name": "advertiser_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount_cents": { + "name": "amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "balance_after_cents": { + "name": "balance_after_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "ad_id": { + "name": "ad_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "stripe_session_id": { + "name": "stripe_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "billing_transactions_advertiser_id_advertisers_id_fk": { + "name": "billing_transactions_advertiser_id_advertisers_id_fk", + "tableFrom": "billing_transactions", + "tableTo": "advertisers", + "columnsFrom": [ + "advertiser_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "billing_transactions_ad_id_ads_id_fk": { + "name": "billing_transactions_ad_id_ads_id_fk", + "tableFrom": "billing_transactions", + "tableTo": "ads", + "columnsFrom": [ + "ad_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "billing_transactions_stripe_session_id_unique": { + "name": "billing_transactions_stripe_session_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_session_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.counter": { + "name": "counter", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_appointments": { + "name": "crm_appointments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "owner_clerk_user_id": { + "name": "owner_clerk_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "start_at": { + "name": "start_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "end_at": { + "name": "end_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'scheduled'" + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "crm_appointments_contact_id_crm_contacts_id_fk": { + "name": "crm_appointments_contact_id_crm_contacts_id_fk", + "tableFrom": "crm_appointments", + "tableTo": "crm_contacts", + "columnsFrom": [ + "contact_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_booking_settings": { + "name": "crm_booking_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "owner_clerk_user_id": { + "name": "owner_clerk_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "business_name": { + "name": "business_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "default_duration_minutes": { + "name": "default_duration_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 60 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "crm_booking_settings_owner_clerk_user_id_unique": { + "name": "crm_booking_settings_owner_clerk_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "owner_clerk_user_id" + ] + }, + "crm_booking_settings_slug_unique": { + "name": "crm_booking_settings_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_contacts": { + "name": "crm_contacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "owner_clerk_user_id": { + "name": "owner_clerk_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "company": { + "name": "company", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_invoices": { + "name": "crm_invoices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "owner_clerk_user_id": { + "name": "owner_clerk_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quote_id": { + "name": "quote_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "contact_id": { + "name": "contact_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "line_items": { + "name": "line_items", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "total": { + "name": "total", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "amount_paid": { + "name": "amount_paid", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cost": { + "name": "cost", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "due_at": { + "name": "due_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "paid_at": { + "name": "paid_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "payment_url": { + "name": "payment_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payment_ref": { + "name": "payment_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "crm_invoices_quote_id_crm_quotes_id_fk": { + "name": "crm_invoices_quote_id_crm_quotes_id_fk", + "tableFrom": "crm_invoices", + "tableTo": "crm_quotes", + "columnsFrom": [ + "quote_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "crm_invoices_contact_id_crm_contacts_id_fk": { + "name": "crm_invoices_contact_id_crm_contacts_id_fk", + "tableFrom": "crm_invoices", + "tableTo": "crm_contacts", + "columnsFrom": [ + "contact_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_messages": { + "name": "crm_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "owner_clerk_user_id": { + "name": "owner_clerk_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "crm_messages_contact_id_crm_contacts_id_fk": { + "name": "crm_messages_contact_id_crm_contacts_id_fk", + "tableFrom": "crm_messages", + "tableTo": "crm_contacts", + "columnsFrom": [ + "contact_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_opportunities": { + "name": "crm_opportunities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "owner_clerk_user_id": { + "name": "owner_clerk_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stage": { + "name": "stage", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'lead'" + }, + "value": { + "name": "value", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "crm_opportunities_contact_id_crm_contacts_id_fk": { + "name": "crm_opportunities_contact_id_crm_contacts_id_fk", + "tableFrom": "crm_opportunities", + "tableTo": "crm_contacts", + "columnsFrom": [ + "contact_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_quotes": { + "name": "crm_quotes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "owner_clerk_user_id": { + "name": "owner_clerk_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "line_items": { + "name": "line_items", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "total": { + "name": "total", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "crm_quotes_contact_id_crm_contacts_id_fk": { + "name": "crm_quotes_contact_id_crm_contacts_id_fk", + "tableFrom": "crm_quotes", + "tableTo": "crm_contacts", + "columnsFrom": [ + "contact_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_workflow_runs": { + "name": "crm_workflow_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "owner_clerk_user_id": { + "name": "owner_clerk_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "crm_workflow_runs_workflow_id_crm_workflows_id_fk": { + "name": "crm_workflow_runs_workflow_id_crm_workflows_id_fk", + "tableFrom": "crm_workflow_runs", + "tableTo": "crm_workflows", + "columnsFrom": [ + "workflow_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "crm_workflow_runs_contact_id_crm_contacts_id_fk": { + "name": "crm_workflow_runs_contact_id_crm_contacts_id_fk", + "tableFrom": "crm_workflow_runs", + "tableTo": "crm_contacts", + "columnsFrom": [ + "contact_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_workflows": { + "name": "crm_workflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "owner_clerk_user_id": { + "name": "owner_clerk_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "trigger_config": { + "name": "trigger_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "action_type": { + "name": "action_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action_config": { + "name": "action_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.news_articles": { + "name": "news_articles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "fetched_at": { + "name": "fetched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "news_articles_url_unique": { + "name": "news_articles_url_unique", + "nullsNotDistinct": false, + "columns": [ + "url" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.news_preferences": { + "name": "news_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "clerk_user_id": { + "name": "clerk_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hidden_sources": { + "name": "hidden_sources", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "news_preferences_clerk_user_id_unique": { + "name": "news_preferences_clerk_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "clerk_user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index a0b7c14..3f884b5 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -106,6 +106,13 @@ "when": 1786220641562, "tag": "0014_tiresome_sleepwalker", "breakpoints": true + }, + { + "idx": 15, + "version": "7", + "when": 1787604567825, + "tag": "0015_youthful_gressill", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/libs/Env.ts b/src/libs/Env.ts index 95157f8..bb6dffa 100644 --- a/src/libs/Env.ts +++ b/src/libs/Env.ts @@ -16,6 +16,7 @@ export const Env = createEnv({ TWILIO_AUTH_TOKEN: z.string().optional(), TWILIO_FROM_NUMBER: z.string().optional(), STRIPE_SECRET_KEY: z.string().optional(), + STRIPE_WEBHOOK_SECRET: z.string().optional(), ANTHROPIC_API_KEY: z.string().optional(), }, client: { @@ -43,6 +44,7 @@ export const Env = createEnv({ TWILIO_AUTH_TOKEN: process.env.TWILIO_AUTH_TOKEN, TWILIO_FROM_NUMBER: process.env.TWILIO_FROM_NUMBER, STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY, + STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET, ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY, }, skipValidation: process.env.NODE_ENV === 'test', diff --git a/src/models/Schema.ts b/src/models/Schema.ts index 35c2619..b5fb585 100644 --- a/src/models/Schema.ts +++ b/src/models/Schema.ts @@ -37,6 +37,7 @@ export const advertisers = pgTable('advertisers', { clerkUserId: text('clerk_user_id').notNull().unique(), email: text('email').notNull(), name: text('name').notNull(), + balanceCents: integer('balance_cents').notNull().default(0), createdAt: timestamp('created_at', { mode: 'date' }).notNull().defaultNow(), }); @@ -261,3 +262,19 @@ export const crmWorkflowRuns = pgTable('crm_workflow_runs', { detail: text('detail'), createdAt: timestamp('created_at', { mode: 'date' }).notNull().defaultNow(), }); + +// Append-only money ledger. Rows are never updated or deleted; the cached +// advertisers.balanceCents is always derivable from SUM(amount_cents). +export const billingTransactions = pgTable('billing_transactions', { + id: serial('id').primaryKey(), + advertiserId: integer('advertiser_id') + .notNull() + .references(() => advertisers.id), + kind: text('kind').notNull(), + amountCents: integer('amount_cents').notNull(), + balanceAfterCents: integer('balance_after_cents').notNull(), + adId: integer('ad_id').references(() => ads.id), + stripeSessionId: text('stripe_session_id').unique(), + description: text('description').notNull(), + createdAt: timestamp('created_at').notNull().defaultNow(), +}); From cfd30c67c47c11e2431fc9523689bb3b3f196900 Mon Sep 17 00:00:00 2001 From: denrod25-del Date: Mon, 24 Aug 2026 16:57:23 -0400 Subject: [PATCH 07/15] feat: add billing ledger with idempotent top-up credit --- src/libs/billing.test.ts | 143 +++++++++++++++++++++++++++++++++++++++ src/libs/billing.ts | 128 +++++++++++++++++++++++++++++++++++ 2 files changed, 271 insertions(+) create mode 100644 src/libs/billing.test.ts create mode 100644 src/libs/billing.ts diff --git a/src/libs/billing.test.ts b/src/libs/billing.test.ts new file mode 100644 index 0000000..08a71c1 --- /dev/null +++ b/src/libs/billing.test.ts @@ -0,0 +1,143 @@ +import { eq, sql } from 'drizzle-orm'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { advertisers, billingTransactions } from '@/models/Schema'; +import { + chargeForClick, + creditTopUp, + getBalanceCents, + listTransactions, +} from './billing'; +import { db } from './DB'; + +describe('billing', () => { + let advertiserId: number; + + beforeEach(async () => { + const [row] = await db + .insert(advertisers) + .values({ + clerkUserId: `billing_test_${crypto.randomUUID()}`, + email: 'billing@example.com', + name: 'Billing Test', + }) + .returning(); + advertiserId = row!.id; + }); + + afterEach(async () => { + await db + .delete(billingTransactions) + .where(eq(billingTransactions.advertiserId, advertiserId)); + await db.delete(advertisers).where(eq(advertisers.id, advertiserId)); + }); + + describe('creditTopUp', () => { + it('increases the balance and writes a ledger row', async () => { + await creditTopUp({ + advertiserId, + amountCents: 2500, + stripeSessionId: `cs_test_${crypto.randomUUID()}`, + }); + + expect(await getBalanceCents(advertiserId)).toBe(2500); + + const rows = await listTransactions(advertiserId, 10); + expect(rows).toHaveLength(1); + expect(rows[0]?.kind).toBe('topup'); + expect(rows[0]?.amountCents).toBe(2500); + expect(rows[0]?.balanceAfterCents).toBe(2500); + }); + + it('accumulates across multiple top-ups', async () => { + await creditTopUp({ + advertiserId, + amountCents: 2500, + stripeSessionId: `cs_a_${crypto.randomUUID()}`, + }); + await creditTopUp({ + advertiserId, + amountCents: 1000, + stripeSessionId: `cs_b_${crypto.randomUUID()}`, + }); + + expect(await getBalanceCents(advertiserId)).toBe(3500); + }); + + it('ignores a duplicate stripe session id', async () => { + const sessionId = `cs_dup_${crypto.randomUUID()}`; + + const first = await creditTopUp({ + advertiserId, + amountCents: 2500, + stripeSessionId: sessionId, + }); + const second = await creditTopUp({ + advertiserId, + amountCents: 2500, + stripeSessionId: sessionId, + }); + + expect(first).toBe('credited'); + expect(second).toBe('duplicate'); + expect(await getBalanceCents(advertiserId)).toBe(2500); + expect(await listTransactions(advertiserId, 10)).toHaveLength(1); + }); + }); + + describe('chargeForClick', () => { + it('decreases the balance and records a negative amount', async () => { + await creditTopUp({ + advertiserId, + amountCents: 1000, + stripeSessionId: `cs_c_${crypto.randomUUID()}`, + }); + + await chargeForClick({ + advertiserId, + amountCents: 50, + adId: null, + description: 'Click on Test ad', + }); + + expect(await getBalanceCents(advertiserId)).toBe(950); + + const rows = await listTransactions(advertiserId, 10); + expect(rows[0]?.kind).toBe('click_charge'); + expect(rows[0]?.amountCents).toBe(-50); + expect(rows[0]?.balanceAfterCents).toBe(950); + }); + }); + + it('keeps the cached balance equal to the ledger sum', async () => { + await creditTopUp({ + advertiserId, + amountCents: 2500, + stripeSessionId: `cs_d_${crypto.randomUUID()}`, + }); + await chargeForClick({ + advertiserId, + amountCents: 50, + adId: null, + description: 'Click', + }); + await chargeForClick({ + advertiserId, + amountCents: 75, + adId: null, + description: 'Click', + }); + + const [summed] = await db + .select({ + total: + sql`coalesce(sum(${billingTransactions.amountCents}), 0)`.mapWith( + Number + ), + }) + .from(billingTransactions) + .where(eq(billingTransactions.advertiserId, advertiserId)); + + expect(await getBalanceCents(advertiserId)).toBe(summed?.total); + expect(summed?.total).toBe(2375); + }); +}); diff --git a/src/libs/billing.ts b/src/libs/billing.ts new file mode 100644 index 0000000..93b8d00 --- /dev/null +++ b/src/libs/billing.ts @@ -0,0 +1,128 @@ +import { desc, eq, sql } from 'drizzle-orm'; +import { advertisers, billingTransactions } from '@/models/Schema'; +import { db } from './DB'; + +type CreditTopUpInput = { + advertiserId: number; + amountCents: number; + stripeSessionId: string; +}; + +type ChargeForClickInput = { + advertiserId: number; + amountCents: number; + adId: number | null; + description: string; +}; + +/** + * Reads an advertiser's cached balance. + * @param advertiserId - The advertiser's id. + * @returns The balance in cents, or 0 if the advertiser does not exist. + */ +export async function getBalanceCents(advertiserId: number): Promise { + const [row] = await db + .select({ balanceCents: advertisers.balanceCents }) + .from(advertisers) + .where(eq(advertisers.id, advertiserId)); + + return row?.balanceCents ?? 0; +} + +/** + * Lists an advertiser's ledger transactions, newest first. + * @param advertiserId - The advertiser's id. + * @param limit - Maximum number of rows to return. + * @returns The matching ledger rows, ordered by creation time descending. + */ +export function listTransactions(advertiserId: number, limit: number) { + return db + .select() + .from(billingTransactions) + .where(eq(billingTransactions.advertiserId, advertiserId)) + .orderBy(desc(billingTransactions.createdAt), desc(billingTransactions.id)) + .limit(limit); +} + +/** + * Credits an advertiser's balance for a completed Stripe checkout, recording + * a `topup` ledger row. Idempotent on `stripeSessionId`: Stripe retries + * webhook deliveries, and a session already credited must not be credited + * again. + * @param input - The advertiser, amount, and Stripe session identifying the top-up. + * @returns `'credited'` when the balance was updated, `'duplicate'` when the + * session id was already recorded and nothing changed. + * @throws When the advertiser does not exist. + */ +export async function creditTopUp( + input: CreditTopUpInput +): Promise<'credited' | 'duplicate'> { + const result = await db.transaction(async (tx) => { + const [existing] = await tx + .select({ id: billingTransactions.id }) + .from(billingTransactions) + .where(eq(billingTransactions.stripeSessionId, input.stripeSessionId)); + + if (existing) { + return 'duplicate'; + } + + const [updated] = await tx + .update(advertisers) + .set({ + balanceCents: sql`${advertisers.balanceCents} + ${input.amountCents}`, + }) + .where(eq(advertisers.id, input.advertiserId)) + .returning({ balanceCents: advertisers.balanceCents }); + + if (!updated) { + throw new Error(`Advertiser ${input.advertiserId} does not exist`); + } + + await tx.insert(billingTransactions).values({ + advertiserId: input.advertiserId, + kind: 'topup', + amountCents: input.amountCents, + balanceAfterCents: updated.balanceCents, + stripeSessionId: input.stripeSessionId, + description: 'Top-up', + }); + + return 'credited'; + }); + + return result; +} + +/** + * Charges an advertiser's balance for an ad click, recording a + * `click_charge` ledger row with a negative amount. + * @param input - The advertiser, charge amount, ad, and description. + * @throws When the advertiser does not exist. + */ +export async function chargeForClick( + input: ChargeForClickInput +): Promise { + await db.transaction(async (tx) => { + const [updated] = await tx + .update(advertisers) + .set({ + balanceCents: sql`${advertisers.balanceCents} - ${input.amountCents}`, + }) + .where(eq(advertisers.id, input.advertiserId)) + .returning({ balanceCents: advertisers.balanceCents }); + + if (!updated) { + throw new Error(`Advertiser ${input.advertiserId} does not exist`); + } + + await tx.insert(billingTransactions).values({ + advertiserId: input.advertiserId, + kind: 'click_charge', + amountCents: -input.amountCents, + balanceAfterCents: updated.balanceCents, + adId: input.adId, + description: input.description, + }); + }); +} From dbca47d1296e32b0eebbb989bb1f35c5a4eab308 Mon Sep 17 00:00:00 2001 From: denrod25-del Date: Mon, 24 Aug 2026 17:00:40 -0400 Subject: [PATCH 08/15] feat: add stripe webhook signature verification Pure function to verify Stripe-Signature headers (HMAC-SHA256, multi-secret rotation support, 5-minute replay tolerance) ahead of the webhook HTTP route. --- src/libs/stripeWebhook.test.ts | 100 ++++++++++++++++++++++++++++++ src/libs/stripeWebhook.ts | 110 +++++++++++++++++++++++++++++++++ 2 files changed, 210 insertions(+) create mode 100644 src/libs/stripeWebhook.test.ts create mode 100644 src/libs/stripeWebhook.ts diff --git a/src/libs/stripeWebhook.test.ts b/src/libs/stripeWebhook.test.ts new file mode 100644 index 0000000..7a2d790 --- /dev/null +++ b/src/libs/stripeWebhook.test.ts @@ -0,0 +1,100 @@ +import { createHmac } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import { verifyStripeSignature } from './stripeWebhook'; + +const SECRET = 'whsec_test_secret'; +const BODY = '{"id":"evt_1","type":"checkout.session.completed"}'; + +function sign(body: string, timestamp: number, secret = SECRET): string { + const signature = createHmac('sha256', secret) + .update(`${timestamp}.${body}`) + .digest('hex'); + return `t=${timestamp},v1=${signature}`; +} + +describe('verifyStripeSignature', () => { + it('accepts a correctly signed recent payload', () => { + const now = Math.floor(Date.now() / 1000); + expect( + verifyStripeSignature({ + body: BODY, + header: sign(BODY, now), + secret: SECRET, + nowSeconds: now, + }) + ).toBe(true); + }); + + it('rejects a payload signed with the wrong secret', () => { + const now = Math.floor(Date.now() / 1000); + expect( + verifyStripeSignature({ + body: BODY, + header: sign(BODY, now, 'whsec_wrong'), + secret: SECRET, + nowSeconds: now, + }) + ).toBe(false); + }); + + it('rejects a tampered body', () => { + const now = Math.floor(Date.now() / 1000); + expect( + verifyStripeSignature({ + body: '{"id":"evt_evil"}', + header: sign(BODY, now), + secret: SECRET, + nowSeconds: now, + }) + ).toBe(false); + }); + + it('rejects a timestamp older than the tolerance', () => { + const now = Math.floor(Date.now() / 1000); + const old = now - 600; + expect( + verifyStripeSignature({ + body: BODY, + header: sign(BODY, old), + secret: SECRET, + nowSeconds: now, + }) + ).toBe(false); + }); + + it('rejects a malformed header', () => { + const now = Math.floor(Date.now() / 1000); + expect( + verifyStripeSignature({ + body: BODY, + header: 'garbage', + secret: SECRET, + nowSeconds: now, + }) + ).toBe(false); + }); + + it('rejects a null header', () => { + const now = Math.floor(Date.now() / 1000); + expect( + verifyStripeSignature({ + body: BODY, + header: null, + secret: SECRET, + nowSeconds: now, + }) + ).toBe(false); + }); + + it('rejects an empty secret', () => { + const now = Math.floor(Date.now() / 1000); + expect( + verifyStripeSignature({ + body: BODY, + header: sign(BODY, now), + secret: '', + nowSeconds: now, + }) + ).toBe(false); + }); +}); diff --git a/src/libs/stripeWebhook.ts b/src/libs/stripeWebhook.ts new file mode 100644 index 0000000..26267ad --- /dev/null +++ b/src/libs/stripeWebhook.ts @@ -0,0 +1,110 @@ +import { createHmac, timingSafeEqual } from 'node:crypto'; + +/** + * Tolerance window, in seconds, for how old a Stripe webhook timestamp may + * be before it is rejected as a possible replay attack. + */ +const TOLERANCE_SECONDS = 300; + +/** Input required to verify a Stripe webhook signature. */ +export type VerifyInput = { + body: string; + header: string | null; + secret: string; + nowSeconds: number; +}; + +type ParsedSignatureHeader = { + timestamp: number; + signatures: string[]; +}; + +/** + * Parses a Stripe `Stripe-Signature` header into its timestamp and the set + * of `v1` signatures it carries. + * + * @param header - The raw `Stripe-Signature` header value. + * @returns The parsed timestamp and `v1` signatures, or `null` if the header + * is malformed (unparsable/non-finite timestamp, or no `v1` entries). + */ +function parseSignatureHeader(header: string): ParsedSignatureHeader | null { + let timestamp: number | null = null; + const signatures: string[] = []; + + for (const part of header.split(',')) { + const [key, value] = part.split('='); + if (key === 't' && value !== undefined) { + timestamp = Number(value); + } else if (key === 'v1' && value !== undefined) { + signatures.push(value); + } + } + + if (timestamp === null || !Number.isFinite(timestamp)) { + return null; + } + if (signatures.length === 0) { + return null; + } + + return { timestamp, signatures }; +} + +/** + * Compares two hex-encoded digests in constant time, treating any mismatch + * in length (including a zero-length candidate) as no match rather than + * throwing. + * + * @param expectedHex - The hex-encoded digest computed from the payload. + * @param candidateHex - A hex-encoded digest parsed from the request header. + * @returns `true` when the digests represent the same bytes. + */ +function matchesDigest(expectedHex: string, candidateHex: string): boolean { + const expected = Buffer.from(expectedHex, 'hex'); + const candidate = Buffer.from(candidateHex, 'hex'); + + if (candidate.length === 0 || candidate.length !== expected.length) { + return false; + } + + return timingSafeEqual(expected, candidate); +} + +/** + * Verifies a Stripe webhook request against its `Stripe-Signature` header. + * + * Validates that at least one `v1` signature in the header matches an + * HMAC-SHA256 digest of `{timestamp}.{body}` keyed by the endpoint's signing + * secret, and that the signed timestamp is within the replay tolerance + * window of the current time. + * + * @param input - The raw body, signature header, signing secret, and the + * current time (in seconds) to check the timestamp against. + * @returns `true` if the request is authentically from Stripe and recent + * enough to accept; `false` otherwise. + */ +export function verifyStripeSignature(input: VerifyInput): boolean { + const { body, header, secret, nowSeconds } = input; + + if (!secret || !header) { + return false; + } + + const parsed = parseSignatureHeader(header); + if (!parsed) { + return false; + } + + const { timestamp, signatures } = parsed; + if (Math.abs(nowSeconds - timestamp) > TOLERANCE_SECONDS) { + return false; + } + + const expectedDigest = createHmac('sha256', secret) + .update(`${timestamp}.${body}`) + .digest('hex'); + + return signatures.some((signature) => + matchesDigest(expectedDigest, signature) + ); +} From 662c1c9f081103da5a4b7b8cd389ed88bac026c6 Mon Sep 17 00:00:00 2001 From: denrod25-del Date: Mon, 24 Aug 2026 17:05:35 -0400 Subject: [PATCH 09/15] feat: add stripe checkout session for balance top-ups --- knip.config.ts | 2 +- src/libs/billingActions.ts | 53 ++++++++++++++++++++++++++++++++ src/libs/payments.ts | 63 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 src/libs/billingActions.ts diff --git a/knip.config.ts b/knip.config.ts index 1501b72..bb3e2ec 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -2,7 +2,7 @@ import type { KnipConfig } from 'knip'; const config: KnipConfig = { // Standalone entry-point scripts (not imported by the app, run directly) - entry: ['scripts/*.ts'], + entry: ['scripts/*.ts', 'src/libs/billingActions.ts'], // Files to exclude from Knip analysis ignore: [ 'checkly.config.ts', diff --git a/src/libs/billingActions.ts b/src/libs/billingActions.ts new file mode 100644 index 0000000..91aea19 --- /dev/null +++ b/src/libs/billingActions.ts @@ -0,0 +1,53 @@ +'use server'; + +import { currentUser } from '@clerk/nextjs/server'; +import { eq } from 'drizzle-orm'; +import { advertisers } from '@/models/Schema'; +import { db } from './DB'; +import { createTopUpLink } from './payments'; + +const MIN_TOPUP_CENTS = 1000; +const MAX_TOPUP_CENTS = 50_000; + +/** + * Starts a Stripe Checkout session to top up the signed-in advertiser's balance. + * @param amountCents - The amount to add, in cents. + * @returns The checkout URL to redirect to, or an error message. + */ +export async function createTopUpSession( + amountCents: number +): Promise<{ url: string } | { error: string }> { + const user = await currentUser(); + if (!user) { + return { error: 'Not signed in' }; + } + + if ( + !Number.isInteger(amountCents) || + amountCents < MIN_TOPUP_CENTS || + amountCents > MAX_TOPUP_CENTS + ) { + return { error: 'Enter an amount between $10 and $500' }; + } + + const [advertiser] = await db + .select({ id: advertisers.id }) + .from(advertisers) + .where(eq(advertisers.clerkUserId, user.id)) + .limit(1); + + if (!advertiser) { + return { error: 'Advertiser account not found' }; + } + + const result = await createTopUpLink({ + advertiserId: advertiser.id, + amountCents, + }); + + if (result.status === 'failed') { + return { error: "Couldn't start checkout" }; + } + + return { url: result.url }; +} diff --git a/src/libs/payments.ts b/src/libs/payments.ts index 69934b7..9dba5d8 100644 --- a/src/libs/payments.ts +++ b/src/libs/payments.ts @@ -118,3 +118,66 @@ export async function createPaymentLink( return { status: 'failed' }; } } + +export type CreateTopUpInput = { + advertiserId: number; + amountCents: number; +}; + +export type CreateTopUpResult = + | { status: 'created'; url: string } + | { status: 'failed' }; + +/** + * Creates a hosted Stripe Checkout session for an advertiser balance top-up. + * Falls back to a simulated link when no Stripe key is configured. + * @param input - The advertiser and amount in cents. + * @returns The checkout URL, or a failed result. + */ +export async function createTopUpLink( + input: CreateTopUpInput +): Promise { + const base = appBaseUrl(); + + if (!Env.STRIPE_SECRET_KEY) { + return { + status: 'created', + url: `${base}/en/advertise/billing?topup=simulated`, + }; + } + + try { + const response = await fetch( + 'https://api.stripe.com/v1/checkout/sessions', + { + method: 'POST', + headers: { + Authorization: `Bearer ${Env.STRIPE_SECRET_KEY}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + mode: 'payment', + client_reference_id: String(input.advertiserId), + 'metadata[advertiser_id]': String(input.advertiserId), + success_url: `${base}/en/advertise/billing?topup=pending`, + cancel_url: `${base}/en/advertise/billing`, + 'line_items[0][quantity]': '1', + 'line_items[0][price_data][currency]': 'usd', + 'line_items[0][price_data][unit_amount]': String(input.amountCents), + 'line_items[0][price_data][product_data][name]': + 'Symbolic Ads balance top-up', + }).toString(), + } + ); + + if (!response.ok) { + return { status: 'failed' }; + } + + const data: unknown = await response.json(); + const url = readString(data, 'url'); + return url ? { status: 'created', url } : { status: 'failed' }; + } catch { + return { status: 'failed' }; + } +} From f04f5868a654dc2819390102ed3f40ea2867dd14 Mon Sep 17 00:00:00 2001 From: denrod25-del Date: Mon, 24 Aug 2026 17:08:05 -0400 Subject: [PATCH 10/15] feat: add stripe webhook endpoint for top-up credits --- src/app/api/stripe/webhook/route.ts | 79 +++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 src/app/api/stripe/webhook/route.ts diff --git a/src/app/api/stripe/webhook/route.ts b/src/app/api/stripe/webhook/route.ts new file mode 100644 index 0000000..11c0b1a --- /dev/null +++ b/src/app/api/stripe/webhook/route.ts @@ -0,0 +1,79 @@ +import { eq } from 'drizzle-orm'; +import { NextResponse } from 'next/server'; +import { creditTopUp } from '@/libs/billing'; +import { db } from '@/libs/DB'; +import { Env } from '@/libs/Env'; +import { verifyStripeSignature } from '@/libs/stripeWebhook'; +import { advertisers } from '@/models/Schema'; + +type StripeSession = { + id?: unknown; + payment_status?: unknown; + amount_total?: unknown; + client_reference_id?: unknown; +}; + +type StripeEvent = { + type?: unknown; + data?: { object?: StripeSession }; +}; + +export async function POST(request: Request) { + // The RAW body is required: parsing and reserializing breaks the signature. + const body = await request.text(); + + const valid = verifyStripeSignature({ + body, + header: request.headers.get('stripe-signature'), + secret: Env.STRIPE_WEBHOOK_SECRET ?? '', + nowSeconds: Math.floor(Date.now() / 1000), + }); + + if (!valid) { + return new NextResponse('Invalid signature', { status: 400 }); + } + + let event: StripeEvent; + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + event = JSON.parse(body) as StripeEvent; + } catch { + return new NextResponse('Bad payload', { status: 400 }); + } + + // Acknowledge anything we do not act on so Stripe stops retrying. + if (event.type !== 'checkout.session.completed') { + return NextResponse.json({ received: true }); + } + + const session = event.data?.object; + if (!session || session.payment_status !== 'paid') { + return NextResponse.json({ received: true }); + } + + const sessionId = typeof session.id === 'string' ? session.id : null; + const amountCents = + typeof session.amount_total === 'number' ? session.amount_total : null; + const advertiserId = + typeof session.client_reference_id === 'string' + ? Number(session.client_reference_id) + : Number.NaN; + + if (!(sessionId && amountCents) || !Number.isInteger(advertiserId)) { + return NextResponse.json({ received: true }); + } + + const [advertiser] = await db + .select({ id: advertisers.id }) + .from(advertisers) + .where(eq(advertisers.id, advertiserId)) + .limit(1); + + if (!advertiser) { + return NextResponse.json({ received: true }); + } + + await creditTopUp({ advertiserId, amountCents, stripeSessionId: sessionId }); + + return NextResponse.json({ received: true }); +} From abdb2cb971447eb5fe6cd1de909c468dea4ea201 Mon Sep 17 00:00:00 2001 From: denrod25-del Date: Mon, 24 Aug 2026 17:11:09 -0400 Subject: [PATCH 11/15] feat: charge advertisers per click and gate serving on balance --- src/app/api/ads/click/route.ts | 12 +++++++++++- src/libs/ads.test.ts | 27 +++++++++++++++++++++++++++ src/libs/ads.ts | 8 +++++--- 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/src/app/api/ads/click/route.ts b/src/app/api/ads/click/route.ts index 0b0586f..5daf69a 100644 --- a/src/app/api/ads/click/route.ts +++ b/src/app/api/ads/click/route.ts @@ -1,5 +1,6 @@ import { eq } from 'drizzle-orm'; import { NextResponse } from 'next/server'; +import { chargeForClick } from '@/libs/billing'; import { db } from '@/libs/DB'; import { adClicks, ads } from '@/models/Schema'; @@ -21,8 +22,17 @@ export async function GET(request: Request) { try { await db.insert(adClicks).values({ adId: ad.id, query }); + + if (ad.advertiserId) { + await chargeForClick({ + advertiserId: ad.advertiserId, + amountCents: ad.bidAmount, + adId: ad.id, + description: `Click on "${ad.title}"`, + }); + } } catch { - // Best-effort: record the click if possible, but don't block the redirect + // Best-effort: never block the visitor's redirect on a billing failure. } return new NextResponse(null, { diff --git a/src/libs/ads.test.ts b/src/libs/ads.test.ts index c713a06..fa6a558 100644 --- a/src/libs/ads.test.ts +++ b/src/libs/ads.test.ts @@ -18,6 +18,11 @@ describe('selectAds', () => { }) .returning(); advertiserId = adv!.id; + + await db + .update(advertisers) + .set({ balanceCents: 10_000 }) + .where(eq(advertisers.id, advertiserId)); }); afterEach(async () => { @@ -72,4 +77,26 @@ describe('selectAds', () => { const result = await selectAds('running'); expect(result.map((ad) => ad.id)).not.toContain(id); }); + + it('excludes an ad whose advertiser has no balance', async () => { + await db + .update(advertisers) + .set({ balanceCents: 0 }) + .where(eq(advertisers.id, advertiserId)); + + const id = await insertAd('approved', true); + const result = await selectAds('running'); + expect(result.map((ad) => ad.id)).not.toContain(id); + }); + + it('includes an ad whose advertiser has a balance', async () => { + await db + .update(advertisers) + .set({ balanceCents: 500 }) + .where(eq(advertisers.id, advertiserId)); + + const id = await insertAd('approved', true); + const result = await selectAds('running'); + expect(result.map((ad) => ad.id)).toContain(id); + }); }); diff --git a/src/libs/ads.ts b/src/libs/ads.ts index 2fdef44..00c57c7 100644 --- a/src/libs/ads.ts +++ b/src/libs/ads.ts @@ -1,5 +1,5 @@ -import { and, desc, eq, sql } from 'drizzle-orm'; -import { ads } from '@/models/Schema'; +import { and, desc, eq, gt, sql } from 'drizzle-orm'; +import { ads, advertisers } from '@/models/Schema'; import { db } from './DB'; export type Ad = typeof ads.$inferSelect; @@ -38,10 +38,12 @@ export const selectAds = async (query: string): Promise => { const result = await db .select() .from(ads) + .innerJoin(advertisers, eq(ads.advertiserId, advertisers.id)) .where( and( eq(ads.status, 'approved'), eq(ads.active, true), + gt(advertisers.balanceCents, 0), sql`${ads.keywords} && ARRAY[${sql.join( tokens.map((t) => sql`${t}`), sql`, ` @@ -50,5 +52,5 @@ export const selectAds = async (query: string): Promise => { ) .orderBy(desc(ads.bidAmount)) .limit(2); - return result; + return result.map((row) => row.ads); }; From 03247b1c8e94adc443815496ffc3259f61aedd3d Mon Sep 17 00:00:00 2001 From: denrod25-del Date: Mon, 24 Aug 2026 17:16:55 -0400 Subject: [PATCH 12/15] feat: add advertiser billing page with top-up and history --- knip.config.ts | 2 +- .../advertise/billing/TopUpButtons.tsx | 73 ++++++++++ .../(portal)/advertise/billing/page.tsx | 135 ++++++++++++++++++ .../[locale]/(portal)/advertise/layout.tsx | 6 + src/locales/en.json | 22 ++- src/locales/fr.json | 22 ++- src/middleware.ts | 1 + 7 files changed, 258 insertions(+), 3 deletions(-) create mode 100644 src/app/[locale]/(portal)/advertise/billing/TopUpButtons.tsx create mode 100644 src/app/[locale]/(portal)/advertise/billing/page.tsx diff --git a/knip.config.ts b/knip.config.ts index bb3e2ec..1501b72 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -2,7 +2,7 @@ import type { KnipConfig } from 'knip'; const config: KnipConfig = { // Standalone entry-point scripts (not imported by the app, run directly) - entry: ['scripts/*.ts', 'src/libs/billingActions.ts'], + entry: ['scripts/*.ts'], // Files to exclude from Knip analysis ignore: [ 'checkly.config.ts', diff --git a/src/app/[locale]/(portal)/advertise/billing/TopUpButtons.tsx b/src/app/[locale]/(portal)/advertise/billing/TopUpButtons.tsx new file mode 100644 index 0000000..096c0db --- /dev/null +++ b/src/app/[locale]/(portal)/advertise/billing/TopUpButtons.tsx @@ -0,0 +1,73 @@ +'use client'; + +import { useState } from 'react'; +import { createTopUpSession } from '@/libs/billingActions'; + +const PRESETS_CENTS = [2500, 5000, 10_000]; +const MIN_TOPUP_CENTS = 1000; + +export function TopUpButtons(props: { + labels: { custom: string; submit: string }; +}) { + const [custom, setCustom] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + + async function start(amountCents: number) { + setBusy(true); + setError(''); + const result = await createTopUpSession(amountCents); + if ('error' in result) { + setError(result.error); + setBusy(false); + return; + } + window.location.href = result.url; + } + + const customCents = Math.round(Number(custom) * 100); + const customValid = + Number.isFinite(customCents) && customCents >= MIN_TOPUP_CENTS; + + return ( +
+
+ {PRESETS_CENTS.map((cents) => ( + + ))} +
+
+ { + setCustom(event.target.value); + }} + placeholder={props.labels.custom} + value={custom} + /> + +
+ {error &&

{error}

} +
+ ); +} diff --git a/src/app/[locale]/(portal)/advertise/billing/page.tsx b/src/app/[locale]/(portal)/advertise/billing/page.tsx new file mode 100644 index 0000000..20973e7 --- /dev/null +++ b/src/app/[locale]/(portal)/advertise/billing/page.tsx @@ -0,0 +1,135 @@ +import { currentUser } from '@clerk/nextjs/server'; +import { eq } from 'drizzle-orm'; +import { getTranslations, setRequestLocale } from 'next-intl/server'; +import { redirect } from 'next/navigation'; +import { listTransactions } from '@/libs/billing'; +import { db } from '@/libs/DB'; +import { advertisers } from '@/models/Schema'; +import { formatUsd } from '@/utils/Money'; +import { TopUpButtons } from './TopUpButtons'; + +const LOW_BALANCE_CENTS = 500; +const HISTORY_LIMIT = 50; + +function balanceClass(balanceCents: number): string { + if (balanceCents <= 0) { + return 'text-red-400'; + } + if (balanceCents < LOW_BALANCE_CENTS) { + return 'text-amber-400'; + } + return 'text-white'; +} + +export default async function BillingPage(props: { + params: Promise<{ locale: string }>; + searchParams: Promise<{ topup?: string }>; +}) { + const { locale } = await props.params; + setRequestLocale(locale); + const { topup } = await props.searchParams; + const t = await getTranslations('BillingPage'); + + const user = await currentUser(); + if (!user) { + redirect(`/${locale}/advertise/sign-in`); + } + + const [advertiser] = await db + .select() + .from(advertisers) + .where(eq(advertisers.clerkUserId, user.id)) + .limit(1); + + if (!advertiser) { + redirect(`/${locale}/advertise/sign-in`); + } + + const transactions = await listTransactions(advertiser.id, HISTORY_LIMIT); + + return ( +
+

{t('title')}

+ + {topup === 'pending' && ( +
+ {t('topup_pending')}{' '} + + {t('refresh')} + +
+ )} + {topup === 'simulated' && ( +
+ {t('topup_simulated')} +
+ )} + +
+
+ {t('balance_label')} +
+
+ {formatUsd(advertiser.balanceCents)} +
+ {advertiser.balanceCents <= 0 && ( +

{t('no_funds')}

+ )} + {advertiser.balanceCents > 0 && + advertiser.balanceCents < LOW_BALANCE_CENTS && ( +

{t('low_balance')}

+ )} +
+ +
+

{t('topup_title')}

+ +
+ +

{t('history_title')}

+ {transactions.length === 0 ? ( +

+ {t('history_empty')} +

+ ) : ( +
+
+ {t('col_date')} + {t('col_description')} + {t('col_amount')} + {t('col_balance')} +
+ {transactions.map((row) => ( +
+ + {row.createdAt.toISOString().slice(0, 10)} + + {row.description} + = 0 ? 'text-green-400' : 'text-red-400' + } + > + {row.amountCents >= 0 ? '+' : ''} + {formatUsd(row.amountCents)} + + + {formatUsd(row.balanceAfterCents)} + +
+ ))} +
+ )} +
+ ); +} diff --git a/src/app/[locale]/(portal)/advertise/layout.tsx b/src/app/[locale]/(portal)/advertise/layout.tsx index c49bfcb..6934dd6 100644 --- a/src/app/[locale]/(portal)/advertise/layout.tsx +++ b/src/app/[locale]/(portal)/advertise/layout.tsx @@ -42,6 +42,12 @@ export default async function AdvertiseLayout(props: { > {t('nav_my_ads')} + + {t('nav_billing')} +
diff --git a/src/locales/en.json b/src/locales/en.json index bd597bc..3aa37c9 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -142,7 +142,8 @@ }, "AdvertiseLayout": { "nav_dashboard": "Dashboard", - "nav_my_ads": "My ads" + "nav_my_ads": "My ads", + "nav_billing": "Billing" }, "AdsPage": { "title": "My ads", @@ -159,6 +160,25 @@ "status_approved_paused": "Paused", "status_rejected": "Rejected" }, + "BillingPage": { + "title": "Billing", + "balance_label": "Current balance", + "low_balance": "Your balance is running low.", + "no_funds": "Your ads are paused - add funds to resume.", + "topup_title": "Add funds", + "topup_custom_placeholder": "Custom amount", + "topup_button": "Add funds", + "topup_pending": "Payment received - your balance will update in a few seconds.", + "topup_simulated": "Simulated top-up (no Stripe key configured).", + "refresh": "Refresh", + "history_title": "Transaction history", + "history_empty": "No transactions yet.", + "col_date": "Date", + "col_description": "Description", + "col_amount": "Amount", + "col_balance": "Balance", + "error_generic": "Couldn't start checkout." + }, "AdminLayout": { "logo": "Symbolic Admin", "nav_dashboard": "Dashboard", diff --git a/src/locales/fr.json b/src/locales/fr.json index 9b753c5..3b734ca 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -142,7 +142,8 @@ }, "AdvertiseLayout": { "nav_dashboard": "Tableau de bord", - "nav_my_ads": "Mes annonces" + "nav_my_ads": "Mes annonces", + "nav_billing": "Facturation" }, "AdsPage": { "title": "Mes annonces", @@ -159,6 +160,25 @@ "status_approved_paused": "En pause", "status_rejected": "Rejeté" }, + "BillingPage": { + "title": "Facturation", + "balance_label": "Solde actuel", + "low_balance": "Votre solde est faible.", + "no_funds": "Vos annonces sont en pause - ajoutez des fonds pour reprendre.", + "topup_title": "Ajouter des fonds", + "topup_custom_placeholder": "Montant personnalisé", + "topup_button": "Ajouter des fonds", + "topup_pending": "Paiement reçu - votre solde sera mis à jour dans quelques secondes.", + "topup_simulated": "Rechargement simulé (aucune clé Stripe configurée).", + "refresh": "Actualiser", + "history_title": "Historique des transactions", + "history_empty": "Aucune transaction pour le moment.", + "col_date": "Date", + "col_description": "Description", + "col_amount": "Montant", + "col_balance": "Solde", + "error_generic": "Impossible de démarrer le paiement." + }, "AdminLayout": { "logo": "Symbolic Admin", "nav_dashboard": "Dashboard", diff --git a/src/middleware.ts b/src/middleware.ts index 7543eef..0d230f9 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -9,6 +9,7 @@ const isProtectedRoute = createRouteMatcher([ '/:locale/advertise/dashboard(.*)', '/:locale/advertise/ads(.*)', '/:locale/advertise/create(.*)', + '/:locale/advertise/billing(.*)', '/:locale/crm(.*)', '/:locale/admin(.*)', ]); From 679204b821c9b714f9dc11a2b241ca6c0a6ea0fd Mon Sep 17 00:00:00 2001 From: denrod25-del Date: Mon, 24 Aug 2026 17:19:53 -0400 Subject: [PATCH 13/15] feat: surface advertiser balance in dashboard and admin list --- .../(admin)/admin/advertisers/page.tsx | 7 +++-- .../(portal)/advertise/dashboard/page.tsx | 27 ++++++++++++++++++- src/locales/en.json | 7 ++++- src/locales/fr.json | 7 ++++- 4 files changed, 43 insertions(+), 5 deletions(-) diff --git a/src/app/[locale]/(admin)/admin/advertisers/page.tsx b/src/app/[locale]/(admin)/admin/advertisers/page.tsx index 7f38532..49a7d5b 100644 --- a/src/app/[locale]/(admin)/admin/advertisers/page.tsx +++ b/src/app/[locale]/(admin)/admin/advertisers/page.tsx @@ -3,6 +3,7 @@ import { getTranslations, setRequestLocale } from 'next-intl/server'; import Link from 'next/link'; import { db } from '@/libs/DB'; import { adClicks, ads, advertisers } from '@/models/Schema'; +import { formatUsd } from '@/utils/Money'; export default async function AdminAdvertisersPage(props: { params: Promise<{ locale: string }>; @@ -43,23 +44,25 @@ export default async function AdminAdvertisersPage(props: {

) : (
-
+
{t('col_name')} {t('col_email')} {t('col_ads')} {t('col_clicks')} + {t('col_balance')} {t('col_joined')}
{allAdvertisers.map((advertiser) => (
{advertiser.name} {advertiser.email} {adsByAdvertiser.get(advertiser.id) ?? 0} {clicksByAdvertiser.get(advertiser.id) ?? 0} + {formatUsd(advertiser.balanceCents)} {advertiser.createdAt.toISOString().slice(0, 10)} diff --git a/src/app/[locale]/(portal)/advertise/dashboard/page.tsx b/src/app/[locale]/(portal)/advertise/dashboard/page.tsx index 1b2b0c6..d23dbae 100644 --- a/src/app/[locale]/(portal)/advertise/dashboard/page.tsx +++ b/src/app/[locale]/(portal)/advertise/dashboard/page.tsx @@ -6,6 +6,9 @@ import { redirect } from 'next/navigation'; import { ensureAdvertiser } from '@/libs/advertisers'; import { db } from '@/libs/DB'; import { ads, advertisers } from '@/models/Schema'; +import { formatUsd } from '@/utils/Money'; + +const LOW_BALANCE_CENTS = 500; export default async function DashboardPage(props: { params: Promise<{ locale: string }>; @@ -38,12 +41,30 @@ export default async function DashboardPage(props: { : []; const activeAdsCount = activeAdsRows.length; + const balanceCents = advertiser?.balanceCents ?? 0; return (

{t('greeting', { name })}

-
+ {balanceCents <= 0 && ( +
+ {t('no_funds_warning')}{' '} + + {t('manage_billing')} + +
+ )} + {balanceCents > 0 && balanceCents < LOW_BALANCE_CENTS && ( +
+ {t('low_balance_warning')}{' '} + + {t('manage_billing')} + +
+ )} + +

{t('active_ads_label')}

{activeAdsCount}

@@ -52,6 +73,10 @@ export default async function DashboardPage(props: {

{t('budget_label')}

{t('budget_placeholder')}

+
+

{t('balance_label')}

+

{formatUsd(balanceCents)}

+
Date: Mon, 24 Aug 2026 17:20:38 -0400 Subject: [PATCH 14/15] docs: add billing ops notes --- README.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/README.md b/README.md index 6b5dd93..743dc33 100644 --- a/README.md +++ b/README.md @@ -649,3 +649,29 @@ Looking for a custom boilerplate to kick off your project? I'd be glad to discus `*/15 * * * * curl -s -H "Authorization: Bearer $CRON_SECRET" https://bsymbolic.com/api/news/refresh` - Production env requires: `OPENWEATHER_API_KEY` (One Call 3.0), `CRON_SECRET`. - New tables (`news_articles`, `news_preferences`) ship in `migrations/0004_*.sql` — apply manually on the VPS. + +## Billing ops + +Advertisers prepay a balance; each ad click deducts their bid. Money movements +are recorded in the append-only `billing_transactions` ledger, and +`advertisers.balance_cents` is a cached figure always equal to +`SUM(amount_cents)` for that advertiser. Ads stop serving when the balance +reaches zero. + +Production env vars: + +- `STRIPE_SECRET_KEY` — start with `sk_test_...` and verify the whole flow + before switching to a live key +- `STRIPE_WEBHOOK_SECRET` — the `whsec_...` signing secret from the Stripe + dashboard webhook endpoint + +Stripe dashboard setup: add a webhook endpoint at +`https://bsymbolic.com/api/stripe/webhook` subscribed to +`checkout.session.completed`. + +Test in Stripe test mode with card `4242 4242 4242 4242`, any future expiry, any +CVC. Confirm the balance credits within a few seconds of paying, and that +resending the same event from the Stripe dashboard does **not** double-credit. + +With no `STRIPE_SECRET_KEY` set, top-ups fall back to a simulated link and no +money moves — useful for local development. From d7ae77e6ab3273fca039c5724cf3ed8f9a2e1ac9 Mon Sep 17 00:00:00 2001 From: denrod25-del Date: Mon, 24 Aug 2026 17:30:56 -0400 Subject: [PATCH 15/15] fix: translate billing errors and acknowledge duplicate webhook races --- .../advertise/billing/TopUpButtons.tsx | 15 ++++++++++++--- .../(portal)/advertise/billing/page.tsx | 6 ++++++ src/app/api/stripe/webhook/route.ts | 11 ++++++++++- src/libs/billingActions.ts | 18 ++++++++++++------ src/locales/en.json | 5 ++++- src/locales/fr.json | 5 ++++- 6 files changed, 48 insertions(+), 12 deletions(-) diff --git a/src/app/[locale]/(portal)/advertise/billing/TopUpButtons.tsx b/src/app/[locale]/(portal)/advertise/billing/TopUpButtons.tsx index 096c0db..78996e1 100644 --- a/src/app/[locale]/(portal)/advertise/billing/TopUpButtons.tsx +++ b/src/app/[locale]/(portal)/advertise/billing/TopUpButtons.tsx @@ -2,12 +2,17 @@ import { useState } from 'react'; import { createTopUpSession } from '@/libs/billingActions'; +import { formatUsd } from '@/utils/Money'; const PRESETS_CENTS = [2500, 5000, 10_000]; const MIN_TOPUP_CENTS = 1000; export function TopUpButtons(props: { - labels: { custom: string; submit: string }; + labels: { + custom: string; + submit: string; + errors: Record; + }; }) { const [custom, setCustom] = useState(''); const [busy, setBusy] = useState(false); @@ -18,7 +23,11 @@ export function TopUpButtons(props: { setError(''); const result = await createTopUpSession(amountCents); if ('error' in result) { - setError(result.error); + setError( + props.labels.errors[result.error] ?? + props.labels.errors.checkout_failed ?? + '' + ); setBusy(false); return; } @@ -42,7 +51,7 @@ export function TopUpButtons(props: { }} type="button" > - ${cents / 100} + {formatUsd(cents)} ))}
diff --git a/src/app/[locale]/(portal)/advertise/billing/page.tsx b/src/app/[locale]/(portal)/advertise/billing/page.tsx index 20973e7..c07f7f3 100644 --- a/src/app/[locale]/(portal)/advertise/billing/page.tsx +++ b/src/app/[locale]/(portal)/advertise/billing/page.tsx @@ -89,6 +89,12 @@ export default async function BillingPage(props: { labels={{ custom: t('topup_custom_placeholder'), submit: t('topup_button'), + errors: { + not_signed_in: t('error_not_signed_in'), + invalid_amount: t('error_invalid_amount'), + no_account: t('error_no_account'), + checkout_failed: t('error_checkout_failed'), + }, }} />
diff --git a/src/app/api/stripe/webhook/route.ts b/src/app/api/stripe/webhook/route.ts index 11c0b1a..e4dd7ac 100644 --- a/src/app/api/stripe/webhook/route.ts +++ b/src/app/api/stripe/webhook/route.ts @@ -73,7 +73,16 @@ export async function POST(request: Request) { return NextResponse.json({ received: true }); } - await creditTopUp({ advertiserId, amountCents, stripeSessionId: sessionId }); + try { + await creditTopUp({ + advertiserId, + amountCents, + stripeSessionId: sessionId, + }); + } 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. + } return NextResponse.json({ received: true }); } diff --git a/src/libs/billingActions.ts b/src/libs/billingActions.ts index 91aea19..123e274 100644 --- a/src/libs/billingActions.ts +++ b/src/libs/billingActions.ts @@ -9,17 +9,23 @@ import { createTopUpLink } from './payments'; const MIN_TOPUP_CENTS = 1000; const MAX_TOPUP_CENTS = 50_000; +export type TopUpError = + | 'not_signed_in' + | 'invalid_amount' + | 'no_account' + | 'checkout_failed'; + /** * Starts a Stripe Checkout session to top up the signed-in advertiser's balance. * @param amountCents - The amount to add, in cents. - * @returns The checkout URL to redirect to, or an error message. + * @returns The checkout URL to redirect to, or an error code. */ export async function createTopUpSession( amountCents: number -): Promise<{ url: string } | { error: string }> { +): Promise<{ url: string } | { error: TopUpError }> { const user = await currentUser(); if (!user) { - return { error: 'Not signed in' }; + return { error: 'not_signed_in' }; } if ( @@ -27,7 +33,7 @@ export async function createTopUpSession( amountCents < MIN_TOPUP_CENTS || amountCents > MAX_TOPUP_CENTS ) { - return { error: 'Enter an amount between $10 and $500' }; + return { error: 'invalid_amount' }; } const [advertiser] = await db @@ -37,7 +43,7 @@ export async function createTopUpSession( .limit(1); if (!advertiser) { - return { error: 'Advertiser account not found' }; + return { error: 'no_account' }; } const result = await createTopUpLink({ @@ -46,7 +52,7 @@ export async function createTopUpSession( }); if (result.status === 'failed') { - return { error: "Couldn't start checkout" }; + return { error: 'checkout_failed' }; } return { url: result.url }; diff --git a/src/locales/en.json b/src/locales/en.json index 3bb8402..fdd2dd2 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -177,7 +177,10 @@ "col_description": "Description", "col_amount": "Amount", "col_balance": "Balance", - "error_generic": "Couldn't start checkout." + "error_not_signed_in": "Please sign in to add funds.", + "error_invalid_amount": "Enter an amount between $10 and $500.", + "error_no_account": "Advertiser account not found.", + "error_checkout_failed": "Couldn't start checkout." }, "AdminLayout": { "logo": "Symbolic Admin", diff --git a/src/locales/fr.json b/src/locales/fr.json index 9e31538..9b13749 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -177,7 +177,10 @@ "col_description": "Description", "col_amount": "Montant", "col_balance": "Solde", - "error_generic": "Impossible de démarrer le paiement." + "error_not_signed_in": "Connectez-vous pour ajouter des fonds.", + "error_invalid_amount": "Saisissez un montant entre 10 $ et 500 $.", + "error_no_account": "Compte annonceur introuvable.", + "error_checkout_failed": "Impossible de démarrer le paiement." }, "AdminLayout": { "logo": "Symbolic Admin",