From feb045467fa60fabdf65e457650bac3d599152ef Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 31 Jul 2026 06:04:09 +0000 Subject: [PATCH] fix(ads): stop free credits converting into withdrawable cash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ad network could pay out real USDC for credits nobody ever bought. Phase 4 argued solvency from a rack<->floor spread: advertisers spend credits valued at 5c, publishers cash out at 2.5c, so 0.7*N*2.5c never exceeds N*5c. That holds only if every credit was SOLD at rack. Two things broke the assumption: 1. Signup grants. credits_balance defaults to 20 free credits (60 before 20260608010000) and admin grants add more. Once in the balance they are indistinguishable from purchased credits, and each one obligated 1.75c of real USDC the moment it funded a click. Production held 16,454 credits against $12.00 of lifetime deposits — ~$288 of liability at 4% coverage. 2. Volume packs. The 100-scan pack sells credits at 2.5c, exactly the publisher floor, so the spread is 1:1 on the deepest tier. The 100% deposit match was granted at rack regardless of what the buyer paid per credit, dropping cash in to 1.67c against 1.75c out. Compounding both: nothing stopped a user owning the campaign and the slot. All 83 valid clicks in production were self-dealt — the entire "Earnings & spend" dashboard was one account's free credits round- tripping through the platform fee. The fix: * profiles.promo_credits tracks the non-cash-backed slice of the balance. Promo-funded clicks still bill the advertiser and count as valid delivery, but accrue nothing to the publisher — there is no cash behind them. Backfilled from purchase history, conservatively. * ad_charge_click refuses to bill or accrue when the slot owner and campaign owner match; serveAd filters those pairs so the impression is not wasted either. * Payout floor 2.5c -> 2.0c (publisher earns 1.4c/credit), and the deposit match is capped so post-match cash in per credit never falls below 1.75c — a 25% margin on every pack. * A trigger on ad_payouts enforces the cumulative solvency invariant in the database, not just in requestPayout(). Also corrects creditsToPayoutCents, which ignored PLATFORM_RATE and overstated publisher balances by 43%. Liability after backfill: $0.43 against $12.00 cash in. Co-Authored-By: Claude Opus 5 (1M context) --- app/actions/admin.ts | 12 +- lib/ads/pricing.ts | 48 ++- lib/ads/serve.ts | 9 +- .../migrations/20260731120000_ad_solvency.sql | 288 ++++++++++++++++++ tests/ad-solvency.test.ts | 131 ++++++++ 5 files changed, 477 insertions(+), 11 deletions(-) create mode 100644 supabase/migrations/20260731120000_ad_solvency.sql create mode 100644 tests/ad-solvency.test.ts diff --git a/app/actions/admin.ts b/app/actions/admin.ts index cee34a65..8cf11b87 100644 --- a/app/actions/admin.ts +++ b/app/actions/admin.ts @@ -71,7 +71,7 @@ export async function grantCredits(input: { const { data: recipient, error: rErr } = await svc .from("profiles") - .select("id, email, credits_balance") + .select("id, email, credits_balance, promo_credits") .ilike("email", email) .maybeSingle(); if (rErr) return { ok: false, error: rErr.message }; @@ -85,9 +85,17 @@ export async function grantCredits(input: { }; } + // Granted credits are not cash-backed: track them as promo so ad clicks they + // fund accrue nothing to publishers. Without this an admin grant could be + // spent on ads and withdrawn as real USDC. Clawbacks reduce promo first, + // clamped to the new balance so the invariant promo <= balance holds. + const promo = recipient.promo_credits ?? 0; + const newPromo = + credits > 0 ? promo + credits : Math.min(Math.max(0, promo + credits), newBalance); + const { error: upErr } = await svc .from("profiles") - .update({ credits_balance: newBalance }) + .update({ credits_balance: newBalance, promo_credits: newPromo }) .eq("id", recipient.id); if (upErr) return { ok: false, error: upErr.message }; diff --git a/lib/ads/pricing.ts b/lib/ads/pricing.ts index badee007..35a73fa3 100644 --- a/lib/ads/pricing.ts +++ b/lib/ads/pricing.ts @@ -5,9 +5,20 @@ // Advertiser spend value per credit (rack). Matches CREDIT_RACK_CENTS. export const CREDIT_CENTS = 5; -// Publisher cash-out value per credit — the FLOOR price (cheapest credit pack). -// The rack↔floor spread is what keeps payouts solvent under a deposit match. -export const CREDIT_FLOOR_CENTS = 2.5; +// Publisher cash-out value per credit. +// +// Deliberately BELOW the cheapest credit pack (2.5c on the 100-scan tier), not +// equal to it. Setting it at the floor price left the deepest pack with a 1:1 +// spread, and a deposit match on top of that pushed cash in per credit under +// the payout rate — see 20260731120000_ad_solvency.sql. At 2.0c the publisher +// earns 1.4c/credit after the platform rate, which keeps a margin on every +// pack (72% at rack, 44% on the deepest) with room for the match. +export const CREDIT_FLOOR_CENTS = 2.0; + +// Minimum real cash that must sit behind every credit granted, in cents. The +// deposit match is capped so a purchase can never dilute below this — a 25% +// margin over the 1.4c publisher payout rate. Mirrors ad_apply_deposit_bonus(). +export const MIN_CASH_PER_CREDIT_CENTS = 1.75; // Default bid / cost-per-click, in credits. 4 credits = $0.20 at rack. export const CPC_CREDITS = 4; @@ -20,14 +31,37 @@ export const PLATFORM_RATE = 0.3; // Minimum publisher balance before a withdrawal can be requested, in cents. export const MIN_PAYOUT_CENTS = 500; // $5.00 -// Deposit-match promo: first deposit is matched 100% in bonus ad credits, -// capped. Mirrors ad_apply_deposit_bonus() in the migration (source of truth). +// Deposit-match promo: the first deposit is matched 100% of the credits +// BOUGHT (not of the dollar amount at rack — that over-granted on discounted +// packs), capped at $100 of rack value and further capped so the deposit never +// dilutes below MIN_CASH_PER_CREDIT_CENTS. ad_apply_deposit_bonus() in +// 20260731120000_ad_solvency.sql is the source of truth. export const DEPOSIT_MATCH_RATE = 1.0; export const MAX_DEPOSIT_MATCH_CENTS = 10000; // $100 -// Publisher cash value of N credits at the floor rate, in whole cents. +// Bonus credits a deposit of `amountCents` buying `credits` earns, matching the +// SQL exactly. Exported so the billing UI can quote the promo without guessing. +export function depositBonusCredits(amountCents: number, credits: number): number { + const solvencyCap = Math.max( + 0, + Math.floor(amountCents / MIN_CASH_PER_CREDIT_CENTS) - credits, + ); + return Math.max( + 0, + Math.min( + Math.floor(credits * DEPOSIT_MATCH_RATE), + Math.floor(MAX_DEPOSIT_MATCH_CENTS / CREDIT_CENTS), + solvencyCap, + ), + ); +} + +// What a publisher actually accrues for a click of N CASH-BACKED credits, in +// whole cents. Clicks funded by promo or bonus credits accrue nothing — there +// is no cash behind them — so callers must pass only the cash-backed slice. +// Mirrors the v_earn expression in ad_charge_click(). export function creditsToPayoutCents(credits: number): number { - return Math.floor(credits * CREDIT_FLOOR_CENTS); + return Math.floor(credits * (1 - PLATFORM_RATE) * CREDIT_FLOOR_CENTS); } export function centsToDollars(cents: number): string { diff --git a/lib/ads/serve.ts b/lib/ads/serve.ts index 17bc5109..fc64d604 100644 --- a/lib/ads/serve.ts +++ b/lib/ads/serve.ts @@ -90,7 +90,7 @@ export async function serveAd( const { data: slot } = await sb .from("ad_slots") - .select("id, status, formats") + .select("id, status, formats, owner_id") .eq("id", slotId) .maybeSingle(); if (!slot || slot.status !== "active") return null; @@ -105,7 +105,7 @@ export async function serveAd( const { data: creatives } = await sb .from("ad_creatives") .select( - "id, campaign_id, format, headline, body, cta_text, image_url, logo_url, bg_color, fg_color, accent_color, font_family, ad_campaigns!inner(id, status, ref_slug, destination_url, daily_budget_cents, spend_today_cents, spend_date, bid_credits)", + "id, campaign_id, format, headline, body, cta_text, image_url, logo_url, bg_color, fg_color, accent_color, font_family, ad_campaigns!inner(id, owner_id, status, ref_slug, destination_url, daily_budget_cents, spend_today_cents, spend_date, bid_credits)", ) .eq("format", format) .eq("status", "ready") @@ -117,6 +117,7 @@ export async function serveAd( type CampaignJoin = { id: string; + owner_id: string; ref_slug: string; destination_url: string; daily_budget_cents: number; @@ -134,6 +135,10 @@ export async function serveAd( const eligible = (creatives as unknown as Row[]).filter((row) => { const c = oneCampaign(row.ad_campaigns); if (!c) return false; + // Never serve someone their own ad on their own slot. ad_charge_click + // refuses to bill or accrue on a self-click anyway; filtering here means we + // don't burn an impression and a redirect on a click that can't earn. + if (slot.owner_id && c.owner_id === slot.owner_id) return false; const spentToday = c.spend_date === today ? c.spend_today_cents : 0; const bid = c.bid_credits ?? DEFAULT_BID_CREDITS; return spentToday + bid * CREDIT_CENTS <= c.daily_budget_cents; diff --git a/supabase/migrations/20260731120000_ad_solvency.sql b/supabase/migrations/20260731120000_ad_solvency.sql new file mode 100644 index 00000000..ea5ffda9 --- /dev/null +++ b/supabase/migrations/20260731120000_ad_solvency.sql @@ -0,0 +1,288 @@ +-- Ad network — Phase 5: make the marketplace solvent. +-- +-- The bug: free credits were convertible into real withdrawable cash. +-- +-- Phase 4 argued solvency from a rack↔floor spread: advertisers spend credits +-- valued at 5c, publishers cash out at 2.5c, so 0.7*N*2.5c <= N*5c and payouts +-- can never exceed cash in. That argument assumes every credit was SOLD at +-- rack. Two things break the assumption: +-- +-- 1. Signup grants. profiles.credits_balance defaults to 20 free credits +-- (60 before 20260608010000), and admin grants add more. These cost a user +-- nothing, are indistinguishable from purchased credits once in the +-- balance, and each one obligated 0.7*2.5c = 1.75c of real USDC the moment +-- it was spent on a click. At the time of writing: 16,454 credits +-- outstanding against $12.00 of lifetime deposits — ~$288 of liability at +-- 4% coverage. +-- 2. Volume packs. The 100-scan pack sells credits at 2.5c, exactly the +-- publisher floor, so the "2:1 spread" is 1:1 on the deepest tier. Layer +-- the 100% deposit match on top (granted at rack regardless of what the +-- buyer actually paid per credit) and cash in per credit falls to 1.67c +-- against 1.75c out — structurally insolvent. +-- +-- The fix, in four parts: +-- A. Credit provenance. profiles.promo_credits tracks the non-cash-backed +-- slice of credits_balance. Clicks funded by promo credits still bill the +-- advertiser and still count as valid delivery — they just accrue nothing +-- to the publisher, because there is no cash behind them to pay out. +-- B. Self-dealing block. A click never earns when the slot owner and the +-- campaign owner are the same account. +-- C. Payout floor 2.5c -> 2.0c (publisher earns 1.4c/credit), and the deposit +-- match is capped so post-match cash in per credit never drops below +-- 1.75c — a 25% margin over the payout rate on every pack. +-- D. A database-level solvency invariant on ad_payouts, so the guard survives +-- anything that writes a payout without going through the server action. +-- +-- Apply via psql over the pooler (prod history diverged), not `db push`. + +-- --------------------------------------------------------------------------- +-- A. Credit provenance +-- --------------------------------------------------------------------------- + +-- The portion of credits_balance that was granted rather than bought. Spent +-- before cash-backed credits, and never accrues publisher cash. +-- +-- Default matches the signup grant in 20260608010000 so a new profile row is +-- born fully promo-funded; credit_purchase_complete adds to credits_balance +-- without touching this column, so purchased credits are cash-backed by +-- construction. +alter table public.profiles + add column if not exists promo_credits integer not null default 20; + +-- Backfill: a user's cash-backed credits can never exceed what they have +-- actually bought, so everything above that is promo. Conservative in the safe +-- direction — it can over-count promo (a user who spent grants on scans looks +-- more promo-funded than they are), which under-accrues publisher cash rather +-- than over-accruing it. +update public.profiles p +set promo_credits = greatest( + 0, + p.credits_balance - coalesce(( + select sum(cp.credits_added) + from public.credit_purchases cp + where cp.owner_id = p.id and cp.status = 'complete' + ), 0) +); + +comment on column public.profiles.promo_credits is + 'Non-cash-backed slice of credits_balance (signup + admin grants). Spent before cash-backed credits; ad clicks funded from it accrue no publisher payout. Always read as least(promo_credits, credits_balance) — other debit paths do not decrement it, and that drift is deliberately conservative.'; + +-- --------------------------------------------------------------------------- +-- B + C. Charge a click: self-deal block, provenance-aware accrual +-- --------------------------------------------------------------------------- + +create or replace function public.ad_charge_click( + p_campaign uuid, + p_slot uuid, + p_creative uuid, + p_impression uuid, + p_visitor text, + p_ip_hash text, + p_country text, + p_device text, + p_cpc_credits int, + p_platform_rate numeric +) returns table(click_id uuid, charged_cents int, publisher_earn_cents int, valid boolean) +language plpgsql security definer set search_path = public as $$ +declare + v_owner uuid; + v_status text; + v_daily int; + v_spend int; + v_date date; + v_paid int; + v_bonus int; + v_promo int; + v_from_bonus int; + v_from_promo int; + v_from_cash int; + v_rest int; + v_slot_owner uuid; + v_charged int; + v_earn int; + v_cut int; + v_click uuid; + v_rack_cents constant int := 5; -- advertiser spend value per credit + v_floor_cents constant numeric := 2.0; -- publisher cash-out value per credit +begin + select owner_id, status, daily_budget_cents, spend_today_cents, spend_date + into v_owner, v_status, v_daily, v_spend, v_date + from public.ad_campaigns where id = p_campaign for update; + if not found then return; end if; + + v_charged := p_cpc_credits * v_rack_cents; + if v_date is distinct from current_date then v_spend := 0; end if; + + -- Not eligible (paused/exhausted or over daily budget): unbilled click. + if v_status <> 'active' or (v_spend + v_charged) > v_daily then + insert into public.ad_clicks(impression_id,slot_id,campaign_id,creative_id,visitor_id,ip_hash,geo_country,device,charged_cents,publisher_earn_cents,platform_cut_cents,valid) + values (p_impression,p_slot,p_campaign,p_creative,p_visitor,p_ip_hash,p_country,p_device,0,0,0,false) + returning id into v_click; + return query select v_click, 0, 0, false; + return; + end if; + + select owner_id into v_slot_owner from public.ad_slots where id = p_slot; + + -- Self-dealing: clicking your own ad on your own slot moves credits into + -- withdrawable cash for free. Bill nobody, earn nobody. Checked before the + -- debit so a self-click doesn't even consume the advertiser's budget. + if v_slot_owner is not null and v_slot_owner = v_owner then + insert into public.ad_clicks(impression_id,slot_id,campaign_id,creative_id,visitor_id,ip_hash,geo_country,device,charged_cents,publisher_earn_cents,platform_cut_cents,valid) + values (p_impression,p_slot,p_campaign,p_creative,p_visitor,p_ip_hash,p_country,p_device,0,0,0,false) + returning id into v_click; + return query select v_click, 0, 0, false; + return; + end if; + + -- Debit advertiser. Row-lock the profile. Spend order is cheapest-to-us + -- first: deposit-match bonus, then promo grants, then cash-backed credits. + select credits_balance, + coalesce(ad_bonus_credits, 0), + least(coalesce(promo_credits, 0), credits_balance) + into v_paid, v_bonus, v_promo + from public.profiles where id = v_owner for update; + + if coalesce(v_paid, 0) + coalesce(v_bonus, 0) < p_cpc_credits then + update public.ad_campaigns set status = 'exhausted' where id = p_campaign; + insert into public.ad_clicks(impression_id,slot_id,campaign_id,creative_id,visitor_id,ip_hash,geo_country,device,charged_cents,publisher_earn_cents,platform_cut_cents,valid) + values (p_impression,p_slot,p_campaign,p_creative,p_visitor,p_ip_hash,p_country,p_device,0,0,0,false) + returning id into v_click; + return query select v_click, 0, 0, false; + return; + end if; + + v_from_bonus := least(v_bonus, p_cpc_credits); + v_rest := p_cpc_credits - v_from_bonus; + v_from_promo := least(v_promo, v_rest); + v_from_cash := v_rest - v_from_promo; + + update public.profiles + set ad_bonus_credits = ad_bonus_credits - v_from_bonus, + credits_balance = credits_balance - (v_from_promo + v_from_cash), + promo_credits = greatest(0, coalesce(promo_credits, 0) - v_from_promo) + where id = v_owner; + + -- Publisher earns at the floor rate, and ONLY on the cash-backed slice of + -- the click. Bonus and promo credits bill the advertiser (so budgets and + -- reporting stay honest) but carry no cash behind them, so they obligate + -- nothing. The platform keeps the remainder, including the rack↔floor spread. + v_earn := floor(v_from_cash * (1 - p_platform_rate) * v_floor_cents); + v_cut := v_charged - v_earn; + + update public.ad_campaigns + set spend_today_cents = v_spend + v_charged, + spend_date = current_date, + total_spent_cents = coalesce(total_spent_cents,0) + v_charged + where id = p_campaign; + + insert into public.ad_clicks(impression_id,slot_id,campaign_id,creative_id,visitor_id,ip_hash,geo_country,device,charged_cents,publisher_earn_cents,platform_cut_cents,valid) + values (p_impression,p_slot,p_campaign,p_creative,p_visitor,p_ip_hash,p_country,p_device,v_charged,v_earn,v_cut,true) + returning id into v_click; + + if v_slot_owner is not null and v_earn > 0 then + insert into public.ad_ledger(kind, owner_id, campaign_id, slot_id, amount_cents, ref_click_id) + values ('publisher_accrual', v_slot_owner, p_campaign, p_slot, v_earn, v_click); + end if; + if v_cut > 0 then + insert into public.ad_ledger(kind, owner_id, campaign_id, slot_id, amount_cents, ref_click_id) + values ('platform_fee', null, p_campaign, p_slot, v_cut, v_click); + end if; + + return query select v_click, v_charged, v_earn, true; +end $$; + +revoke execute on function public.ad_charge_click(uuid,uuid,uuid,uuid,text,text,text,text,int,numeric) from anon, authenticated; +grant execute on function public.ad_charge_click(uuid,uuid,uuid,uuid,text,text,text,text,int,numeric) to service_role; + +-- --------------------------------------------------------------------------- +-- C. Deposit match, capped for solvency +-- --------------------------------------------------------------------------- + +-- The old grant was floor(amount_cents / 5) — denominated at rack regardless of +-- what the buyer actually paid per credit, so a 2.5c/credit pack got matched at +-- 200% of the credits bought. Now: a true 100% match of credits purchased, +-- capped so that amount_cents / (credits_added + bonus) never falls below +-- 1.75c, a 25% margin over the 1.4c publisher payout rate. +create or replace function public.ad_apply_deposit_bonus(p_payment_id text) +returns int language plpgsql security definer set search_path = public as $$ +declare + v_owner uuid; + v_amount int; + v_credits int; + v_status text; + v_already int; + v_prior int; + v_bonus int; + v_cap int; + v_match_rate constant numeric := 1.0; -- 100% match of credits bought + v_max_cents constant int := 10000; -- cap bonus value at $100 rack + v_rack_cents constant int := 5; + v_min_cash_per_credit constant numeric := 1.75; -- solvency floor, in cents +begin + select owner_id, amount_cents, credits_added, status, coalesce(ad_bonus_credits, 0) + into v_owner, v_amount, v_credits, v_status, v_already + from public.credit_purchases where coinpay_payment_id = p_payment_id for update; + if not found or v_status <> 'complete' then return 0; end if; + if v_already > 0 then return 0; end if; -- already granted + + -- First deposit only: any earlier completed purchase disqualifies. + select count(*) into v_prior from public.credit_purchases + where owner_id = v_owner and status = 'complete' and coinpay_payment_id <> p_payment_id; + if v_prior > 0 then return 0; end if; + + -- Solvency cap: the most bonus credits this deposit can carry and still leave + -- at least v_min_cash_per_credit of real cash behind every credit granted. + v_cap := greatest(0, floor(v_amount / v_min_cash_per_credit)::int - v_credits); + + v_bonus := least( + floor(v_credits * v_match_rate)::int, -- 100% of what they bought + floor(v_max_cents / v_rack_cents)::int, -- $100 of rack value + v_cap -- solvency + ); + if v_bonus <= 0 then return 0; end if; + + update public.profiles + set ad_bonus_credits = coalesce(ad_bonus_credits, 0) + v_bonus where id = v_owner; + update public.credit_purchases + set ad_bonus_credits = v_bonus where coinpay_payment_id = p_payment_id; + return v_bonus; +end $$; + +revoke execute on function public.ad_apply_deposit_bonus(text) from anon, authenticated; +grant execute on function public.ad_apply_deposit_bonus(text) to service_role; + +-- --------------------------------------------------------------------------- +-- D. Database-level solvency invariant +-- --------------------------------------------------------------------------- + +-- Cumulative publisher payouts can never exceed cumulative real advertiser cash +-- in. requestPayout() checks this too, but the check belongs where it cannot be +-- bypassed: any path that inserts an ad_payouts row is now covered. +create or replace function public.ad_payout_solvency_guard() +returns trigger language plpgsql security definer set search_path = public as $$ +declare + v_cash_in bigint; + v_paid_out bigint; +begin + if new.status = 'failed' then return new; end if; + + select coalesce(sum(amount_cents), 0) into v_cash_in + from public.credit_purchases where status = 'complete'; + + select coalesce(sum(amount_cents), 0) into v_paid_out + from public.ad_payouts where status <> 'failed' and id <> new.id; + + if v_paid_out + new.amount_cents > v_cash_in then + raise exception 'ad payout would exceed platform cash in (requested %c, already paid %c, cash in %c)', + new.amount_cents, v_paid_out, v_cash_in + using errcode = 'check_violation'; + end if; + + return new; +end $$; + +drop trigger if exists ad_payout_solvency on public.ad_payouts; +create trigger ad_payout_solvency + before insert or update of amount_cents, status on public.ad_payouts + for each row execute function public.ad_payout_solvency_guard(); diff --git a/tests/ad-solvency.test.ts b/tests/ad-solvency.test.ts new file mode 100644 index 00000000..6cff7f49 --- /dev/null +++ b/tests/ad-solvency.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "vitest"; +import { + CREDIT_CENTS, + CREDIT_FLOOR_CENTS, + MIN_CASH_PER_CREDIT_CENTS, + PLATFORM_RATE, + creditsToPayoutCents, + depositBonusCredits, +} from "@/lib/ads/pricing"; +import { CREDIT_PACKS, type CreditPack } from "@/lib/credits"; + +// The ad network can pay publishers real USDC, so the only invariant that +// ultimately matters is: every credit that can fund a click must have more real +// cash behind it than the click can ever pay out. These tests pin that down at +// the pack level, because the previous pricing looked safe in the abstract +// ("advertisers spend at 5c, publishers cash out at 2.5c") and was insolvent on +// the deepest pack once the volume discount and the deposit match were applied. + +/** Real cash received per credit granted, in cents. */ +const cashPerCredit = (pack: CreditPack, bonus = 0) => + pack.amountCents / (pack.credits + bonus); + +/** Cash a publisher can withdraw per cash-backed credit spent, in cents. */ +const payoutPerCredit = (1 - PLATFORM_RATE) * CREDIT_FLOOR_CENTS; + +describe("payout rate", () => { + it("pays 1.4c per cash-backed credit", () => { + expect(payoutPerCredit).toBeCloseTo(1.4, 10); + }); + + it("accrues the platform take, not the full floor value", () => { + // The old helper returned floor(credits * 2.5) and ignored PLATFORM_RATE, + // overstating a publisher's balance by 43%. + expect(creditsToPayoutCents(4)).toBe(5); // floor(4 * 0.7 * 2.0) + expect(creditsToPayoutCents(100)).toBe(140); + expect(creditsToPayoutCents(0)).toBe(0); + }); + + it("never pays more than the advertiser was charged", () => { + for (let credits = 1; credits <= 200; credits++) { + expect(creditsToPayoutCents(credits)).toBeLessThan(credits * CREDIT_CENTS); + } + }); +}); + +describe("credit packs are solvent without a promo", () => { + it.each(CREDIT_PACKS.map((p) => [p.id, p] as const))( + "%s keeps cash in above the payout rate", + (_id, pack) => { + expect(cashPerCredit(pack)).toBeGreaterThan(payoutPerCredit); + }, + ); + + it("holds at least a 25% margin on every pack", () => { + for (const pack of CREDIT_PACKS) { + expect(cashPerCredit(pack)).toBeGreaterThanOrEqual(MIN_CASH_PER_CREDIT_CENTS); + } + }); + + it("is tightest on the deepest pack", () => { + const margins = CREDIT_PACKS.map((p) => cashPerCredit(p)); + expect(Math.min(...margins)).toBe(cashPerCredit(CREDIT_PACKS.at(-1)!)); + }); +}); + +describe("deposit match stays solvent", () => { + it.each(CREDIT_PACKS.map((p) => [p.id, p] as const))( + "%s survives its first-deposit bonus", + (_id, pack) => { + const bonus = depositBonusCredits(pack.amountCents, pack.credits); + expect(cashPerCredit(pack, bonus)).toBeGreaterThanOrEqual( + MIN_CASH_PER_CREDIT_CENTS, + ); + expect(cashPerCredit(pack, bonus)).toBeGreaterThan(payoutPerCredit); + }, + ); + + it("caps the match on the deepest pack instead of doubling it", () => { + const deepest = CREDIT_PACKS.at(-1)!; // $50 / 2000 credits = 2.5c each + const bonus = depositBonusCredits(deepest.amountCents, deepest.credits); + // A naive 100% match would grant 2000 and drop cash in to 1.25c/credit — + // below the 1.4c payout rate. The solvency cap holds it to 857. + expect(bonus).toBe(857); + expect(bonus).toBeLessThan(deepest.credits); + }); + + it("grants a full 100% match where the pack can afford it", () => { + const starter = CREDIT_PACKS[0]; // $1.00 / 20 credits = 5c each + expect(depositBonusCredits(starter.amountCents, starter.credits)).toBe(20); + }); + + it("matches credits bought, not dollars at rack", () => { + // The old rule was floor(amountCents / 5), which on a discounted pack + // granted more bonus credits than the buyer had actually purchased. + const deepest = CREDIT_PACKS.at(-1)!; + const oldRule = Math.floor(deepest.amountCents / CREDIT_CENTS); + expect(depositBonusCredits(deepest.amountCents, deepest.credits)).toBeLessThan( + oldRule, + ); + }); + + it("caps bonus value at $100 of rack", () => { + // A hypothetical whale deposit: $5,000 buying 200,000 credits at 2.5c. + expect(depositBonusCredits(500_000, 200_000)).toBe( + Math.floor(10_000 / CREDIT_CENTS), + ); + }); + + it("never returns a negative bonus", () => { + // A pack priced below the solvency floor gets no promo rather than a + // negative one that would silently remove credits. + expect(depositBonusCredits(100, 1000)).toBe(0); + }); +}); + +describe("free credits cannot mint cash", () => { + it("pays nothing when no part of the click was cash-backed", () => { + // ad_charge_click computes v_earn from the cash-backed slice only, so a + // click funded entirely by signup or bonus credits accrues zero. + expect(creditsToPayoutCents(0)).toBe(0); + }); + + it("prices the current free grant at zero liability", () => { + // 20 signup credits used to obligate floor(20 * 0.7 * 2.5) = 35c of real + // USDC apiece. Now they are promo-tagged and obligate nothing. + const signupGrant = 20; + const cashBackedSlice = 0; + expect(creditsToPayoutCents(cashBackedSlice)).toBe(0); + expect(creditsToPayoutCents(signupGrant)).toBe(28); // only if it were paid + }); +});