URL: https://mkdocs.justinsforge.com/memory/handoffs/links-commissions-status-2026-07-11/
Links commissions worker: status, 2026-07-11¶
Built Phase 4 (commission reporting) of the
Link In Bio module per
affiliate-click-foundation.
Additive-only: did not touch render.ts, cf.ts, the edge Worker, or the sync script (kept
clear of the parallel worker on the Links core lane).
Branches (NOT merged, NOT deployed, NOT prod-migrated)¶
Two separate repos, same branch name:
forgerepo, branchlinks-commissions(commit495a9bc): new filescripts/forge_links_commissions_import.pyonly. Pushed tohttps://github.com/JustinWieb/forge/pull/new/links-commissions.forge-suite(CreatorTrack) repo, worktree/home/justinwieb/forge-suite-wt/links-commissions, branchlinks-commissions(commit51a7898): schema + migration + stats page. Pushed tohttps://github.com/JustinWieb/creatortrack/pull/new/links-commissions.
I did not merge either branch to main and did not touch the shared prod DB. forge's main
branch had pre-existing unrelated local modifications (LESSONS.md, brands/, etc, not mine); I
branched off main, committed only my file, and returned main to its original dirty state.
Files changed¶
forge repo (branch links-commissions)
- scripts/forge_links_commissions_import.py (new) — CSV importer + API-pull scaffold.
CreatorTrack worktree (branch links-commissions)
- db/migrations/20260711T214555_links_commissions_links_commissions_table.sql (new) —
links.commissions table.
- db/schema.ts (edit, additive) — linkCommissions drizzle table + LinkCommission type.
- lib/links-store.ts (edit, additive) — getCommissionsSummary(id, workspaceId).
- app/(suite)/link-in-bio/stats/page.tsx (edit, additive block only) — "Commissions"
section (confirmed/pending totals + per-brand table), inserted above the existing
clicks/captures section, does not restructure existing code.
Schema: links.commissions¶
Workspace-scoped + RLS, matching the existing links.* pattern (workspace_isolation
policy on workspace_id IN (SELECT m.workspace_id FROM core.memberships ...)).
| Column | Type | Notes |
|---|---|---|
id |
text PK | ULID, minted by the importer (own generator, no external dep) |
workspace_id |
bigint | FK core.workspaces(id) |
brand |
text | free-form, matches links.clicks/captures.brand convention |
network |
text | CHECK IN ('amazon','cj','shopmy','impact','manual') |
external_id |
text | the network's own order/conversion id |
link_id |
text, nullable | our /r/<linkId> tracking id, when the network's export carries it |
product |
text | |
amount_cents |
bigint | signed (negative = reversal amount) |
currency |
text | default 'USD' |
status |
text | CHECK IN ('pending','confirmed','reversed') |
occurred_at |
timestamptz | the network's transaction date |
imported_at |
timestamptz | stamped by the importer's injected clock |
source_batch |
text | free-text label for the import run |
Idempotency: UNIQUE (network, external_id). The importer does INSERT ... ON CONFLICT
(network, external_id) DO UPDATE, so re-importing the same report (or a later pull where a
row flips pending -> confirmed) updates in place. Verified: re-running the same CSV produced
+0 inserted, 3 updated, row count unchanged.
Importer: forge/scripts/forge_links_commissions_import.py¶
forge_links_commissions_import.py --csv report.csv --format amazon \
--workspace-id 85 --brand gus [--source-batch amazon-2026-07] [--dry-run]
forge_links_commissions_import.py --csv report.csv --format generic \
--workspace-id 85 --brand gus --network cj \
--mapping '{"external_id":"Conversion ID","product":"Advertiser",
"amount_cents":"Commission Amount","occurred_at":"Conversion Date"}'
--format amazon: matches Amazon Associates earnings-export headers by alias (tolerant of the several header variants Amazon uses across report types/locales), maps status text (Shipped/Completed -> confirmed, Returned/Cancelled -> reversed, else pending).--format generic: JSON column-mapper for CJ / ShopMy / Impact / anything CSV-shaped.- Fail loud: a data row (non-blank external id) with an unparsable amount or date raises
ImportError_and aborts the whole run with no partial commit (verified: bad-date CSV exited 1, row count in DB unchanged). Blank/summary rows (blank external id) are skipped with a printed notice, not an error. - Injected clock:
main()computesnow = datetime.now(timezone.utc)once and threads it throughimport_csv()/make_ulid(); nodate.today()anywhere (doctrinefeedback_date_today_banned_in_forge_scripts). - API-pull scaffold:
fetch_amazon_paapi()/fetch_cj_conversions()are typed stubs. Each raises a clearRuntimeErrornaming the missing~/.forge-secrets/{amazon-paapi,cj-affiliate}.envfile and points back at the CSV path as the working alternative — never a silent no-op.
Verification performed (real, against the dev clone)¶
PGDATABASE=ct_links_commissions WORKSPACE_MIGRATIONS_DIR=db/migrations python3 ~/forge/scripts/forge_workspace_migrate.py— applied cleanly (40 prior migrations + this one, allapplied).\d links.commissionson the dev clone — confirmed columns, both CHECK constraints, the unique index, the FK, and theworkspace_isolationRLS policy all present as designed.- Seeded a 4-row Amazon CSV (3 data rows + 1 trailing blank-id "Totals" row) and a 2-row CJ
generic-mapper CSV, workspace 85 (Gus The Bass). Import:
+3 inserted/+2 inserted. - Re-ran the Amazon import unchanged:
+0 inserted, 3 updated— idempotency confirmed, row count stayed at 5. - Malformed-row test: a CSV with an unparsable date raised
ImportError_, process exited 1, DB row count unchanged (5) — confirmed the failure rolled back, no partial write. - API-pull stub test:
fetch_amazon_paapi()(no secrets file present) raised the expectedRuntimeErrorwith the Blocked-on-Justin message. - Started
next dev --webpack -p 3068against the worktree,curl'd/link-in-bio/statswithct_active_ws=85: rendered$15.70confirmed /$13.75pending totals and the correct per-brand split (Gus The Bass $15.70/$0.00, Gus Outdoor Co. $0.00/$13.75), matching the seeded data exactly. Dev server stopped after. - Deleted the test rows (
source_batch like 'test-batch%' or 'cj-test%') from the dev clone afterward so the clone is clean for the next apply/seed cycle. npx tsc --noEmit: only 6 pre-existing, unrelated errors (missingpublic/brand/*.pngassets, a globalLayoutPropstype not generated because the dev server never fully built.next/typesbefore this run) — none in any file I touched (links-store.ts,schema.ts,stats/page.tsxare clean).
Blocked-on-Justin (does not block anything shipped here)¶
Real Amazon Associates report API access (or CJ/ShopMy/Impact API creds) to wire up
fetch_amazon_paapi() / fetch_cj_conversions(). Amazon does not expose commission totals
via PA-API (product catalog only) — real automated ingestion needs either the Associates
"Earnings Report" CSV export (works today via --format amazon) or the Amazon Attribution
API; confirm which Justin's account has access to before building the live pull. Until then,
commissions land via manual CSV export + forge_links_commissions_import.py, which is fully
working now.
Go-live steps (Justin, when ready)¶
- Review + merge the
forgerepo PR (scripts/forge_links_commissions_import.py) to main. - Review + merge the
creatortrackrepo PR (schema/store/stats-page changes) to main. - Apply the migration to prod by hand (per doctrine, this worker never runs prod migrations):
- Deploy CreatorTrack per the normal path (
scripts/forge_creatortrack_deploy.sh). - Export an Amazon Associates earnings CSV and run the importer against prod (swap
PGDATABASE/PG*to the prod values, or rely on~/.forge-secrets/forge-data.envdefaults) to seed real commissions; the stats page section appears automatically oncelinks.commissionshas rows for the active workspace (hidden when empty, so nothing changes for Michael until real data lands). - When Amazon PA-API/Attribution or CJ API creds are available, drop them into
~/.forge-secrets/amazon-paapi.env/~/.forge-secrets/cj-affiliate.envand implement the bodies offetch_amazon_paapi()/fetch_cj_conversions()(currently typed stubs that fail loud).