Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions app/actions/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand All @@ -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 };

Expand Down
48 changes: 41 additions & 7 deletions lib/ads/pricing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {
Expand Down
9 changes: 7 additions & 2 deletions lib/ads/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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")
Expand All @@ -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;
Expand All @@ -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;
Expand Down
Loading
Loading