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 + }); +});