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) => (
+ {
+ await start(cents);
+ }}
+ type="button"
+ >
+ ${cents / 100}
+
+ ))}
+
+
+ {
+ setCustom(event.target.value);
+ }}
+ placeholder={props.labels.custom}
+ value={custom}
+ />
+ {
+ await start(customCents);
+ }}
+ type="button"
+ >
+ {props.labels.submit}
+
+
+ {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' && (
+
+ )}
+ {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) => (
+ {
+ await start(cents);
+ }}
+ type="button"
+ >
+ ${cents / 100}
+
+ ))}
+
+
+ {
+ setCustom(event.target.value);
+ }}
+ placeholder={props.labels.custom}
+ value={custom}
+ />
+ {
+ await start(customCents);
+ }}
+ type="button"
+ >
+ {props.labels.submit}
+
+
+ {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' && (
+
+ )}
+ {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",