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
75 changes: 67 additions & 8 deletions app/(app)/dashboard/ads/earnings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const EMPTY: EarningsModel = {
advClicks: 0,
pubImpressions: 0,
pubClicks: 0,
invalidClicks: 0,
},
campaigns: [],
slots: [],
Expand All @@ -45,6 +46,18 @@ export default async function EarningsPage() {
const model = user ? await loadEarnings(supabase, user.id, RANGE) : EMPTY;
const t = model.totals;

// Money that moved *in the window*, which the balance tiles cannot answer —
// they are lifetime. Delivery with no money behind it in the same window is
// what the free-tier note explains, and this is the only figure that tells
// the two apart.
const rangeEarnedCents = model.slots.reduce((a, s) => a + s.earnedCents, 0);
const rangeSpentCents = model.campaigns.reduce((a, c) => a + c.spentCents, 0);
const deliveredInRange = t.pubImpressions > 0 || t.advImpressions > 0;
const invalidNote =
t.invalidClicks === 1
? "1 further click was filtered as invalid"
: `${t.invalidClicks.toLocaleString()} further clicks were filtered as invalid`;

return (
<div className="mx-auto max-w-4xl">
<Link href="/dashboard/ads" className="text-sm text-[var(--color-muted)]">
Expand All @@ -56,28 +69,74 @@ export default async function EarningsPage() {
</div>
<p className="mt-2 text-[var(--color-muted)]">
Your CrawlProof ad money across both sides — what you earn as a publisher and what
you spend as an advertiser. Last {RANGE} days. Download a PDF report for your
accountant or team.
you spend as an advertiser. Download a PDF report for your accountant or team.
</p>

<div className="mt-6 grid grid-cols-2 gap-3 sm:grid-cols-4">
{/* Balances, not a period. "Available to withdraw" is lifetime earnings
minus lifetime payouts; clipping it to the last {RANGE} days would
under-report money the account is owed. The tables below are the ones
that cover a window, and they say so. */}
<h2 className="mt-6 text-sm font-medium uppercase tracking-wider text-[var(--color-muted)]">
Balance · all time
</h2>
<div className="mt-3 grid grid-cols-2 gap-3 sm:grid-cols-4">
<Stat label="Total earned" value={dollars(t.earnedCents)} accent />
<Stat label="Total spend" value={dollars(t.spentCents)} />
<Stat label="Net" value={dollars(t.netCents)} accent={t.netCents >= 0} danger={t.netCents < 0} />
<Stat label="Available to withdraw" value={dollars(t.availableCents)} />
</div>

<div className="mt-6">
<MoneyTrend data={model.daily} />
</div>

<div className="mt-6 grid grid-cols-2 gap-3 sm:grid-cols-4">
<div className="mt-3 grid grid-cols-2 gap-3 sm:grid-cols-4">
<MiniStat label="Earned today" value={dollars(t.earnedTodayCents)} />
<MiniStat label="Spent today" value={dollars(t.spendTodayCents)} />
<MiniStat label="Withdrawn" value={dollars(t.withdrawnCents)} />
<MiniStat label="Net" value={dollars(t.netCents)} />
</div>

{/* Everything below covers the window, and the heading is the only place
that has to say so. */}
<h2 className="mt-8 text-sm font-medium uppercase tracking-wider text-[var(--color-muted)]">
Delivery · last {RANGE} days
</h2>

<div className="mt-3">
<MoneyTrend data={model.daily} />
</div>

<div className="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-4">
<MiniStat
label="Publisher impr."
value={t.pubImpressions.toLocaleString()}
/>
<MiniStat label="Publisher clicks" value={t.pubClicks.toLocaleString()} />
<MiniStat
label="Advertiser impr."
value={t.advImpressions.toLocaleString()}
/>
<MiniStat label="Advertiser clicks" value={t.advClicks.toLocaleString()} />
</div>

{/* Delivery counts free backfill, so a range that earned nothing still
reports the traffic it carried. Without this line the tables read as a
billing fault rather than as the free tier working. */}
{deliveredInRange && rangeEarnedCents === 0 && rangeSpentCents === 0 && (
<p className="mt-3 text-sm text-[var(--color-muted)]">
Impressions and clicks count free-tier backfill as well as paid inventory — a
campaign out of credits or daily budget, or one running on a slot its own
account owns. It bills nobody and earns nobody, which is why delivery can be
busy while the money is flat.
</p>
)}

{/* Otherwise these are recorded and shown nowhere, and on a site under a
bot run they are most of the click volume. */}
{t.invalidClicks > 0 && (
<p className="mt-2 text-sm text-[var(--color-muted)]">
{invalidNote} — bot, duplicate, forged, or against a campaign that was not
servable — and left out of the figures above.
</p>
)}

{/* Publisher earnings */}
<h2 className="mt-8 text-xl font-semibold">Earnings by site</h2>
{model.slots.length === 0 ? (
Expand Down
31 changes: 25 additions & 6 deletions app/(app)/dashboard/ads/slots/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ import { createClient } from "@/lib/supabase/server";
import { env } from "@/lib/env";
import { fetchSupportedTokens } from "@/lib/coinpay-tokens";
import { SlotManager } from "@/components/ads/slot-manager";
import {
deliveredClicks,
deliveredImpressions,
getSlotTotalsSince,
type SlotTotals,
} from "@/lib/ads/series";

// Fallback coins when CoinPay's supported-coins endpoint is unavailable, so the
// payout dropdown is never empty. Codes match what /payments+/payouts expect.
Expand Down Expand Up @@ -34,7 +40,6 @@ type Payout = {
tx_hash: string | null;
created_at: string;
};
type SlotStat = { slot_id: string; impressions: number; clicks: number; earned_cents: number };

export default async function SlotsPage() {
const supabase = await createClient();
Expand All @@ -47,9 +52,9 @@ export default async function SlotsPage() {
const earnedBySlot = new Map<string, number>();
const withdrawnBySlot = new Map<string, number>();
const payoutsBySlot = new Map<string, Payout[]>();
const statsBySlot = new Map<string, SlotStat>();
let statsBySlot = new Map<string, SlotTotals>();
if (user) {
const [{ data: p }, { data: s }, { data: ledger }, { data: payouts }, { data: slotStats }] =
const [{ data: p }, { data: s }, { data: ledger }, { data: payouts }, slotStats] =
await Promise.all([
// Monetization is owner-only: you earn from a slot, so only list projects
// you OWN — not org/member-shared ones the broad RLS would also return.
Expand All @@ -64,11 +69,15 @@ export default async function SlotsPage() {
.from("ad_payouts")
.select("id, slot_id, amount_cents, currency, status, tx_hash, created_at")
.order("created_at", { ascending: false }),
supabase.from("ad_slot_stats").select("slot_id, impressions, clicks, earned_cents"),
// Lifetime (null window), like the payout figures beside it — but via
// the RPC rather than the ad_slot_stats view, because the view counts
// only tier 'paid' and every fill here is free backfill, so every site
// read 0 impressions while serving six figures of them.
getSlotTotalsSince(supabase, null),
]);
projects = (p as Project[]) ?? [];
slots = (s as Slot[]) ?? [];
for (const st of (slotStats as SlotStat[]) ?? []) statsBySlot.set(st.slot_id, st);
statsBySlot = slotStats;
for (const row of (ledger as { slot_id: string; amount_cents: number }[]) ?? []) {
if (row.slot_id) earnedBySlot.set(row.slot_id, (earnedBySlot.get(row.slot_id) ?? 0) + (row.amount_cents ?? 0));
}
Expand Down Expand Up @@ -139,7 +148,17 @@ export default async function SlotsPage() {
availableCents={earned - withdrawn}
coins={coins}
payouts={slot ? payoutsBySlot.get(slot.id) ?? [] : []}
stats={stat ? { impressions: stat.impressions, clicks: stat.clicks, earnedCents: stat.earned_cents } : null}
stats={
stat
? {
// Paid inventory plus free backfill: what the site
// actually showed, not just what someone paid for.
impressions: deliveredImpressions(stat),
clicks: deliveredClicks(stat),
earnedCents: stat.earnedCents,
}
: null
}
/>
);
})}
Expand Down
79 changes: 60 additions & 19 deletions lib/ads/earnings-data.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import {
deliveredClicks,
deliveredImpressions,
getCampaignDailySeries,
getCampaignTotalsSince,
getSlotDailySeries,
getSlotTotalsSince,
mergeMoneySeries,
sinceForDays,
EMPTY_SLOT_TOTALS,
EMPTY_TOTALS,
} from "./series";

// Unified money model for one account — a single user is both an advertiser
Expand Down Expand Up @@ -40,6 +47,19 @@ export type EarningsPayoutRow = {

export type EarningsModel = {
rangeDays: number;
/**
* Money is a balance and delivery is a rate, so they answer different
* questions and cannot share a window.
*
* `spentCents` / `earnedCents` / `withdrawnCents` / `availableCents` /
* `netCents` are ALL TIME: "available to withdraw" is lifetime earnings minus
* lifetime payouts, and clipping it to the last 30 days would under-report a
* real balance the account is owed.
*
* Everything else — the impression and click totals, and every row in
* `campaigns` and `slots` — covers `rangeDays`, which is what the page and
* the PDF header both promise.
*/
totals: {
spentCents: number;
earnedCents: number;
Expand All @@ -52,6 +72,13 @@ export type EarningsModel = {
advClicks: number;
pubImpressions: number;
pubClicks: number;
/**
* Clicks recorded in the range and deliberately not counted as delivery:
* bot, duplicate, forged, or against a campaign that was not servable.
* Reported so they are visible somewhere; kept out of pubClicks so a bot
* run cannot flatter the CTR.
*/
invalidClicks: number;
};
campaigns: EarningsCampaignRow[];
slots: EarningsSlotRow[];
Expand All @@ -67,10 +94,8 @@ type CampaignRow = {
spend_today_cents: number | null;
spend_date: string | null;
};
type CampaignStat = { campaign_id: string; impressions: number; clicks: number; spent_cents: number };
type Project = { id: string; name: string };
type Slot = { id: string; project_id: string; status: string };
type SlotStat = { slot_id: string; impressions: number; clicks: number; earned_cents: number };
type LedgerRow = { slot_id: string | null; amount_cents: number | null };
type PayoutRow = {
amount_cents: number | null;
Expand All @@ -85,21 +110,29 @@ export async function loadEarnings(
userId: string,
days = 30,
): Promise<EarningsModel> {
// The window the tables and the impression/click totals cover. Money is not
// scoped to it — see the note on EarningsModel.totals.
const since = sinceForDays(days);

const [
{ data: campaignsData },
{ data: campaignStatsData },
campaignTotals,
{ data: projectsData },
{ data: slotsData },
{ data: slotStatsData },
slotTotals,
{ data: ledgerData },
{ data: payoutsData },
] = await Promise.all([
supabase.from("ad_campaigns").select("id, name, status, total_spent_cents, spend_today_cents, spend_date"),
supabase.from("ad_campaign_stats").select("campaign_id, impressions, clicks, spent_cents"),
// Not ad_campaign_stats / ad_slot_stats: those views are lifetime and count
// only tier 'paid', so on a network running entirely on free backfill they
// report zero for every campaign and every site. The RPCs take a window and
// return both tiers.
getCampaignTotalsSince(supabase, since),
// Monetization is owner-only (payouts go to the slot owner), like /ads/slots.
supabase.from("projects").select("id, name").eq("owner_id", userId),
supabase.from("ad_slots").select("id, project_id, status"),
supabase.from("ad_slot_stats").select("slot_id, impressions, clicks, earned_cents"),
getSlotTotalsSince(supabase, since),
supabase.from("ad_ledger").select("slot_id, amount_cents").eq("kind", "publisher_accrual"),
supabase
.from("ad_payouts")
Expand All @@ -108,14 +141,10 @@ export async function loadEarnings(
]);

const campaigns = (campaignsData as CampaignRow[]) ?? [];
const campaignStats = new Map<string, CampaignStat>();
for (const s of (campaignStatsData as CampaignStat[]) ?? []) campaignStats.set(s.campaign_id, s);
const projectsById = new Map<string, Project>();
for (const p of (projectsData as Project[]) ?? []) projectsById.set(p.id, p);
// Only slots for projects the user owns (mirrors the /ads/slots scoping).
const slots = ((slotsData as Slot[]) ?? []).filter((s) => projectsById.has(s.project_id));
const slotStats = new Map<string, SlotStat>();
for (const s of (slotStatsData as SlotStat[]) ?? []) slotStats.set(s.slot_id, s);
const earnedBySlot = new Map<string, number>();
for (const row of (ledgerData as LedgerRow[]) ?? []) {
if (row.slot_id) earnedBySlot.set(row.slot_id, (earnedBySlot.get(row.slot_id) ?? 0) + (row.amount_cents ?? 0));
Expand All @@ -124,31 +153,39 @@ export async function loadEarnings(

const todayUtc = new Date().toISOString().slice(0, 10);

// Delivery is paid inventory plus free backfill, the same measure the
// campaigns dashboard reports. A free-tier impression is still an impression;
// what it is not is revenue, and the money columns say so on their own.
const campaignRows: EarningsCampaignRow[] = campaigns.map((c) => {
const s = campaignStats.get(c.id);
const s = campaignTotals.get(c.id) ?? EMPTY_TOTALS;
return {
id: c.id,
name: c.name,
status: c.status,
impressions: s?.impressions ?? 0,
clicks: s?.clicks ?? 0,
spentCents: c.total_spent_cents ?? 0,
impressions: deliveredImpressions(s),
clicks: deliveredClicks(s),
spentCents: s.spentCents,
};
});

const slotRows: EarningsSlotRow[] = slots.map((sl) => {
const s = slotStats.get(sl.id);
const s = slotTotals.get(sl.id) ?? EMPTY_SLOT_TOTALS;
return {
id: sl.id,
name: projectsById.get(sl.project_id)?.name ?? "Site",
status: sl.status,
impressions: s?.impressions ?? 0,
clicks: s?.clicks ?? 0,
earnedCents: earnedBySlot.get(sl.id) ?? 0,
impressions: deliveredImpressions(s),
clicks: deliveredClicks(s),
earnedCents: s.earnedCents,
};
});

const spentCents = campaignRows.reduce((a, c) => a + c.spentCents, 0);
// Lifetime, deliberately — these feed the balance tiles. `availableCents` is
// lifetime earnings minus lifetime payouts, so scoping either side to the
// range would under-report money the account is actually owed. Read them from
// the campaign row and the ledger rather than from campaignRows/slotRows,
// whose money columns now cover the range instead.
const spentCents = campaigns.reduce((a, c) => a + (c.total_spent_cents ?? 0), 0);
const earnedCents = [...earnedBySlot.values()].reduce((a, v) => a + v, 0);
const withdrawnCents = payouts
.filter((p) => p.status !== "failed")
Expand Down Expand Up @@ -179,6 +216,10 @@ export async function loadEarnings(
advClicks: campaignRows.reduce((a, c) => a + c.clicks, 0),
pubImpressions: slotRows.reduce((a, s) => a + s.impressions, 0),
pubClicks: slotRows.reduce((a, s) => a + s.clicks, 0),
invalidClicks: slots.reduce(
(a, sl) => a + (slotTotals.get(sl.id)?.invalidClicks ?? 0),
0,
),
},
campaigns: campaignRows,
slots: slotRows,
Expand Down
Loading
Loading