S&T Card Manager — Functional Reference
What the system does today
Edition 4.0 · August 29, 2026 · Example organization: BK Cards 71
This document is a functional specification of the shipped system, written from a full read of the source. It describes behaviour that exists, not behaviour that is planned. Where a rule is subtle, or was arrived at after a production incident, the reason is stated. Where the code is inconsistent with itself, the inconsistency is recorded in §8 Known Defects rather than smoothed over.
1System Overview
S&T Card Manager is a single-page application that runs a sports-card trading business end to end: intake (scan, importers, manual entry), inventory (the Ledger and the Collection gallery), sale (single, lot, and marketplace-file reconciliation), and accounting (a cards-only executive layer and a full non-card general ledger with a partner capital waterfall).
1.1Stack and deployment
- Client
- React + Vite, a PWA. Tailwind utility classes throughout; no component library.
- Charts
- Recharts, always inside
<ResponsiveContainer>with a fixed pixel height. - Virtualization
@tanstack/react-virtual—useVirtualizerin the Ledger grid,useWindowVirtualizerin Collection.- Spreadsheets
- SheetJS (
XLSX) for every CSV/XLSX read and for the Accounting Excel export. - Backend
- Supabase — Postgres (with generated columns and RLS), Auth, and Storage.
- Storage buckets
card-photos(permanent card images) andscan-uploads(transient scan intake).- Server functions
- Six Vercel serverless routes in
/api, colocated with the Vite app. - External services
- PSA public API (cert attributes + images), Ximilar
collectibles/v2/sport_id(card identification), Anthropicclaude-sonnet-4-6(two routes). - Hosting
- Vercel, production at
bkcards71.vercel.app. Static help pages are served from/help/.
total_cost, net_return and the three projected-profit columns are
Postgres generated columns. The client never writes them. The Card Editor renders a
live preview that mirrors the DB formula so figures move as you type, but the value that
is stored and read back is always the database's.
1.2Top-level navigation
The TABS array in App.jsx is the authority. Three of the five tab
keys no longer match their labels; anyone reading code or writing tests must key
on the label, never the key.
| # | Tab key | Label the user sees | Component | Accent | Gate |
|---|---|---|---|---|---|
| 1 | collection | Collection | Collection.jsx | #2dd4bf teal | none — landing tab |
| 2 | insights | Financials | Insights.jsx | #8b5cf6 violet | canSeeFinance |
| 3 | finances | Accounting | Finances.jsx | #10b981 emerald | canSeeFinance |
| 4 | ledger | Ledger | Inventory.jsx | #f59e0b amber | none |
| 5 | scan | Scan | Scan.jsx | #38bdf8 sky | none |
The tab labelled Ledger is the card inventory grid. The tab labelled
Accounting contains a sub-view also called Ledger, which is the
non-card expense register. They are unrelated surfaces. Similarly, Insights.jsx
renders the tab labelled "Financials" while its own page heading still reads
"Executive Dashboard" and points users at "the Financial tab", and
Finances.jsx's H2 still reads "Finances".
The Admin Console and Help & Support are not tabs — they are full-screen overlays reached
from the header (gear and ? respectively).
1.3Role model and the three gate functions
Five roles, three predicates, all exported from App.jsx and all null-safe (an
unloaded partner fails closed):
export const MANAGER_ROLES = ['company', 'partner', 'admin']
export const canManage = (p) => !!p && MANAGER_ROLES.includes(p.role)
export const canEdit = (p) => !!p && [...MANAGER_ROLES, 'user'].includes(p.role)
export const canSeeFinance = (p) =>
!!p && (MANAGER_ROLES.includes(p.role) || p.can_access_finance === true)
| Role | canManage | canEdit | canSeeFinance | Meaning |
|---|---|---|---|---|
company | yes | yes | always | Manager. BKCards71 — the entity. |
partner | yes | yes | always | Manager. Full access, manages users. |
admin | yes | yes | always | Manager. Full access, manages users. Also the only role that can hard-delete a card. |
user | no | yes | only if can_access_finance === true | Can edit cards. |
viewer | no | no | only if can_access_finance === true | Read-only. |
Note the strict comparison in canSeeFinance: a null
can_access_finance reads as false. The Users admin forces the flag to true
whenever a manager role is assigned, so the two halves of the predicate can never contradict for a
manager.
1.4Where each gate is enforced — and where the real boundary is
| Surface | Client gate | Server gate | Real boundary |
|---|---|---|---|
| Financials / Accounting tabs | Twice: the tab is filtered out of TABS, and the view is guarded at mount | none — no API route involved | RLS on fin_*. Neither Insights nor Finances re-checks the role internally; a permissive policy means a viewer with a console can read the financial tables. |
| Admin Console (open) | none on the gear button — any signed-in user can open it | n/a | Inside the console: non-managers see only My Profile, and every manager pane is double-gated (section === 'x' && isManager). Deep links are validated against the caller's visible tabs. |
| User administration | Row actions hidden by role/status | api/users.js re-derives MANAGER_ROLES and re-checks against the DB | Service-role route. Client-side hiding is cosmetic. |
| PSA lookup, Ximilar scan, photo attach, bring-into-app | Buttons disabled with a "Requires a manager account" tooltip | requireManager() in photos.js, psa.js, identify.js — bearer token → auth.getUser → partner row → role → account_status | Server. Suspended/removed callers are rejected with Account is not active. |
| Scan upload and Scan delete | none | none | RLS on scan_candidates and the scan-uploads bucket — the only protection. Worth auditing. |
| Card edit / save / bulk edit | canEdit conceptually; the editor itself does not re-check | none | RLS on cards. |
| Card hard delete | Button rendered only for role === 'admin' on an already-archived card; handler re-tests the role | none | RLS on cards DELETE. |
Each privileged API route builds its own admin client from
SUPABASE_SERVICE_ROLE_KEY with {persistSession:false, autoRefreshToken:false}
and re-derives the manager-role list server-side. The service key never reaches the browser. The
cost of that independence is that MANAGER_ROLES is duplicated in six places
(App.jsx, Scan.jsx, and the four routes); the four server copies are
deliberate, the Scan.jsx copy is not (see §8).
1.5Boot sequence and account lifecycle
App() resolves four mutually exclusive screens, in order:
- Loading gate — while
supabase.auth.getSession()is in flight, a single pulsingLoading…. - Recovery / invite gate — if the URL hash matches
/type=(recovery|invite)/and Supabase has minted a temporary session,<SetPasswordScreen>renders and nothing else. - Unauthenticated gate — no session →
<Login>. - Authenticated app —
<SignedInApp>, a deliberately separate inner component so thatuseCards()mounts only after login and never while the Login screen is showing.
Password rules (all four must pass, rendered as a live checklist): at least 8 characters, one
uppercase, one lowercase, one number. There is no symbol requirement and no maximum length. On
success the token is stripped from the URL with history.replaceState so a refresh does
not re-trigger the screen.
Account status ladder
| From | Trigger | To |
|---|---|---|
| — | manager runs invite | invited |
invited | invitee sets a password; a manager presses Refresh (syncStatus) | accepted |
invited / accepted | the user signs in and the app actually loads | active |
| any | suspend | suspended (auth ban 876000h ≈ 100 years) |
suspended | reactivate | active (ban 'none') |
| any | remove | removed — the login is deleted, auth_user_id nulled, the accounting identity survives |
user/viewer only | delete | row deleted entirely |
Promotion to active happens as a side effect of the partner fetch: reaching that
code proves the app loaded. last_sign_in_at is stamped on every load;
invite_accepted_at is stamped on the invited → active jump.
1.6Data-loading architecture
useCards() is called exactly once, in SignedInApp, and
its results are passed down to every view. Switching tabs never re-fetches. It pages the whole
cards table 1,000 rows at a time with a single CARD_SELECT projection
carrying 14 embedded lookup joins; at ~1,973 rows that is two round-trips.
The Ledger, Collection, Financials and Accounting all read the same in-memory array.
useFinancials() loads the nine fin_* tables but deliberately does
not load cards — Finances receives them as a prop. Edit a card
anywhere and every screen re-tallies from one source. That is the design guarantee that keeps the
tabs consistent with each other.
Three surgical mutators avoid full reloads: refreshCard(id) (single-row re-fetch,
swapped in place), refreshNewCard(id) (re-fetch, append and re-sort by id after an
insert), and removeCardLocal(id) (pure client-side filter after a hard delete).
reload() is exposed so surfaces outside the Ledger — notably Admin Console → Bulk Edit —
can re-sync the whole app. A separate head-only exact count of non-archived cards feeds the Ledger's
data-integrity badge.
Scan is the exception: it receives neither the cards array nor loading state and queries
scan_candidates itself. The Admin Console's Integrity and Bulk Edit panes also run
their own paged reads, because they need populations (soft-deleted rows, archived rows) that the
shared array deliberately excludes or includes differently.
2Data Model
Everything below is inferred from the projections the app actually reads and the patches it actually writes. Column families are grouped the way the UI groups them, because the grid, the editor and the CSV exporter all share one group vocabulary.
2.1cards — field families
The canonical projection is CARD_SELECT in useCards.js, reused verbatim
by the full-database CSV export, by every importer's "open the card" path, and by Scan's
bring-into-app re-select. The nine groups below are the same nine that drive the Ledger's header
band, the editor's field groups, and the export column picker.
| Family | Columns | Notes |
|---|---|---|
| Item | id, description | The two frozen, locked grid columns. id is minted by the database on insert, never by the client. description is the Card Name. |
| Identity | sku, player, card_year, set_name, subset, parallel, card_number, print_run, is_rookie, is_auto, is_relic, brand_id, sport_id, type_id | The 11 CDP/intake attribute columns (set_name … name_locked) were added 2026-08-21 after a review found ~570 of 647 proposed CDP overwrites targeted fields the grid could not display, sort, filter or export. |
| Naming / provenance | intake_title, intake_source, name_source, name_locked, listing_name, purchase_name, cdp_name | name_source ∈ canonical | intake | user | grader | legacy. name_locked = true means a hand-typed override or a grader-authoritative name; bulk name generation skips those rows unconditionally. |
| Status & location | sale_status, disposition_id, location_id, showcase, damaged, damage_note, archived, archived_at, archived_by, counts_in_financials, deleted_at | sale_status ∈ SOLD | NOT_SOLD | NOT_FOR_SALE. GIFTED and WRITEOFF exist only as pseudo-values in the editor's Status dropdown — they resolve to SOLD plus a Loss disposition. |
| Storage location | home_location, show_box, show_column, show_divider, show_case | Rendered as one derived string: Case {x} when show_case is set, else Bin {box, column, divider}. |
| Acquisition | purchase_date, purchase_year, purchase_platform_id, pay_method_id, owned_by, paid_by, platform_purchase_order_id | owned_by / paid_by / grading_paid_by are three separate FKs to partners, which is why every join must be disambiguated by constraint name. |
| Grading | grader_id, grade, grade_qualifier, cert_number, psa_order_number, grading_paid_by, psa_no_image | grade is numeric on a fixed 19-step ladder (1 → 10 by 0.5). cert_number is the join key for every photo importer. psa_no_image is a permanent budget flag. |
| Costs | base_cost, tax, shipping, handling, grade_fee, total_cost | Five typed inputs, one generated total. Return costs on a refunded sale are added into handling. |
| Valuation | comp_as_is, exp_sale_10, asking_price, proj_profit_asis, proj_profit_10, proj_profit_sale | Three typed inputs, three generated projections. |
| Sale | date_sold, year_sold, sold_price, net_proceeds, platform_sold_price, sold_platform_id, received_method_id, platform_sale_order_id, platform_sale_item_id, shipping_collected, label_cost, gifted_to, net_return | platform_sold_price is the gross the platform reported. net_proceeds is what actually arrived. sold_price is kept permanently mirrored to net_proceeds — "one number, two columns, never out of step" — because sold_price is what drives the generated net_return. |
| Media / misc | photo_url, photo_back_url, notes, created_by, updated_at, updated_by, disposition (legacy text) | The legacy free-text disposition column is aliased away as disposition_legacy in the projection so it can never be confused with the FK. |
platform_sold_price = gross (reference only, never summed into revenue).
net_proceeds = the money basis. sold_price = the same number as
net_proceeds, kept because the generated net_return reads it. The grid's
column keyed sold_price is labelled "Net Proceeds" and renders
net_proceeds ?? sold_price; its CSV export writes raw sold_price.
See §8 D-11.
2.2Generated / computed columns
Five columns are computed by Postgres and rendered read-only everywhere (as <span>s
suffixed (calc), with no input element to focus). The Card Editor mirrors each formula
locally so the numbers move as you type; the stored value is always the DB's.
total_cost = base_cost + tax + shipping + handling + grade_fee (blanks count as 0)
proj_profit_asis = comp_as_is − total_cost (blank driver → blank, not 0)
proj_profit_10 = exp_sale_10 − total_cost
proj_profit_sale = asking_price − total_cost
net_return = sold_price + shipping_margin − total_cost
where shipping_margin = shipping_collected − (label_cost ?? shipping_collected)
i.e. a null label cost means shipping washes to zero margin
Owner-ratified (PRD §2.3.1). The shipping term is already inside the generated
net_return, so every profit figure computed in the app must carry it too or the
screens drift from the database. The Accounting tab's Shipping Ledger is therefore a
breakdown of a term already counted — adding it again double-counts. That is the
single most important note in the money code.
year_sold is not a generated column but is derived by every sale flow in JS
as Number(date_sold.slice(0,4)) — Mark Sold, Bulk Sale, the eBay and CollX importers,
and the Generic CSV importer all do this. It remains hand-editable in the Card Editor's Sale group,
which is how the two can be made to disagree (§8 D-27).
2.3Lookup tables
Every lookup shares the shape id, label, active, sort_order. Nine of them are managed
from Admin Console → Dropdowns; the rest are managed elsewhere or deliberately not managed at all.
| Table | Shown as | Managed in Dropdowns? | Extra columns |
|---|---|---|---|
brands | Brand | yes | — |
card_types | Item Type | yes | — |
sports | Card Type | no — bulk-editable elsewhere but not managed here | — |
graders | Grader | yes | — |
locations | Location | yes | PSA Grading is id 5, hard-coded in the PSA Return importer |
purchase_platforms | Purchase Platform | yes | — |
pay_methods | Pay Method | yes | — |
listing_platforms | Listed On | yes | A row labelled none is filtered out of the editor — unchecking everything is how you say "not listed" |
sold_platforms | Sold Platform | yes | fee_pct, per_order_fee — the flat fee model. FB is relabelled Facebook for display only |
received_methods | Proceeds To | yes | Doubles as the receiver in the capital waterfall — see §8 D-40 |
platform_fee_tiers | (Settings tab) | own editor | sold_platform_id, min_price, fee_pct, flat_fee. Tiers override the flat model |
dispositions | Sale Type | deliberately excluded | category, is_sold, is_inventory, counts_in_financials — it carries accounting behaviour, so it is not an ordinary dropdown |
Disposition ids that appear as literals in code
| id | Meaning | Written by |
|---|---|---|
| 3 | Sold – Straight Sale | Mark Sold (default), Bulk Sale, eBay Sales, CollX Sales |
| 4 | Sold – Trade | Mark Sold, second of two hard-coded options |
| 5 | Sold – Sold Then Refunded | an accepted pairing for an unsold card in the status/disposition coherence check |
| 6 | Lost write-off | Write-off story |
| 7 | Forgery write-off | Write-off story |
| 8 | Ripped Off write-off | Write-off story |
| 12 | For Sale – In Inventory | Add Card, status flip, Scan bring-in, eBay Purchases, PSA Return, Purchases conversion |
| 13 | Not For Sale – In Inventory | status flip |
| 15 | Gifted write-off | Status → Gifted |
| 16 | Destroyed write-off | Write-off story |
The Accounting tab's Write-Offs block maps {15:'Gifted', 6:'Lost', 7:'Forgery',
8:'Ripped Off', 16:'Destroyed'} directly. Reseeding or reordering the
dispositions table silently reclassifies every write-off. Tracked as
D-33.
2.4Join and satellite tables
| Table | Shape | Written by | Rules |
|---|---|---|---|
card_listings |
card_id, listing_platform_id |
Card Editor's Listed On multi-checkbox (syncListings); CDP import (additive); card creation from CDP |
A card can be listed on any set of marketplaces at once, so this is a join table, not a column. syncListings diffs the checked set against what was saved and issues one delete-in plus one insert. Wrapped in try/catch: a listings failure never fails the card save. CDP's Listed On proposal only ever adds — removing a listing is a destructive edit and this importer only adds. |
card_events |
card_id, event_type, occurred_on, actor, gross, net, cost_delta, platform_id, order_ref, counterparty, note, detail (jsonb), source, sort_hint |
logCardEvent() from every flow that performs an action; Card Journey hand entry; the importers |
Append-only — RLS grants select + insert only; a correction is a new event, never an edit. source ∈ app | bulk | manual | importer | seed. sort_hint is stamped at write time from EVENT_SORT so the timeline stays in lifecycle order even with missing dates. Full type table at §9.1. |
card_title_events |
card_id, source, title, created_at |
Every name change in the editor; bulk Generate Names; Scan bring-in; eBay Purchases; PSA Return (both branches) | Lazily loaded by the editor's Name history disclosure, newest first. Best-effort — a failure is swallowed. |
user_audit |
id, action, detail (jsonb), created_at, actor_partner_id, actor_email, target_partner_id, target_email |
logAudit(); api/users.js; three inline inserts that bypass the helper |
There is no actor column. Nine call sites once inserted one anyway, every insert threw, every throw was swallowed, and the audit trail was silently dead for three days. The helper exists to make that impossible: one shape, and detail is JSONB so it takes an object, never a stringified one. Still best-effort, but it now console.warns. Full action table at §9.2. |
saved_views |
id (uuid), partner_id (smallint FK), name, config (jsonb), is_default, updated_at |
Ledger → Columns & Views | RLS scopes rows per partner; a partial unique index enforces one default per partner, and the client also clears defaults explicitly before setting one. config = {columns, widths, sort} — layout only; no filter state is saved. |
scan_candidates |
id, batch_id, group_id, seq, side, status, image_url, original_filename, uploaded_by, proposed (jsonb), suggested_name, confidence, read_grade, read_grader, raw (jsonb), scan_error, scanned_at, accepted_card_id, reviewed_by, reviewed_at |
Scan uploads, the PSA cert path, api/identify.js, the bring-in flow |
Isolated from cards by design. Ximilar writes here and only here. status ∈ pending | scanning | scanned | error | accepted | rejected. group_id is {batchId}:{index} — one group is one future card, front and back. |
psa_cert_status |
cert → verdict (done | no_front | error) |
api/photos.js PSA batch |
The daily-budget ledger. Definitive verdicts are persisted so a cert is never re-asked; transient failures are deliberately left unrecorded so they retry. |
psa_return_drafts |
order_number (key), payload (jsonb) |
PSA Return importer's Save draft | Upserted on order_number. Payload is the entire review state; Resume restores it and re-fetches the candidate pool so cards added since become assignable. |
support_requests |
partner_id, request_type, area, severity, card_ids, subject, body, status, created_at |
Help & Support forms | RLS: insert/select own; managers see all. status ∈ new | seen | in_progress | done | declined. |
purchases |
order/item metadata, linked_card_id, converted_card_id, match_status |
Admin Console → Purchases (manual matching only) | match_status ∈ unmatched | matched | converted. Joined to cards via purchases_linked_card_id_fkey. |
2.5The fin_* tables
useFinancials() fires nine parallel queries in one Promise.all and
surfaces the first error found across all nine.
| Table | Order | Purpose |
|---|---|---|
fin_entries | entry_date asc | The non-card general ledger. One row per cost — including a recurring rule, which is one row, never N rows. |
fin_categories | sort_order | name, is_capex, is_capital_contribution, default_counts_in_books, sort_order. Drives all classification. |
fin_vendors | name | Who you paid. |
fin_contributors | name | Doubles as partners for distributions, splits and gifts. |
fin_frequencies | sort_order | name, months_interval. A falsy interval means non-recurring. |
fin_pay_methods | name | Card/account used. |
fin_distributions | dist_date asc | dist_date, partner, amount, kind, note. kind ∈ payout | capital_return | profit_draw. |
fin_profit_splits | — | year, partner_id, pct, upserted on (year, partner_id). |
fin_profit_gifts | — | card_id, amount, gift_date, note, from_partner_id. Upserted on conflict card_id — one gift per card. |
fin_entries columns that carry behaviour
| Column | Values | Effect |
|---|---|---|
amount | numeric | The unit cost — one occurrence, not the annual total. |
counts_in_books | bool | false = informational only; excluded from every total, rendered at 60% opacity with an info chip. |
scope | business | personal | personal is excluded from all business totals, rendered at 50% opacity with an amber chip, and reported in its own summary stat. |
reimbursable, reimbursed_state, reimbursed_amount, reimbursed_date, reimbursed_to_id | none | partial | full | Only reimbursable and reimbursed_amount affect arithmetic. The state, the date and the payee are labels and audit trail. |
start_date, end_date, frequency_id | dates + FK | The recurring rule. Blank end_date = runs until stopped. Both dates are forced to null on save when the entry is not recurring. |
active | bool | Written, displayed, round-tripped — and read by nothing. Unticking it changes no total. D-36. |
allocated_to_cards | numeric | Only meaningful for the category named exactly New Cards: how much of a bulk lot went to keeper cards already on the card P&L. The leftover becomes business COGS. |
owner_contributor_id | FK | Whose personal expense; forced to null on save unless scope === 'personal'. |
Four classification rules string-match on the category name rather than a flag:
startsWith('Operating Expense'), includes('Write-Off'),
=== 'New Cards', and the donut's exact prefix strip
'Operating Expense (Non-Card) – ' (note the en-dash). Renaming a
category in the admin UI silently drops it out of the P&L. This is the most fragile coupling in
the money code. D-32.
Three of the seven joins in ENTRY_SELECT point at the same table and must be
disambiguated by FK constraint name — renaming any of them in Postgres breaks the query:
category: fin_categories(id, name, is_capex, is_capital_contribution, default_counts_in_books)
vendor: fin_vendors(id, name)
contributor: fin_contributors!fin_entries_contributor_id_fkey(id, name)
frequency: fin_frequencies(id, name, months_interval)
pay_method: fin_pay_methods(id, name)
reimbursed_to: fin_contributors!fin_entries_reimbursed_to_id_fkey(id, name)
owner: fin_contributors!fin_entries_owner_contributor_id_fkey(id, name)
2.6partners and identity
Columns the app reads: id (smallint), name, display_name
(NOT NULL), first_name, last_name, email, role,
can_access_finance, auth_user_id, account_status,
invited_at, invited_by, invite_sent_at,
invite_accepted_at, last_sign_in_at.
A partner row is referenced by three FKs on every card (owned_by,
paid_by, grading_paid_by). Remove login deletes the auth user and
nulls auth_user_id but keeps the row, so historical cards keep their owner and payer.
Hard delete is refused for manager roles entirely, and refused for
user/viewer whenever any card references them — the error names the count
and points at the non-destructive path.
Names are written in lockstep. Editing a name anywhere (Users tab or My Profile) writes
first_name, last_name, name and
display_name together, because "names drive Owned By / Paid By across the app". The
partner accounting engine resolves names by case-insensitive prefix —
kevin* → Kevin, brian* → Brian, bk* → BKCards71 — and anything
that does not match one of the three is silently dropped from every accumulator
(D-38).
3Component Reference
One section per surface. UI strings are quoted from source, including the literal
typographic characters (…, —, ▾, ↻,
✕) a tester has to match.
3.1App shell and the integrity badge
The header
Sticky (top-0 z-40) so the tabs, the identity and Sign Out stay on screen while a
view scrolls. Left to right: the two-line wordmark (S&T Card Manager / CARD MANAGER),
the folder-tab nav, then a right cluster of — in DOM order — the integrity badge, the ?
help button, the gear, the partner name, the role chip, and Sign Out. The role chip renders the
raw role string, so a user literally sees company, partner,
admin, user or viewer in lowercase monospace. Sign Out has no
confirmation — the auth state change drops straight back to the login screen.
Tabs sit on one baseline with deliberately no vertical hop on selection. The active tab merges
its bottom edge into the page (-mb-px bg-slate-950, killing the header's bottom line
beneath it) and grows a 3px accent bar across its top. A disabled code path exists (Coming
soon + a lowercase soon micro-label) but no tab currently uses it.
The ? button is visible to every role, unconditionally — "'?' is the
ONE entrance; the gear no longer carries it." The gear opens a fully working Admin Console but is
still labelled Settings (coming soon) (D-01).
The integrity badge — the header's health light
An earlier draft hid itself when the books were clean. That was wrong: a badge that disappears cannot be told apart from a badge that broke, so green has to be shown as loudly as red. A percentage was explicitly rejected — "twelve broken records against 3,000 cards is 0.4%, which would read green while the books were wrong." Red strictly outranks amber: one contradiction lights the badge red no matter how many amber items sit behind it.
| State | Condition | Icon silhouette | Colour | Text shown |
|---|---|---|---|---|
| settling | first run in flight (unknown with no error) | circle-with-? | slate | ·· pulsing. Renders as a non-interactive <span>, not a button — the slot is held so the header does not jump, but no state is claimed that has not been measured. |
| clean | every invariant reads zero | shield-with-check | emerald | the word Clean |
| attention | incomplete, not wrong | triangle-with-! | amber | the numeric total |
| broken | a contradiction — the books are wrong right now | octagon-with-X | red | the numeric total |
| unknown | the check failed, or the dispositions lookup came back empty | circle-with-? | slate | — |
The four silhouettes are deliberately distinct "so the state reads at a glance even in
grayscale". The tooltip lists each non-zero part as {n} × {label}. Clicking the badge
runs setAdminSection('integrity') then opens the console — "the gear means 'settings',
so it opens where it always did; the integrity badge means 'show me the problem', so it deep-links to
Integrity instead of making you hunt for the tab." Closing the console increments
pulseKey, which re-runs the check, "since that's where the fixes happen". Full
definitions of the checks are in §7.
Deep links the shell owns
- Badge → Integrity —
initialSection='integrity', validated inside the console against the caller's visible tabs so a non-manager can never be dropped onto a manager pane. - Admin Bulk Edit → single card —
handleEditCard(id)closes the console, switches to the Ledger tab, and setspendingCardId. The Ledger receives it asopenCardId, opens the card, then callsonOpenCardConsumed()so it does not reopen on the next render.
3.2Ledger — the inventory grid
Inventory.jsx, ~3,170 lines. The working surface: 64 columns, 9 group bands, 57
filter funnels, saved views, a KPI band, virtualization, two CSV exporters, and the launch point for
the Card Editor and Bulk Sale.
Classification predicates (module-scope, pure)
| Predicate | Definition | Why it is written that way |
|---|---|---|
isArchived | c.archived === true | Strict — a null flag is live. |
isNotForSale | c.sale_status === 'NOT_FOR_SALE' | sale_status is the source of truth, not disposition flags. |
isSold | !isArchived(c) && c.sale_status === 'SOLD' | Deliberately keyed off status, not year_sold, so a For-Sale card carrying a stale sale date cannot inflate the Cards Sold KPI. |
inInventory | false if archived / sold / NFS; else c.disposition ? c.disposition.is_inventory === true : true | Falls back to true with no joined disposition, so it is safe pre-classification. NFS is explicitly excluded from for-sale inventory. |
hasPhoto | typeof c.photo_url === 'string' && trim !== '' | The BK placeholder is rendered, never stored, so it never counts. photo_back_url is ignored by this predicate. |
countsInFinancials | disposition flag, falling back to the card column | Defined, documented as the money-on-the-books driver, and never called. D-16. |
Columns
64 columns across nine groups. The header band is computed by
bandCells, which walks the visible columns in render order and coalesces adjacent
columns sharing both the same group and the same freeze segment —
which is what makes the band split correctly at the freeze boundary.
| # | Group | Cols | Rule colour | Contents |
|---|---|---|---|---|
| 1 | Item | 2 | #94a3b8 | id, description — both locked and frozen |
| 2 | Identity | 21 | #64748b | player, year, brand, item type, card type, set, subset, parallel, card number, intake title/source, name source, print run, rookie/auto/relic badges, name locked, SKU, listing/purchase/CDP names |
| 3 | Status & Location | 5 | #0ea5e9 | Status pill, Location, Listed On, Showcase, Damaged |
| 4 | Acquisition | 7 | #8b5cf6 | purchase date/year/platform, owned by, paid by, pay method, purchase order id |
| 5 | Grading | 5 | #ec4899 | grader, grade (rendered combined, e.g. 10 Pristine), cert, PSA order #, grading paid by |
| 6 | Costs | 6 | #f59e0b | grade fee, base cost, tax, shipping, handling, total cost |
| 7 | Valuation | 6 | #14b8a6 | comp as-is, projected profit as-is, expected sale (10), projected profit (10), asking price, projected profit (sale) |
| 8 | Sale | 11 | #22c55e | Sale Type, date/year sold, sold platform, proceeds to, order/item ids, Sold Price (gross), Net Proceeds, Net Return |
| 9 | Storage Location | 2 | #6366f1 | Home, Show (derived Case X / Bin a, b, c) |
Flag tallies: 2 frozen, 2 locked, 56 filterable, 19 numeric (all also filterable), 4 signed
red/green money, 24 mono, 15 right-aligned. DEFAULT_VIEW shows 34 of the 64; the 30
hidden ones are the CDP/intake attributes and the less-used cost and sale detail columns.
- Net Return is suppressed for anything not
isSold— blank in the grid, null in the sort (sinks last), excluded from the numeric range filter, and blank in the CSV. A stalenet_returnon an unsold card can never leak anywhere. - Projected Profit (Sale) never shows a number without an asking price — it renders the italic placeholder
set priceinstead. - A blank asking price is deliberately coerced to
0for sort and numeric filtering, so a0..0range catches every unpriced for-sale card, not just literal zeros. - Sold Platform uses
|| null, not?? null, so an empty string also becomes null and sinks last.
Saved views
config = { columns: visibleKeys, widths: the full 64-key map, sort }.
Filters, search text, the sport chip, the status facet, the photo funnel and
showArchived are not saved — a "Sales only" view still needs its
filters re-applied by hand (D-22).
applyConfig validates every saved key against ALL_KEYS (silently
dropping keys from renamed columns), re-injects the two locked columns even if the config omitted
them, re-orders the whole set into canonical COLUMNS order, merges widths over the
defaults, and adds every explicitly-saved width to the manualWidths ref so content
auto-sizing will never override user intent. The default view auto-loads exactly once per mount
(didAutoLoad ref) so a later refresh cannot stomp an in-session layout.
saveCurrentView matches existing names case-insensitively and updates in place, so a
re-save cannot create a duplicate. setDefaultView always clears all of the partner's
defaults first, backing the one-default-per-partner index client-side too.
Filters — the pipeline
cards
→ gridBase archive scope
→ searched global text search (7 fields)
→ filtered status facet → card-type chip → photo funnel → checklists → numeric ranges
→ visible sort
→ virtualRows windowing
| Filter | Count | Behaviour |
|---|---|---|
| Global search | 7 fields | id, sku, description, player, cert, brand label, sport label. Plain substring, lower-cased, no tokenising. The placeholder advertises only five. |
| Status facet | 5 | All / For Sale / Not For Sale / Sold / Archived. Mutually exclusive, click-again-to-clear. NOT_SOLD catches nulls too. Archived renders only when the count is > 0. |
| Card-Type chips | 1 + N | Available: {n} plus one per distinct sport label, each with a count and a · $50.6K money badge. Count and dollars describe the identical set (searched ∩ inInventory), and Available equals the sum of the per-type counts. |
| Photo coverage funnel | 1 | On the ID column header. Two independent checkboxes over hasPhoto. An echo pill appears in the Status row reading Has Photo / No Photo / No Photos Shown. |
| Per-column checklists | 37 | Distinct values computed over searched, so the list reflects what is in play. Normalised on Apply: an empty draft or a fully-ticked draft deletes the entry entirely, so a fully-ticked funnel does not become an expensive no-op predicate. Columns AND; values within a column OR. |
| Numeric min–max | 19 | Inclusive bounds, blank = open-ended, Enter applies. Rows with a null value are excluded whenever any bound is set — a card with no value cannot satisfy a range. |
| Show archived | 1 | Rendered only when archived cards exist. Not part of isFiltered and not reset by Clear all filters (D-18). |
Funnel popovers render at the document root with position:fixed so they escape the
grid's overflow-auto, anchored at the header cell and horizontally clamped to
[8, innerWidth − 232] so they never clip off-screen. Clear all filters is
scoped to filters only — columns, widths, sort and the active saved view are deliberately left
alone.
The KPI band
Every KPI is computed over liveCards (all non-archived cards). Filtered numbers
appear only in a separate rollup callout, so the headline figure never silently changes meaning
under a filter.
| Tile | Value | Year-scoped? |
|---|---|---|
Net Return Total | Σ net_return over sold cards in the enabled years; red below zero, green above | yes |
Cards Sold | count of isSold in enabled years | yes |
Total Sold $ | Σ sold_price in enabled years | yes |
Inventory Count | count of inInventory | no — point in time |
Money in Inventory | Σ total_cost over inInventory, amber | no |
Not For Sale | {count} · {money} with a per-owner split (BK / Kevin / Brian pinned first, then alphabetical). Rendered only when the count is > 0. | no |
The Years Sold toggles: All is a reset, not a toggle; from the All state
clicking a year narrows to just that year; deselecting the last remaining year falls back to
all years, so an empty KPI band is unreachable. When more than one year is shown, the first
three tiles grow a per-year sub-line.
The filtered rollup callout reads You have {money} across {n} cards and its dollar
figure is facet-aware: sale proceeds under Sold, cost basis under For Sale or Not
For Sale, and held cost basis (invDollars + nfsDollars) otherwise — so "You have $X"
always means the right thing.
Grid mechanics
- Sorting — click the header label. Nulls always sink last, in both directions. No secondary tiebreak; equal values rely on
Array.prototype.sortstability. - Resizing — a 1.5px drag handle on every header's right edge; double-click resets that one column to content width. Dragging adds the key to a
manualWidthsref that auto-sizing will never touch again. - Auto-sizing — one pass per mount (
didAutoSizeref, inside arequestAnimationFrameafter the first non-empty cards array), measuring the visible string through a shared canvas 2D context, clamped to[60, 420]px so one long value cannot blow out a column. - Frozen columns — only
idanddescription. Frozen cells paint an opaque background (bg-slate-950, orbg-emerald-950when bulk-selected), which is exactly why the grid is deliberately zebra-free: scrolling columns must disappear cleanly behind them. - Virtualization —
useVirtualizerover the already-filtered, already-sorted array,estimateSize 33px,overscan 12. Height is preserved by twoaria-hiddenspacer<tr>s rather than a transform, which keeps<table>,<colgroup>, sticky headers and frozen columns intact. A third always-on 24px spacer row keeps the horizontal scrollbar off the last data row.
Footer data-integrity badge
{✓|✕} Data integrity · {N} live. Green only when the server's head-count is known
and strictly equals the number of rows the client loaded; red otherwise, including
when the count could not be verified. It fails closed.
CSV export — two scopes
| Button | Scope | Rows |
|---|---|---|
Export View CSV (teal) | view | The current visible array — post search, facet, chips, every funnel, and sort — with the popup's own photo radio applied on top. Seeds the column picker from the visible columns. |
Export Full DB CSV (indigo) | db | Ignores the grid entirely and live-pulls every row from Supabase using the same CARD_SELECT and 1000-row paging, including archived rows. Seeds all 64 columns. |
Cell rules: numeric columns export a raw spreadsheet number, never "$1,234.00";
Net Return mirrors the grid's sold-only guardrail; everything else prefers the full untruncated
title text. Fields are RFC-4180 escaped. Column order is re-derived from
COLUMNS regardless of tick order. Every export always appends two extra
columns, Front Photo URL and Back Photo URL, so a file handed to
CDP or a marketplace never loses the image links. Filename:
bkcards71_{view|fulldb}_{n}rows{_hasphoto|_nophoto}_{YYYY-MM-DD}.csv.
Row interactions
- Hover thumbnail on the ID cell — 200×280,
position:fixed,pointer-events-none, flips to the left of the cell when it would overflow and clamps vertically. Source isphoto_url, falling back to an inlinedata:SVG placeholder that never makes a network request. - Click the description → the shared
<CardEditor>, keyed on the card id, wired so thatonSaved/onInserted/onDeletedre-tally the grid, KPIs, chips and rollup live. + Add Card→ the editor in insert mode with a For-Sale default.Select to Sell→ select mode: row clicks toggle selection instead of opening the editor, hover preview and click-to-open are both disabled, and the ID column widens by 26px so the checkbox can never overlap Card Name. Selection is aSetof card ids resolved against the full cards array, so it survives filter, sort and virtualization changes.Recent Sales→ a self-contained modal over the raw cards array (unaffected by every grid filter): all non-archivedSOLDcards, local search, seven sortable columns with a descending-id tiebreak, a headline{n} sold · net {money}computed over all rows, and a hard cap of 400 displayed rows with a notice.
3.3Collection — the showcase gallery
Teal identity. An image-first gallery described in source as a "pure show-off surface; only 'insight' is the header stat line". It consumes the shared cards array — no separate fetch, no DB change — and derives rookie and autograph status in-app from the description text.
Collection's isSold is !archived && year_sold != null. The
Ledger's is sale_status === 'SOLD'. A card with one but not the other reads
inconsistently between the gallery and the rest of the app. D-05.
Archived cards never appear — held is the base population for stats,
dropdowns and the grid alike, and there is no archived view.
Controls
A sticky region parked directly under the 56px app header, in three rows: a grid/list segmented
toggle plus search; a horizontally scrollable filter rail; and a result count. The rail, in exact
order: Sort · divider · For Sale / Not For Sale /
Sold pills · divider · Price · divider · Graded /
Rookie / Autographed pills · divider · Category ·
Year · × Clear.
<select>s on purpose
"Built on a NATIVE <select> so it always opens instantly (the custom
button+panel version had click/render issues) … The open list is the OS-native control — which is
exactly why it's reliable." Options carry an explicit dark text colour so they stay readable in the
OS popup on a light background. Similarly, the dropdown trigger and panel components are declared
at module top level rather than as inline closures: with ~1,958 cards, defining them inline tore
them down mid-click and caused both the "doesn't click" bug and the lag.
Nine sort options (default Newest added = descending id); numeric sorts push nulls
to the end regardless of direction. Five price buckets tested against the tile price
(sold_price for sold cards, else asking_price); a null price is excluded
by any bucket. Category and Year lists are built dynamically from the held population.
The three status pills are independent toggles unioned together, not a radio
group. The default is the single-member set {FOR_SALE}, so Collection opens showing only
for-sale cards; toggling all three off falls back to "everything held except Sold" rather than
showing nothing. That default set is treated as "no filter" when deciding whether
× Clear appears. Clearing resets search, price, status, the three tag toggles, category
and year — but not the sort or the grid/list view.
Stat blocks
| Label | Count | Money sub-line |
|---|---|---|
For Sale | neither sold nor NFS | Σ total_cost |
Not For Sale | sale_status === 'NOT_FOR_SALE' | Σ total_cost |
Sold | year_sold is set | Σ sold_price |
The asymmetry is intentional: unsold positions are valued at cost, sold ones at realised price.
Tiles
The image frame is deliberately narrower than the text column (78%) at a fixed 264px
height, "so every image aligns on one baseline". Four corner overlays: a bare neon year badge
top-left (colour taken from a fixed 10-colour palette indexed by year % 10, so every
card of the same year wears the same colour, with a text-shadow so it stays readable over any
image); a SOLD badge top-right; the grade badge bottom-left; and a show-location badge
bottom-right.
The grade badge ladder: ≥10 emerald, ≥9 teal, ≥8 sky,
≥6 amber, <6 orange, grader-with-no-grade slate, and
RAW when there is neither a usable grader label nor a numeric grade. A
grader labelled NONE is treated as no grader. The Graded filter pill is
implemented as "the badge is not RAW".
Rookie and autograph are regex heuristics over the lowercased description
(/\brc\b/, /\(rc\)/, /\br\s c\b/, /\brookie\b/;
and /\bauto/ as a prefix match). They are only as good as the description.
Virtualization and the detail modal
useWindowVirtualizer against the window (Collection scrolls the page; the controls
are sticky, not a separate scroll box), with scrollMargin taken from the list
container's offset. Column count mirrors the Tailwind breakpoints (4 / 3 / 2) and listens to
resize. Grid rows estimate 380px with overscan 6; list rows 80px. The body component is
memoised so it re-renders only when the result list or the view mode actually change — not when a
dropdown opens.
The detail modal opens front-first and every card flips, whether or not it has a
back photo: a perspective:1200px container with a 0.55s rotateY transition,
falling back to a second inline SVG placeholder reading No back photo yet available. The
right half is a facts panel — a definition list where any null entry is dropped entirely — and the
footer hint Edit details in the Ledger view. The modal is strictly
read-only; there is no edit affordance anywhere in Collection, and it renders from the
captured object so it does not live-update if the underlying card changes.
3.4Financials — the executive dashboard
Violet identity. Insights.jsx, ~975 lines. Everything is computed live in
useMemo from the shared cards array; nothing is fetched separately and nothing is
stored. Page H1 is Executive Dashboard.
The governing owner directive, from the file header: "At this layer there are NO operating
expenses or capital movements, so Revenue − COGS == Gross Profit. We show GROSS PROFIT only and
never also show a 'Net Return' tile (that would be the identical calc under two names)." The
same underlying sales produce Gross Profit here and Gross Profit → Net Ordinary
Income on Accounting.
Populations
isSold(c) → !archived && year_sold != null // narrower than useWaterfall's
inInventory(c) → not archived, not sold, not NOT_FOR_SALE, and
disposition ? disposition.is_inventory === true : true
live = cards.filter(!isArchived)
sold = live.filter(isSold) // the realised book
inv = live.filter(inInventory) // for-sale inventory; EXCLUDES Not-For-Sale
soldF = sold narrowed by the Sales-year pill
The Sales year pill — scope semantics
The filter is named Sales year and it narrows soldF only. Every inventory
metric — invested capital, unrealised P/L, aging, dead money, mix pies, price bands — is always
whole-dataset, as-of-now, because inventory has no sale year. This is deliberate and it is the
single biggest source of user confusion. Changing the year also clears the CFO briefing and every
Analyst paragraph, so a paragraph can never describe a scope other than what is on screen.
| Visual | Population | Obeys the year pill? |
|---|---|---|
| KPI tiles 1–4, 7 (Revenue, COGS, Gross Profit, ROI, Avg Days to Sell) | soldF | yes |
| KPI tiles 5, 6, 8 (Invested Capital, Unrealized P/L, Top-Card Concentration) | inv | no |
| Gross Profit by Year | sold | no — a one-bar chart would be useless |
| Revenue vs COGS by Year | sold | no |
| Gross Profit by Platform | soldF | yes — the only chart that does |
| Units Sold Over Time · ROI Distribution · Grade Lift · Best & Worst Flips | sold | no |
| Avg Acquisition (brand, platform) · Pipeline chips · Grade Distribution · Grading Spend | live | no |
| Aging · Money by Sport · Dead Money · Mix pies · Price bands · Top 10 by cost | inv | no |
| Data Quality | mixed | no |
KPI band — eight tiles
| Tile | Formula | How to read it |
|---|---|---|
Revenue | Σ sold_price over soldF | Sub-line gives the card count. |
Cost of Goods Sold | Σ total_cost over soldF | Basis + grading + shipping. |
Gross Profit | revenue − cogs; margin = grossProfit / revenue | The single profit figure at the cards layer. |
ROI | grossProfit / cogs | A lifetime-of-sold-cards ratio, not annualised — a week-long flip and a three-year hold contribute identically. |
Invested Capital | Σ total_cost over inv | Cost basis tied up in unsold, for-sale inventory. |
Unrealized P/L | Σ (comp_as_is − total_cost) over only inventory with a comp; coverage = comped ÷ all inventory | If coverage is 40%, the number describes 40% of the book and says nothing about the rest. Directional, never a balance-sheet figure. |
Avg Days to Sell | mean of daysBetween(purchase_date, date_sold), counting only non-null values ≥ 0 | Cash conversion. |
Top-Card Concentration | max(total_cost over inv) / investedCapital | Single-name risk. |
The seven sections
| Section | Accent | Contents and the notable rules |
|---|---|---|
| Capital Health | violet | Gross Profit by Year — bars coloured per sign, but the series is Σ net_return while the KPI tile is revenue − cogs; they differ by the shipping margin (D-25). Capital Position is a 2×3 mini-stat grid at the same 240px height so the two cards align, using full-precision money() against the band's abbreviated moneyK() — same numbers, different formatting, by design. |
| Sales Performance | teal | Revenue vs COGS by Year (grouped bars — the gap between them is gross profit). Gross Profit by Platform (horizontal, unknown platforms bucket as the literal (unknown)). Units Sold Over Time (month key YYYY-MM; a card with year_sold but no date_sold becomes a YYYY-00 bucket rendered as a bare year — a stray bare-year point is the missing-Date-Sold backlog surfacing). ROI Distribution: six buckets from Loss to 300%+ over net_return / total_cost, skipping every card with total_cost ≤ 0, so the bars do not sum to the sold count. |
| Buying Discipline | sky | Average base_cost (not total_cost — this measures the purchase decision, before grading and shipping) by brand and by purchase platform, over live. Skips cards with base_cost ≤ 0, drops any group with fewer than 3 cards, takes the top 10. Absence is not zero. These are average ticket size, not performance. |
| Inventory & Velocity | amber | Aging buckets 0–30 / 31–60 / 61–90 / 90+ / No date by daysSince(purchase_date); a negative age matches no bucket and vanishes. Money in inventory by sport (cost basis, not market value). Dead Money: every inventory card 90+ days old, sorted by cost, with a suggested-action decision tree — no asking price → Set an asking price; asking > comp × 1.1 → Reprice — above comp; else Promote / discount. Capped at the 40 largest, but the headline total covers all of them. |
| Grading Performance | rose | Four pipeline chips (Graded + Ungraded = live exactly; In Pipeline is a subset of Ungraded; Has Cert overlaps both). Grade distribution. Grade Lift: mean net_return per grade, with everything ungraded collapsed into a single Raw bar unshifted to the front — the control group. The business case for grading is every green bar that clears Raw. Caveat carried in the doc: this is average profit per card, not lift per grading dollar, and better cards get graded, so some of the "lift" is selection bias. Grading Spend by Payer renders only when non-empty. |
| Mix & Exposure | indigo | Two donuts (sport, brand — brand collapses beyond 8 into a literal Other slice). Legend percentages are shares of the plotted top-10, not of total inventory, so they read high. Asking-price bands, and a Top-10-by-cost table. |
| Best & Worst Flips | green | Two tables of 8, over sold cards with total_cost > 0. Both are ranked by absolute profit, not ROI, despite the ROI column being present — a $2 card that returned 900% will never appear in Top Winners. |
| Data Quality | slate | Six flags. Intro: "These flags don't break anything — they tell you where the numbers above lean on incomplete data." The only section with no Analyst panel. |
Data Quality flags and how they feed back
| Flag | Definition | Red when | What it degrades |
|---|---|---|---|
Listed with no asking price | inventory with null-or-0 asking and at least one listing row | > 0 | A buyer can see it on a marketplace with no price. |
For sale, no asking price (unlisted) | the rest of the no-asking set | never | Backlog to price when you list it. |
Sold, missing Date Sold | sold with no date_sold | > 0 | Avg Days to Sell, and pushes points into the bare-year buckets of Units Sold Over Time. |
Sold for $0 | sold_price === 0 | > 0 | Revenue and margin. |
$0 total cost | live with total_cost === 0 | > 0 | Inflates ROI; those cards are skipped in the ROI histogram, so it undercounts. |
Inventory w/o comp | inventory with null comp_as_is | never | The denominator gap behind the Unrealized P/L coverage percentage. |
The AI analyst layer
Two mechanisms, one transport. CFO Board Briefing is a whole-dashboard panel
under the header (Brief the board ↗ → Preparing… →
Regenerate ↻) that sends 19 named metrics plus a verbatim scope instruction. Per-section
Analyst panels (Ask the analyst ↗ → Thinking…) appear on six of
the seven sections. Both POST {scope, metrics} to /api/analyst, which holds
the key and the CFO persona server-side. See §5 for the prompt's scope fence.
Footer, verbatim: Card-trading performance · computed live from {live} live cards · {sold}
sold · {inv} in inventory. Those three counts are the fastest sanity check on the tab and
should tie to the Ledger's own footer.
3.5Accounting — ledger, statements, waterfall
Emerald identity. Finances.jsx, ~2,346 lines. Two sub-views under one control
cluster, and a nine-sheet Excel export.
Green = you type it. The Ledger sub-view's hint reads "Entry surface — you type it"; Statements reads "Computed — finance & tax". Nothing on Statements is hand-entered except distributions and the profit-split sliders.
The control cluster
Sticky at top-14: an H2 still reading Finances, the eyebrow
Business books · non-card, + Add Entry (Ledger sub-view only), the year toggle,
the sub-view switch, and Export to Excel (Statements only, and only once Statements has
handed its export closure up).
YEARS = [{all},{2025},{2026}]. It does not derive from the data. When 2027 arrives
a developer must edit the array or the year is unreachable. D-35.
Scope helpers that govern everything
parseYMD(s) // splits 'YYYY-MM-DD' by hand into {y, m: m-1, d}
new Date(string) here
new Date('2026-01-01') parses as UTC and drifts to the prior
day/month/year in western timezones. That was the documented cause of a real miscount — yearly
recurrences counting as zero. Every date comparison in this file uses hand-split integers.
occurrencesInScope(entry, yearKey) walks up to 1,200 steps from the start month by
months_interval, using integer arithmetic only, and stops past end_date or
once it reaches the current month. The in-progress month is not counted until it ends
— a monthly bill started in January, viewed in mid-August, counts 7, not 8. Full algorithm at
§4.9.
Sub-view 1 — Ledger
A sticky five-stat SummaryBand computed over the currently filtered rows, so
it responds live to the column funnels: Operating Exp, Capital Equip,
Write-Off, Owner Capital, Personal (excl.), and a right-aligned
Total · in books.
Total · in books is not an expense total
It is Σ of every business, counts-in-books row — which includes CapEx and Owner Capital. The four category stats above it will not sum to it unless there are no uncategorised business entries.
Nine columns (Date · Title · Category · Vendor · Paid By · Unit Cost · Freq · Reimb ·
Total). A recurring row shows {frequency} ×{occ} in the Freq cell and
amount × occ in Total — the "caret-expand to a generated schedule" concept is
realised as an inline multiplier; there is no expandable schedule list in this build.
Prior-year / this-year / all-time totals come from flipping the year toggle.
Column filters use an explicit convention worth reproducing: the Set holds the CHECKED
(= shown) values; an undefined Set, an empty Set, or a Set containing everything all mean
"unfiltered". On Apply, a fully-checked list is deliberately stored as an empty Set. The
funnel draws active only when 0 < size < values.length.
Clicking any row opens the EntryPanel drawer — the whole row is the button; there is
no separate edit affordance. Validation, first failure wins: Title is required. →
Category is required. → Frequency is required. →
Recurring entries need a start date. Save normalisation forces
start_date/end_date to null when the entry is not recurring, and
owner_contributor_id to null unless the scope is personal.
There is no delete for ledger entries anywhere in the file — only distributions can
be deleted from the UI.
Field is defined at module scope on purpose. Defining it inside
EntryPanel remounted every input on each keystroke and kicked the cursor out mid-type.
Do not move it back. The same lesson appears independently in Collection's dropdowns.
Managed dropdowns (ManagedSelect)
One component drives all five lookup tables, each with an inline + add. The dedupe is
two-stage: an exact case-insensitive match silently selects the existing row; a
whitespace-insensitive near match refuses the first time and shows
Did you mean "{existing}"? Click Add again to create new. — clicking Add a second time
proceeds.
(1) A category added here is created with name only, so its is_capex /
is_capital_contribution flags default at the DB level and it lands in no summary
bucket until an admin sets them. (2) A frequency added here gets no
months_interval, so it is treated as one-off no matter what it is called — type
"Quarterly" and you get a single-occurrence entry. D-37.
The reimbursement lifecycle
Checkbox Reimbursable → select Reimbursed? with exactly three options
(Not yet (owed) / Partial / Fully reimbursed) → three more
fields when the state is not none. It reaches the books as:
if (e.reimbursable && who && nonCardOpexPaid[who] !== undefined)
nonCardOpexPaid[who] += Math.max(0, amt − num(e.reimbursed_amount))
- The outstanding balance is amount-driven, not state-driven. Marking an entry Fully reimbursed while leaving the amount at 0 leaves the full amount showing as owed.
Math.max(0, …)means over-reimbursement never becomes a debt back to the company — it clamps and the excess is invisible.- Any reimbursable category counts, not just Operating Expense.
- For a recurring reimbursable,
amtis multiplied by the occurrence count butreimbursed_amountis not — recurring reimbursables are effectively unsupported (D-34).
Sub-view 2 — Statements, in render order
| # | Block | What it says |
|---|---|---|
| 1 | KPI tiles | Gross Profit · Net Ordinary Income · Capital Recovered · Total Still Out (cohort). Tiles 1–2 are sold-year scoped and 3–4 are purchase-cohort scoped — two different frames in one row. |
| 2 | P&L Bridge | Five bars: Sales, −COGS, −OpEx, −Write-Off, Net Income. Despite the name it is a plain bar chart, not a stepped waterfall — no cumulative bars, no connectors. |
| 3 | Net Owed by Company → Partner | One bar per partner, from totalOwed. |
| 4 | Capital: Recovered vs Still Out | Stacked bars by purchase cohort. Still Out is floored at 0 for the chart so an over-recovered partner shows an emerald-only bar instead of a broken stack; the negative is visible only in the table below. |
| 5 | Operating Expenses by Category | Donut. Legend labels strip the exact prefix 'Operating Expense (Non-Card) – '. |
| 6 | Business Accounting · P&L (sold-year, IRS basis) | Six lines ending in Net Ordinary Income. Footnote states CapEx and owner capital are deliberately outside the P&L. |
| 7 | Cards Only · Capital Waterfall | Six rows per partner: cost advanced → ± cross-partner grading → adjusted capital advanced → − recovered → still out. |
| 8 | Shipping Ledger (Both Legs) | Collected − actual = margin, where a null label_cost is a wash. Carries the parenthetical "(already inside each card's Net Return — this is the breakdown, not an extra line)". |
| 9 | Write-Offs | Grouped by the five write-off disposition ids; each booked a $0 sale, so the full cost becomes a realised loss. A separate always-rendered amber watch line covers damaged, still in inventory — held, sellable, cost stays in inventory, not a loss yet, and deliberately not part of the total. |
| 10 | Non-Card Position (Ledger) | Out-of-pocket unsettled + capital contributions = non-card owed. |
| 11 | Total Owed (All Sources) | Card capital still out + non-card owed − distributions taken = NET OWED BY COMPANY. Footnote: "These do NOT sum to zero." |
| 12 | Reconciliation · Why the three don't sum to zero | The residual is realised gain/loss — money truly made or lost on cards, not a debt between partners. A negative figure is value that left the system entirely. |
| 13 | Distributions | Violet. + Record → a drawer with Date (drives fiscal year), Partner, Amount Taken, Kind, Note. Delete confirms. Any save or delete triggers a full refetch of all nine financial tables. |
| 14 | Out-of-Pocket by Payer & Owner | The 3×3 payer × owner matrix. Footnote states the invariant: recovery is capped at cost per card, so Cost Out − Recovered = Loss, and profit goes to the owner, not the payer, which is why it does not net against Payer Net. |
| 15 | Loss Carried by Partner | For personal returns. |
| 16 | Partner Accounting · Tax pass-through | profit kept − losses carried = net to personal return, sold-year basis. |
| 17 | Profit Gifts | Joins each gift row to its card, drops archived, filters by the card's year_sold, and reads Sale Profit directly from the DB's generated net_return — never recomputed, so the row cannot drift. |
| 18 | Profit Distribution | Sliders only when a specific year is selected. Upserts (year, partner_id, pct). Partner matching is by name prefix; a contributor whose name does not start with kevin/brian/bk is skipped silently. Nothing enforces 100% — the amber warning is advisory. |
| 19 | Tax Schedule | Per-card gain/loss, sorted ascending — biggest losses first, by design, because that is the harvesting worklist. The footer renders grossProfit as the column total, which is the app's strongest internal reconciliation assertion. |
Excel export
BKCards71_Accounting_{All-Years|YYYY}.xlsx, nine sheets: P&L Summary, Tax
Schedule, Partner Positions, Out-of-Pocket, Partner Tax, Capital Waterfall, Non-Card Ledger,
Distributions, Profit Gifts. Every number passes through Number(Number(v||0).toFixed(2)).
The export closure is re-registered on every change of scope, so the button always exports what is on
screen.
It filters a recurring entry in only when yearKey === 'all' and exports the
unit amount, not the scoped total. Documented behaviour, but a reconciliation trap.
D-39.
3.6Scan — the intake pipeline
Sky identity. Heading AI Scan. Two sub-tabs: Scan (the intake flow)
and Value (a placeholder card stating that per-grade market value comes from
SportsCardsPro and is a later phase, deliberately kept separate from detection).
Every uploaded photo and every PSA cert lookup becomes a row in scan_candidates — a
"potential card" — isolated from the cards table. Ximilar writes only to
scan_candidates and never to cards. In the bulk bring-in path, a
candidate with no suggested_name is skipped rather than guessed at,
because an unscanned card has no name and is not ready.
Intake path A — Add by PSA cert #
Paste one or many certs, separated by anything (the parser splits on [^0-9]+,
de-duplicates, and drops empties). The pipeline: POST /api/psa {action:'lookup'} →
duplicate guard (any cert already in cards is skipped) → build a
proposed object per result marked {identified:true, graded:true,
source:'psa'} → insert one scan_candidates row for the front (seq =
idx*2) and, when PSA returned one, a second for the back (seq = idx*2+1), sharing
a group_id. The report line is assembled conditionally, each clause appearing only when
its counter is non-zero: Added {n} card(s) from PSA · {n} already in the app · {n} had no PSA
image · {n} not found.
Intake path B — photo upload
Two modes: Front + back pairs (default — photos pair in order, 1st = front,
2nd = back) and Fronts only. Drag-and-drop, multi-select, or a folder picker (the
webkitdirectory attribute is applied in an effect, since React will not set it
declaratively).
Files are filtered to images, then sorted by name with numeric collation
(localeCompare(…, {numeric:true, sensitivity:'base'})) so img2 sorts before
img10 — that is what makes "processed in filename order" trustworthy for a folder drop.
One batchId is minted for the whole drop; each file is uploaded to
scan-uploads/{partnerId}/{batchId}/{0000-index}_{safeName}. Per-file failures
are caught individually and the loop continues — one bad file does not abort the batch.
Review and bring-in
Candidates are bucketed by group_id, fronts first, groups newest-first — one group is
one future card. The toolbar's Select all excludes groups where every item is already
accepted, so brought-in cards cannot be re-selected. Status pills: pending
slate, scanning sky and pulsing, scanned emerald, error red,
accepted violet.
The review panel seeds an editable object from the front row's proposal, resolving the grader text
to a lookup id via a two-stage match (exact case-insensitive label, then a loose substring match in
either direction). Twelve editable fields, three checkboxes, an alternatives list (up to 6 from
Ximilar; picking one overwrites the identity fields and rebuilds the name, preserving the current
parallel), a raw-JSON disclosure, and a ↻ button that rebuilds the name from the fields.
The primary button is disabled when the name is blank, with an inline amber warning saying so.
Card creation and the durability chain
buildInsertPatch lands every brought-in card as For Sale
(sale_status:'NOT_SOLD', disposition_id: 12) with all five money fields
zeroed, intake_source:'scan', and description set to the canonical name —
at intake the name field and the card description are the same thing. Photos are deliberately
not set in the insert.
PSA CDN or scan-uploads bucket
→ POST /api/photos { action:'attach' } (server-side download)
→ re-upload into card-photos at cards/{id}.{ext} / cards/{id}-back.{ext}
→ cache-busted public URL written to photo_url / photo_back_url
Nothing in the app keeps a long-term pointer to PSA's CDN, to CDP's
storage.googleapis.com URLs, or even to the scan-uploads bucket. Every
path — the PSA batch importer, the CDP CSV attach flow, and Scan's bring-in — routes through a
server-side download and re-upload. The attach call is wrapped in its own try/catch: photos are
best-effort, the card exists regardless.
Permissions
| Action | Manager required? | Enforced where |
|---|---|---|
| Upload photos | no | RLS only |
| Delete selected candidates | no | RLS only |
| Look up on PSA | yes | UI + api/psa.js |
| Scan selected (Ximilar) | yes | UI + api/identify.js |
| Bring into App (single and bulk) | yes | UI + api/photos.js |
Scan.jsx does not import canManage; it re-implements the manager array
inline (D-04).
3.7Card Editor — the one detail panel
CardEditor.jsx, ~1,903 lines. Described in its own header as "the ONE card
detail/edit panel", lifted out of Inventory.jsx specifically so every entry point opens
the same component. Current callers: the Ledger (description click, and Add Card), Admin Console
Bulk Edit (amber description click), the Integrity drill-downs, and Scan (after a bring-in, to
finish the card).
saveDetail builds its patch from the whole EDIT_FIELDS
schema, not from what changed, and writes null for anything empty. Opening the editor
on a partial projection would therefore erase real data on the next Save.
BulkEdit.openCard() re-fetches with the full CARD_SELECT before opening,
with the comment "fetched full so a Save can never blank a column that wasn't shown." Every
new caller must do the same. The host must also pass key={addMode ? 'add' : card.id}
so re-opening resets state cleanly.
The colour language
| Appearance | Meaning |
|---|---|
| green border, green focus ring | Editable. You type it. |
dark slate panel, grey mono text, suffixed (calc) | Calculated. Read-only — rendered as a <span>, so there is no input to focus and no way to override. |
slate, cursor-not-allowed | Temporarily locked — an editable field the platform's fee data is currently computing (Mark Sold's Net Sale). |
| sky border | The eBay What-If sandbox. Nothing there is ever saved. |
Field groups
Nine groups mirroring the Ledger's bands, plus two group headers (Names, Notes)
that have no GROUP_META colour and therefore render an invisible dot
(D-28). Money fields render a fixed $ inside the input frame,
absolutely positioned and pointer-events-none — it is chrome, never part of what you
type.
Notable controls: Grade is the 19-step ladder (1 → 10 by 0.5) as a
<select> with a leading blank — there is no free-text grade entry anywhere in the
app. Grade Qualifier is a combo (<input list> +
<datalist>) offering the six official PSA letter codes (MC, MK, OC, OF, PD, ST)
plus Pristine, Black Label and Gold Label, while always allowing free
text. Listed On is the one field that is not a card column — see below.
Listed On — the join-table field
A card can be listed on any set of marketplaces at once, so this lives in
card_listings and outside editForm, in its own Set of platform ids. A row
labelled none is filtered out of the options: unchecking everything is how you
say "not listed." An empty lookup shows No platforms defined. On save,
syncListings diffs against what was saved and issues one delete plus one insert, wrapped
so a listings failure never fails the card save. The dirty check includes the listings set, so
toggling a platform alone triggers the discard-changes confirm.
Card Name, provenance and the name engine
A dedicated bordered card above the photos: the provenance chip (PSA/SGC
when the grader is authoritative, else the session's nameMode, else the stored
name_source, else legacy), Generate from attributes,
Use intake (only when an intake title exists), the intake line, and a lazily-loaded
▸ Name history disclosure over card_title_events.
Auto-follow. The ten name drivers are card_year, brand_id, set_name,
subset, parallel, player, card_number, print_run, grader_id, grade. Editing any driver
rebuilds the Card Name live only when the name is currently an unlocked canonical name. A
hand-typed (locked) name or a PSA/SGC-authoritative name is never silently
rewritten. Editing the Card Name by hand sets nameMode='user' and locks it.
| Situation | name_source | name_locked | Also written |
|---|---|---|---|
| Add mode | canonical if generated, else user | true unless canonical | intake_title = name, intake_source = 'app' |
| Edit, name changed | from nameMode | true unless canonical | a card_title_events row (best-effort) |
| Edit, name unchanged | not written | not written | — |
The Status dropdown and its stale-state guard
Five options: For Sale, Not For Sale, Sold,
Gifted (free — write-off), Write Off… (lost / destroyed). The last two are
pseudo-statuses that resolve to a $0 sale with a Loss disposition.
changeStatus() re-reads the live row first. If the re-read fails, nothing happens
and the user is told the database could not be reached. If the live status differs from the cached
one, the panel refreshes and the action is abandoned with "This card changed
elsewhere — it's actually 'X' now. The panel has been refreshed; pick again if you still want to
change it." The incident this prevents: a stale tab skipped the un-sell chooser and stranded
ghost sale data on card #1692.
The For-Sale ↔ Not-For-Sale flip is a single confirm and always resets
disposition_id to 12 or 13. Leaving SOLD is never a plain
confirm — it always opens the un-sell chooser. A write-off from a sold card is refused
outright: "This card is Sold — its exit is already booked. Un-sell it first if the sale was
wrong."
Mark Sold
Prefills from the live re-read row. Six fields; validation runs in a fixed order and the
first failure wins. Net Sale is auto-filled from the fee engine (§4.8) and
locked for platforms that carry fee data — tooltip "Computed from the platform
fee — not editable", with a live label suffix showing the maths, e.g. (−12.35% −
$0.40) or (−8% tier). Editing Total or Platform re-runs the estimate.
Two checks mirror the cards_net_integrity database constraint so the user gets a
sentence instead of a Postgres error: "Net Sale can't be negative — money received is never
below $0." and "Net Sale can't exceed Total (gross) — fees only ever reduce the sale price."
(tolerance +0.005). The same rule is enforced in ordinary Save, in Bulk Sale's
per-card clamp, and in Card Journey's hand-entry form.
Confirm Sale writes ten columns including year_sold derived in JS from the date, and
sold_price = net_proceeds. It then logs a sold event, re-reads the row,
fires onSaved, and closes with Marked sold ✓.
The sale-time profit gift prompt
Fires immediately after a successful sale when all three hold: the owner is
known, the owner is not BKCards71, and profit is strictly positive. Offers a gift amount
with 25% / 50% / All quick-fills, capped at the card's profit.
Saving resolves the owner to a fin_contributors row (exact case-insensitive full name
first, else first-token match) and refuses helpfully on 0 or 2+ matches. Upserts
fin_profit_gifts on conflict card_id.
Gift Profit to BKCards71 moves money between partners on a sale that happened. Gifted (free — write-off) in the Status dropdown gives the card away for nothing. They share a word and nothing else.
Gift the card, and Write off
Both book a $0 sale: sale_status='SOLD', gross/net/sold all zero, a
Loss disposition, and year_sold derived from the chosen date, so the card's full cost
writes off as a loss in that year. Gift explains "Gifts are always free — if money changed
hands, it's a sale." Write-off offers four radio stories (Lost 6, Forgery 7, Ripped Off 8,
Destroyed 16) and points at the alternative: "Just damaged but still in hand? Cancel and tick the
Damaged box instead — no money moves until it actually leaves."
Damaged is a checkbox on the card. The card is still in hand, still sellable, and no money moves. It becomes a write-off, as Destroyed, only on the day it is actually discarded. The Accounting tab reports damaged-but-held separately from write-offs for exactly this reason.
The un-sell chooser — four stories
Any move off SOLD opens Un-sell card, which first restates what is on
file: "Recorded sale: $X net on YYYY-MM-DD. What actually happened?"
| Mode | Label | Financial effect |
|---|---|---|
strike | Strike the sale — it was a mistake | No money ever moved. The sale block is cleared; the ledger takes zero penalty. Old values survive in the audit log. |
return | Returned & refunded | Sale cleared and handling += return cost — whatever the round trip cost adds to the card's basis. A note is appended recording the amount and date. |
canceled | Order canceled & refunded — the card never shipped | Sale cleared. The card was never out of inventory. "This is what eBay's 'canceled' orders are — not a return." |
kept | We kept the money AND got the card back | The original is not modified at all — the sale stands. A new card row is inserted at $0 basis, no cert (the slab identity does not follow the copy), intake_source:'reintake', and a note naming the source card. No double counting either way. |
SALE_CLEAR nulls twelve columns. Before wiping,
saleSnapshot() captures every one of them, and the snapshot goes into both the audit
row's detail.cleared and the journey event's — "The card's columns are about to lose
these numbers; the timeline is where they survive."
After the write, the panel re-reads the row and rebuilds editForm from it. Without
this the form still held the pre-un-sell values and the next Save wrote the wiped sale straight
back — which silently resurrected card #809's sale 39 seconds after it was reversed.
Photos
Add mode shows a dashed placeholder: "Save the card first, then click here to add a
photo." — a photo cannot be attached before the card exists. Otherwise two 190×266 tiles,
Front and Back, uploading to
card-photos/cards/{id}.{ext} and cards/{id}-back.{ext} with
upsert:true and a ?v={timestamp} cache-buster. Remove confirms and then
nulls the column only — the storage object is not deleted. The file input is reset
in a finally so the same file can be picked twice in a row.
Save, Update, Archive, Delete
| Button | Behaviour |
|---|---|
Update (sky) | Save and stay open; re-reads the row so the calc fields show the DB's truth. Updated ✓ — saved, still editing. Hidden in add mode. |
Save (green) | Save and close. |
Cancel / × / scrim click | All run the same guarded close. A field-by-field dirty check (including the listings set) triggers Discard unsaved changes? Your edits will be lost. Clean panels close silently. The un-sell, gift, write-off and mark-sold modals do not use this guard — nothing has been written yet, so their Cancel simply dismisses. |
Archive (amber) | Confirms, writes archived, archived_at, archived_by, logs an archived event, and closes. |
Restore (sky) | The one state change with no confirmation — it is non-destructive and reversible by Archive. |
Delete Permanently (red) | Rendered only when the card exists and is archived and partner.role === 'admin'; the handler re-tests the role. So there is no path to permanent deletion that does not go through Archive first. It removes the front image from the bucket (wrapped so a storage failure never blocks the delete), then deletes the row. |
Add mode's only guard is Description is required to add a card. On insert it applies
For-Sale defaults, coerces all five cost inputs from null to 0, lets the database mint the id, logs a
purchased event whose gross is the sum of the cost inputs the user typed
(because total_cost has not round-tripped yet), then switches out of add mode in
place so a photo can be attached immediately.
Two eBay helpers
The What-If sandbox (sky-bordered, bottom of the panel) is the only place in the
editor with hardcoded default rates (12.35%, $0.40, $6 shipping, 6% tax). It computes
fee = feePct% × (price + shipping + tax) + perOrder and shows the projected net profit
against the live total cost. Explicitly a scratchpad — no write path touches it.
The shipping-collected correction: editing eBay Shipping Collected on an
eBay-family platform recomputes Net Sale as
stored_net + (fee_pct/100) × (stored_shipping_collected − new_value). Both baselines come
from the stored card, not the form, so the result is stable across keystrokes rather than
compounding. The rate comes from sold_platforms, falling back to 12.35.
3.8Card Journey — the per-card timeline
Opened from the 🕘 History button in the editor header (never in add mode). A centred
modal over card_events.
From the file header: "the card row remains the single source of truth for money. This panel
RECORDS history, it never computes a financial figure and nothing in the P&L / tax schedule /
partner waterfall reads card_events. A returned sale therefore stays VISIBLE here
forever instead of vanishing when the un-sell chooser wipes the card's sale block."
Sort model — three server-side keys: sort_hint ascending (the
lifecycle position, stamped at write time), then occurred_on ascending with
nulls last, then id as a deterministic tiebreak. Events with no date
render date unknown in italics rather than sorting to the top.
21 rendered event types (§9.1); an unrecognised type degrades gracefully to a
bullet, the raw type string, and neutral styling. Four types form the strike-through
set — sale_reversed, returned_refunded,
order_canceled, reintake_kept_paid — whose money renders struck through
because it was recorded and then reversed. sale_corrected is deliberately not in
that set: its amount is the money that stands.
Each entry shows the title and platform label, the date, a money line (cost $X in
amber for purchased, gross $X for everything else; net $X in
emerald; cost_delta in rose, labelled fee $X for
sent_to_grading), an attribution line, an italic note, and a provenance badge —
reconstructed from card record for source='seed', added by hand
for 'manual'. App and bulk sources carry no badge.
Hand entry
+ Add event opens an inline form headed by the amber warning "⚠ Events can't be
edited or deleted afterwards — check before you add." The offered type list is deliberately a
subset: notes, physical facts (Found, Lost, Loaned out, At a show, Damaged), grading and listing
milestones, and five explicitly historical money types (Sold — historical,
Returned & refunded — historical, Order canceled & refunded —
historical, Sale corrected — historical, Sale struck — historical).
What is absent matters: purchased, live sold,
gifted, written_off, archived, restored and
reintake_kept_paid can only ever be written by the flow that actually performs the
action. Hand-entered rows carry source:'manual' and the same net-integrity validation
the editor enforces. Footer, always visible: "History is a record, not a calculation — the card's
own fields remain the source of truth for all money."
3.9Bulk Edit — picker and apply modal
Lives in the Admin Console. Loads all non-archived cards client-side, paged 1,000 at a time. Intro, verbatim: "Filter and select cards, then Edit selected to change one or more fields across all of them at once. … Archived cards are excluded. Derived values (total cost, net return, years, projected profits) are never bulk-editable - only their source inputs. SKU, Status, Sale Type and Mark-Sold are edited per-card in the editor."
Search matches id, SKU, description or player. Eight multi-select facets (Status, Card Type, Item
Type, Brand, Grader, Location, Owner, Card Year), each with Select all /
Clear and live per-value counts; blanks group under (blank) and sort last.
Facets AND across keys, OR within a key. Nine sortable columns, blanks always sinking last, ties
broken on id. The header checkbox selects everything in the current filter, and
selection survives filter changes.
The bulk-edit modal — 8 groups, 31 fields
Every row is a checkbox + label + a control that stays disabled and grey until the field
is ticked. Subtitle: "Tick the fields to change. Untouched fields are left alone. Blank =
clear the value." Lookup selects load only rows where active !== false.
Not bulk-editable by design: description (use Generate names),
sku, sale_status, disposition_id, every sale field,
archived, damaged/damage_note, cert_number, the
listings join, photos, and all five calculated columns. Sales are made one card at a time in the
editor, or as a lot in Bulk Sale.
The three-layer safety model
| Layer | Trigger | Effect |
|---|---|---|
| 1 · cost-basis acknowledgement amber | any of base_cost, tax, shipping, handling, grade_fee is ticked and the selection contains — or might contain — a SOLD card | Apply is disabled until ticked: "💰 N of these M cards are already SOLD. You're changing <fields>, which rewrites Total Cost → Net Return → the P&L, the tax-year totals and the partner waterfall for sales already on the books." |
| 2 · clear acknowledgement red | any ticked non-toggle field has nothing typed, which means it will be erased | Apply is disabled until ticked: "🧨 This will ERASE values on all N cards …" |
| 3 · the confirm dialog | always | An itemised Label → new value summary (selects resolve to their label, toggles to Yes/No, empties to (clear)) plus up to three stacked warnings. |
The selection may be a partial projection (the Integrity drill-downs pass
trimmed rows). Rather than pretending an unseen row scores zero, the modal counts SOLD only where
sale_status is actually present, counts the rest as unknown, adds the
rows it cannot see at all, treats unknown > 0 as enough to demand the cost
acknowledgement, and discloses the shortfall explicitly: "(Estimate only: N of the M
selected rows aren't fully loaded here, so the overwrite count above may be low.)"
Apply writes one update(patch).in('id', chunk) per 500 ids, patching
only the ticked columns — so the database recomputes total_cost,
net_return, year_sold and the projections itself. A best-effort
user_audit row records fields, values, count, cleared columns, cost fields touched, the
sold-card count and every id. The modal cannot be dismissed while saving.
Generate names (bulk)
Rebuilds the canonical Card Name from each selected card's attributes.
Skips every card with name_locked = true — PSA/SGC-authoritative names
and hand-typed overrides are never clobbered — and drops cards whose attributes produce an empty
string. The confirm names both counts explicitly. Writes in batches of 40 with
name_source:'canonical', name_locked:false, plus one
card_title_events row per card and a bulk_generate_names audit row, both
best-effort.
Attributes → name. There is no reverse parser anywhere in the components: a grep
for parseName, backfill, fill-fields and conflict across
src/components returns nothing. The nearest thing is the Generic CSV importer's
"matching + fill if empty" roles, which fill blanks — they do not parse a name into fields. Any
name→field extraction that exists lives in the importers' own parsers (§4).
3.10Bulk Sale — one order, one fee, penny-exact
Launched from the Ledger's select mode. Sell a set of cards as one order on one platform: you type the single total you actually received, the app splits it across the cards proportionally by asking price to the penny, runs the order through the fee engine for net, and books every card sold.
The asking-price guard
Any card whose effective asking price is ≤ 0 blocks the whole sale. An amber panel
lists every blocking card with an inline price box, and the entire order form is dimmed and
non-interactive until they are priced — "the split is weighted by asking price, so every
card needs one." A typed override counts; the stored value is the fallback.
The fee engine applies to the order, not the card
netForOrder uses the same tier-then-flat rules as the Card Editor
(§4.8) but against the order total, because one order incurs one
fee. This differs meaningfully from selling the same cards individually: a per-order flat fee is
charged once for the lot, and a tiered platform brackets on the lot total rather than on each
card.
grossᵢ = allocate(orderGross, askingWeights)[i]
feeᵢ = allocate(orderFee, askingWeights)[i]
netᵢ = clamp(round(grossᵢ − feeᵢ, 2), 0, grossᵢ)
The clamp enforces the same net-integrity invariant as everywhere else. A preview table shows per-card asking / gross / net with a Totals footer proving the allocation lands exactly on the order totals. The allocation algorithm is at §4.5.
Commit
Book N sales enables only when every card is priced, a platform is chosen, a date is
set, gross > 0, net is computed and ≥ 0, and nothing is in flight. A second guard refuses a
negative net outright. After the confirm, in order: (1) persist captured asking
prices, so the card's own record carries the price the split was based on; (2) one
UPDATE per card writing the sale block with its allocated gross and net; (3) one
sold journey event per card with source:'bulk', carrying that card's own
share plus the order context and lot_card_ids.
lot_card_ids exists
The whole lot can be reconstructed from any one card. The comment records the reason: "Without this a bulk-sold card's timeline simply stopped — and a blank timeline can't be told apart from 'nothing was recorded'."
The loop is sequential and stops on the first error. Cards already written stay written;
recovery is to fix the failing card and re-run the remainder. Neither Bulk Sale nor its host writes
a user_audit row for the lot — the per-card journey events are the only record.
D-30.
Asymmetries with Mark Sold, worth knowing: a bulk sale writes no
received_method_id (Proceeds To), offers no Trade sale type, and never raises the
profit-gift prompt.
3.11Admin Console
A full-screen modal opened by the header gear (or deep-linked to Integrity by the badge). The title is role-dependent: managers see Admin Console / Configuration · users · data; everyone else sees My Account / Your profile.
| # | Tab | Key | Visible to | Purpose |
|---|---|---|---|---|
| 1 | My Profile | profile | everyone | Self-service name edit; everything else read-only. |
| 2 | Users | users | manager | Invite, edit, suspend, remove, delete; the activity log. |
| 3 | Dropdowns | dropdowns | manager | Nine managed lookups. |
| 4 | Photo Import | datatools | manager | Coverage table, PSA backfill, CDP CSV photos, raw fuzzy matching. |
| 5 | Data Import | import | manager | Seven importers + Tier-1 auto-detect (§3.12). |
| 6 | Purchases | purchases | manager | Manual matching of purchase records to cards; conversion to live cards. |
| 7 | Integrity | integrity | manager | 18 standing data-quality checks. |
| 8 | Bulk Edit | bulkedit | manager | §3.9. |
| 9 | Settings | settings | manager | Platform fees and price-bracketed tiers — the tab is labelled Settings but contains exactly one subject. |
Default landing tab: users for managers, profile otherwise. A deep-linked
section is honoured only if that tab exists for the caller's role, and every render is double-gated.
Help & Support deliberately does not live here — "one entrance, one home".
3.11.1 · Integrity — every check
One paged read-only fetch of all cards where deleted_at IS NULL, joined to graders,
locations and dispositions; everything is then computed in the browser in a single
useMemo. There is no server RPC and no SQL view.
"Archiving resolves an issue, so it never nags." Both collision checks
(dupcert, skucollision) also run over the live set only.
Header blurb: "Standing data-quality checks, grouped by area. Archived cards are excluded. Click any count to see the exact cards, with a CSV download. {N} total flagged." Footer: "Scanned {n} live cards ({m} incl. archived)."
The 18 checks
| # | Key | Label | Logic | Inline fix |
|---|---|---|---|---|
| Category 1 — Sale & financial consistency emerald | ||||
| 1 | soldmissing | Sold, missing details | sale_status === 'SOLD' && (sold_price == null || sold_platform_id == null || year_sold == null) — an exact day is optional; the year is what drives accounting | Sold $ |
| 2 | saleonunsold | Sale data on unsold card | sale_status !== 'SOLD' && saleResidue(c), where residue is any of six signals: sold_price > 0, net_proceeds > 0, platform_sold_price > 0, a date_sold, a year_sold, a sold_platform_id, or a marketplace order number. "A reversed sale should leave none of these behind." | — |
| 3 | statusdisp | Status and disposition disagree | No disposition → not flagged (that is a separate problem). SOLD → flag unless (category==='Sold' && is_sold) or category==='Loss'. Otherwise → flag if category==='Loss' or (category==='Sold' && is_sold). "This is what a half-reversed sale looks like." | — |
| 4 | soldnoorder | Sold with no order number | SOLD with a platform but no platform_sale_order_id, excluding Loss dispositions (gifts and write-offs have no marketplace order). "A refund on it would never be spotted." | — |
| 5 | wrongsaleyear | Wrong sale year | date_sold present and year_sold missing or ≠ the date's year. "This corrupts your 2025/2026 tax buckets." | — |
| 6 | soldzero | Sold for $0 or less | SOLD with sold_price ≤ 0 — confirm gift vs. data error | Sold $ |
| Category 2 — Cost & acquisition sky | ||||
| 7 | missingcost | Missing / $0 cost | base_cost == null || === 0. The largest backlog bucket — the header badge explicitly refuses to count it, calling it "a data-entry queue, not an alarm" | Base Cost |
| 8 | badpurchasedate | Bad purchase date | no purchase_date, or purchase_year missing or ≠ the date's year | Purchase Date |
| 9 | negmoney | Negative money | any of base_cost, tax, shipping, handling, grade_fee, asking_price, sold_price is < 0 — it silently distorts total_cost → net_return | — |
| Category 3 — Grading violet | ||||
| 10 | dupcert | Duplicate certs | every live card sharing a non-blank cert — the same physical card entered more than once, which double-counts cost and inventory | — |
| 11 | gradednocert | Graded without cert | looks graded but no cert, excluding cards out at the grader (location label matching /GRAD/ and not sold), where the cert is legitimately still pending | Cert # |
| 12 | certnotgraded | Cert but not graded | a cert but no grader/grade — a cert implies a graded card | Grade |
| Category 4 — Ownership & references amber | ||||
| 13 | missingownerpayer | Missing owner or payer | owned_by == null || paid_by == null — without them the partner waterfall and capital return cannot be computed | — (bulk-editable from the drill-down) |
| 14 | skucollision | SKU collisions | live cards sharing a SKU (case-insensitive). SKU is a match key for the eBay and CDP importers, so a collision misroutes imports | — |
| Category 5 — Impossible / sanity rose | ||||
| 15 | soldbeforebought | Sold before bought | ISO string compare: date_sold < purchase_date — usually a typo'd year | — |
| 16 | futuredate | Future date | either date after today — a future sale date lands revenue in the wrong tax year | — |
| 17 | impossibleyear | Impossible card year | card_year blank, < 1900, or > thisYear + 1 (next year is allowed, for pre-release product) | Card Year |
| Category 6 — Collection-facing teal | ||||
| 18 | showcasenophoto | Showcased, no photo | showcase === true and no front image | — |
Every non-zero check renders identically: an amber-bordered card with an amber count pill. Every
zero check renders slate with an emerald clean pill and its "good" sentence. The six
category accent colours are identity, not severity. The broken-vs-attention model
exists only in the header badge — see §7.
The origin of the coherence checks: card #809 sat as NOT_SOLD
carrying disposition 3 (Sold – Straight Sale) and a full sale block, because a stale edit form
re-saved a sale that had just been reversed. Nothing flagged it; it was found by hand. Checks 2 and 3
exist so that shape can never hide again.
The drill-down modal
View {n} cards → opens a table (ID · Description · optional Fix column ·
Cert · SKU · Grade · Status · Cost · Sold $ · Sale Date) with four actions:
- Click an ID or description → re-fetches the row with the full
CARD_SELECTand opens the shared Card Editor. Same guardrail as everywhere: "fetched full so a Save can't blank a field." On close it re-runs the checks and re-syncs the app's card array. - Inline fix — only the seven checks in
INTEGRITY_FIX. A single typed input plus Save (Enter also saves). On success it optimistically removes the row from the open list, then re-syncs and re-runs in the background. - Multi-select → Edit selected → the shared bulk-edit modal, with the honest-unknowns counting described in §3.9 (these rows are exactly the partial projections that motivated it).
- Download CSV — eleven columns, filename derived from the check label.
3.11.2 · Users
Reads partners plus the 50 most recent audit rows. The Refresh button
first POSTs syncStatus: Supabase stamps auth.users.confirmed_at when an
invitee sets a password, and only the service key can read it, so the client asks the
server to promote invited → accepted. Failure is swallowed; it still refreshes.
Row actions: Edit (inline names + role + finance radios), Resend (invited only), Reset pw, Suspend / Reactivate, Remove login, Delete. Every one confirms and every one audits. See §5.6 for the server-side guardrails, which are the ones that actually hold.
The Finance radios are disabled and pinned to Yes for manager roles, both client-side and in the
invite route (MANAGER_ROLES.includes(role) ? true : !!canAccessFinance), so the role and
the flag can never contradict. Editing a name writes first_name,
last_name, name and display_name together, because names
drive Owned By / Paid By across the app.
Footer note, verbatim: "Company, Partner and Admin always have Finance & Insights access, and their accounting identity is permanent — their login can be removed but the record stays so historical cards keep their owner and payer. Only User and Viewer accounts can be permanently deleted, and only when no cards reference them."
3.11.3 · Dropdowns
Nine lookup tables (§2.3), each id, label, active, sort_order.
- Add — free text; a case-insensitive duplicate is refused with
"{existing}" already exists.New rows getsort_order = max + 10, so they land at the end with room to reorder later. - Rename — in place; cards referencing the row by id are unaffected.
- Retire / Restore — flips
active. Retired rows hide from future dropdowns and render struck-through behind a Show retired values checkbox. - There is no delete path in this UI at all. Retiring is the only removal — that is the referential-integrity guardrail.
dispositions is deliberately excluded: "Disposition is managed separately because
it drives accounting behaviour." sports is also absent, though it is bulk-editable
elsewhere.
3.11.4 · Photo Import
Four parts. Everything routes through POST /api/photos, which holds the PSA token,
downloads the third-party image, and uploads the bytes into the bucket — max 8 items per server call,
so the client loops.
(a) Status header — four live head-only counts: With fronts · Missing fronts · Missing backs · PSA certs ready to try.
(b) Photo Coverage by Grader — computed entirely in the browser. Rows PSA / SGC /
Other graded / Raw / TOTAL; columns Cards · Complete (F+B) · Front Only · Back Only · No Photos ·
Ready To Try. The buckets are mutually exclusive by construction:
Complete + Front Only + Back Only + No Photos = Cards, and the caption says so.
Every non-zero count is a drill-down with its own CSV. Ready To Try = PSA cards with a cert,
no front photo, and no recorded PSA verdict yet.
(c) PSA Cert Photos — the backfill, sub-labelled "100 free PSA calls per
day". A Calls this run input (1–100, default 90) loops psaBatch in
chunks of ≤5 until the cap, a user Stop, a quota hit, or nothing left. Every log line is explicit,
including cert {n} (card {id}): PSA has no front image — recorded, won't retry and
STOPPED: PSA daily quota reached — run again tomorrow.
(d) CDP CSV Photos (by cert) — parses a CDP export in-browser, matches by cert
(batched 200 at a time) and by base SKU (/^(BFL-[A-Z]*\d+)/, "because
the random suffixes differ between systems, so both sides are normalized the same way"), with cert
winning over SKU. Only the missing sides are sent; already-covered rows are counted
as fully covered and skipped, and the server guards each side independently anyway.
(e) Raw Photo Matching — propose → review → approve, for cert-less cards. Rows
with a cert are excluded on ingest ("they belong to the cert pipelines") and reported.
Scoring is a Jaccard token-set similarity between normalised title and description, +0.05
for a matching year, +0.05 when the ledger player is contained in the CSV player, and an
exact normalised match forced to 1.1. Tiers: EXACT ·
STRONG (≥0.80) · MAYBE (≥0.45) · NO_MATCH.
The bulk buttons (Approve all EXACT, Approve all STRONG) skip and report three classes: rows with no matching card, rows whose card already has a photo (protected, never overwritten), and multi-match rows where more than one CSV row proposes the same card. There is deliberately no MAYBE bulk-approve button — MAYBE must be ticked row by row. Attaching confirms, batches by 8, and marks attached rows so they cannot be re-sent.
The behaviour exists but is unconditional and implicit: attachImage() always
downloads the third-party image and re-uploads it into card-photos. Every path in this
tab localises photos by construction. The standalone tool the owner may remember is the local Node
script import-photos.mjs, which is not part of the Admin Console.
3.11.5 · Purchases
Reads up to 2,000 rows from purchases, newest first, joined to cards.
Manual matching only — nothing here is automatic. A 4-way filter (all / unmatched /
matched / converted), a search over item name, order number and seller, and a thumbnail that
hover-pops a 288px card with the full order metadata.
Match {n} to card →— a picker searching by description, SKU, and by numeric id when the query is all digits; picking one setslinked_card_idandmatch_statuson all selected purchases at once.Turn {n} into Live Card(s)— already-converted rows are filtered out first. SKUs are allocated by scanning existingBFL-app-%SKUs and issuingBFL-app-{00000}sequentially. New cards take the item name, order date,base_cost= the order total with every other money field 0, the order number, the purchase image, and For-Sale defaults. The loop aborts on the first insert error, so a partial batch is possible and the message reports how many were actually created.
3.11.6 · Settings — platform fees
Intro, verbatim: "These rates drive every Mark-Sold estimate and the eBay importer — live, no deploy needed. Flat model: net = gross × (1 − fee%) − per-order fee. Platforms with tiers pick the bracket by sale price instead. Estimates are always trued up by the real payout."
A flat-model table over sold_platforms (retired platforms still appear, marked), where
the Tiers cell reads {n} tier(s) — tiers govern; then one sub-table per tiered platform
over platform_fee_tiers, plus an add-tier row. Every value must be a finite number ≥ 0;
tier deletion confirms; every mutation writes a fees_update audit entry.
The tiers table has no gap/overlap validation — adjacency is implied by ordering, not
enforced (D-45).
IdentifySection (the AI Identify / Ximilar test panel) is fully implemented but is
not in SECTIONS and is never mounted. It posts to /api/identify, writes
nothing, and would cost ~20 Ximilar credits per card. D-46.
3.12Data Import
DataImport.jsx is a shell around seven importers. It owns exactly three things: the
mode toggle, the Tier-1 drop/detect layer, and the Generic CSV importer implemented inline. Only
the active importer is mounted, which is why the hand-off store is kept per importer.
| Group band | Buttons (left → right) | mode | Books money? |
|---|---|---|---|
| Import slate | Generic CSV | generic | writes prices/dates, never a status |
| PSA sky | PSA Grading Return | psareturn | no |
| eBay emerald | Purchases · Listings · Sales | ebaypurchases · ebaylistings · ebaysales | Sales only |
| CollX cyan | Sales | collxsales | yes |
| CDP fuchsia | CDP Batch | cdp | no |
The eBay ordering is deliberate and documented: Purchases → Listings → Sales, the life-cycle order of a card, not alphabetical.
3.12.1 · Tier 1 auto-detect
Verbatim from the source: "Drop any file anywhere on this screen and fileDetect.js reads its
header row to say which importer it belongs to, then switches to that importer. NOTHING IS PARSED
OR WRITTEN HERE. The verdict only moves you to the right screen; the file is still handed to the
importer by you, and the importer still asks for approval before a single row is written."
fileDetect.js never parses rows, never touches the database, and never routes anything
itself — it returns a verdict and the caller decides.
Signatures, gates, the confidence model and the page rules are documented as an algorithm at §4.2. The screen-level behaviour:
- Drag overlay — a full-screen dashed-sky target reading Drop to identify · eBay · CollX · CDP · PSA — I'll read the header and open the right importer. Drag tracking uses a depth counter on window-level
dragenter/dragleaveso nested elements do not flicker the overlay. Listeners exist only while Data Import is mounted. - Verdict cards are coloured by confidence — "so 'I'm sure' and 'I'm guessing' never look alike. Low means the gate matched but the importer will still refuse it." Each shows the filename, the label, the confidence, an amber
books moneybadge where applicable, the destination and blurb, the literalreason(the matched header line, trimmed), and each warning. - Hand-off happens only when all three hold: the whole drop resolves to one importer with zero unknowns, that importer is on
HANDOFF_READY, and every item ishighconfidence. Otherwise the screen switches you there to look but never auto-loads. - A mixed drop switches nothing and shows the breakdown: "guessing which half of a pile you meant is exactly the kind of 'helpful' that loses money."
handoffsis merged, never replaced — because only the active importer is mounted, a new drop must not evict a file another importer is still holding. The panel says so:Files still held: ebaysales (1) · cdp (2) — each importer keeps its own until you press Clear.- Every receiving importer routes the hand-off through the identical
loadFilea manual pick uses — the comment repeated in each file: "auto-detect saves a click, it never skips a step."
3.12.2 · Generic CSV — propose → review → approve
Header: CSV Import — any file, propose → review → approve. Four stages: upload → map →
review → apply.
- No silent overwrites of money fields, ever — every change is shown old-beside-new and nothing is ever pre-selected.
- Sold Price / Date Sold only write to cards already marked Sold. This tool never flips a card's status. When a value is present but the card is not Sold, a non-selectable row is still pushed reading
Sold Price: (card is not Sold) → $X (skipped)— the user sees that the tool saw the value and refused it. - Archived cards are never matched.
- Renames are ordinary reviewable changes, opt-in via a checkbox.
Eleven column roles, single-use (assigning a role clears it from any other column), auto-mapped by
a ten-entry whole-header regex table. Two gates disable Match & review: at least one of
cert/name/sku must be mapped (Map Cert Number or Card Name first.) and at least
one writable field must be mapped or the rename box ticked. A mapping that could match but could not
write anything is refused, and vice-versa.
Serial Number used to auto-map to cert. In CDP's inventory export that
column is the card's print-run serial (150, 399, 75 — as in /150), not a certification
number, and it arrived on the mapping screen pre-selected as Cert — one tick away from writing
150 onto a card as its cert. "Audited 2026-08-20: no card was ever corrupted this
way. Left unmapped."
Matching is a six-rung tier ladder, first hit wins: CERT → SKU →
EXACT → STRONG (≥0.8) → MAYBE (≥0.45) →
NO_MATCH. The fuzzy scorer here is local to the file and is not
cardMatch.js. Date parsing handles Excel serials, Date objects,
YYYY-M-D and M/D/YY — added after a Date Sold silently vanished and no
change was ever proposed.
Review offers four bulk-selection buttons and one apply button. Note what is not offered:
there is no "select overwrites · STRONG", and MAYBE is never bulk-selectable at all.
Multi-match (collision) rows are likewise excluded from every bulk-select and must be ticked
individually. Apply confirms with the change and card counts, and separately names the overwrite
count. If date_sold is written, year_sold is derived alongside it.
3.12.3 · eBay Sales — the money importer
The only eBay importer that books money. Matches by eBay Item # first, then base
SKU, then title (cardMatch score ≥ 0.5), then a maybe tier (≥ 0.34) that is
never auto-ticked. Uses the file's real Sold For and Shipping — no fee or shipping
guesses; the fee comes from sold_platforms as data, falling back to eBay's 12.35% +
$0.40 only if the platform list has not loaded.
eBay's orders report has no refund column — 82 columns and not one says it. A full refund is visible only arithmetically: the order's components add to a real number while Total Price is $0.00, because eBay zeroes the total when the money goes back to the buyer.
refunded ⟺ order.total <= 0.004
&& order.components > 0.004
&& (order.ship > 0.004 || order.tax > 0.004 || order.paidDateSeen)
Two structural rules make it correct. (1) Aggregate per ORDER, never per line — a multi-item order carries its total on one line only, so a line-level test flags every other line of a perfectly good order (28 such lines in one real report). (2) The false-positive guard — some older combined orders arrive with money columns simply unpopulated; the proof they are an artifact is that $0-shipping-and-$0-tax orders never have a populated total, and all carry a real shipped date. A genuine refund always shows populated money. The guard was added after the first live run flagged 29 orders when only 18 were real.
Validation, Aug-02-2026 report, 514 orders: 478 match components exactly, 4 within $2, 18 show
the zero-total signature. Spot-check 15-14761-19242: $6.00 + $5.62 + $0.45 =
$12.07, shown on eBay as "Refund −$12.07 / Order total $0.00". Penny-exact.
Six buckets: NEW SALE · MISMATCH · DONE · NOT IN APP · ⛔ SKIPPED · 🚨 REFUND BOOKED AS SALE. The last requires the order numbers to agree — "A refunded line matched by an 86% title score to a card sold under a different order number is a different sale of a similar card, not overstated revenue."
The refund sweep headline is always rendered after a match, including when the answer is none — "a silent screen can't tell you it checked." In the red state it tells you what to do: open each card and un-sell it, choosing Order canceled & refunded if it never shipped or Returned & refunded if the buyer sent it back — either keeps the history.
autoTick = bucket === 'sale' && !dupTarget && how !== 'maybe' —
"Auto-tick only what is safe to trust: a NEW SALE, matched better than a guess, and not
competing with another row for the same card." The duplicate-target scan is scoped to the
sale bucket only: two file lines pointing at one card is normal in
MISMATCH (a relist, or a canceled order plus its replacement), and "flagging 203 rows trains the
eye to ignore the warning, which is worse than not having it." A pre-flight collision check at
Apply covers everything ticked, which catches two ticked MISMATCH rows aimed at one
card.
Apply either inserts a new card already marked Sold (for NOT IN APP rows) or
updates a matched card. In both cases sold_price = net_proceeds = net
and platform_sold_price = gross — the gross is reference only, never summed. Three fields
are fill-only, where an existing value always wins: received_method_id,
listing_name, shipping_collected.
A correction on an already-Sold card can emit two journey events in order —
order_canceled for the old order (when this file proves it was refunded), then
sale_corrected for the new one — because "a correction tells a two- or three-part
story; a plain sale tells one." The card row is captured before the update so the
correction records what it replaced, not just what it became.
3.12.4 · eBay Listings
Books nothing. Stamps the permanent eBay Item Number onto cards so every future
sale matches exactly. The direction is inverted versus Sales: it iterates cards and matches
each to a listing. A checkbox only cards missing an item # is on by default.
SKU and title (≥0.5) matches auto-tick; maybe (≥0.34) does not. Apply writes only
platform_sale_item_id and listing_name.
3.12.5 · eBay Purchases
The only importer that reads a real binary workbook. Fuzzy-matches each buy to a card by name (one threshold, score ≥ 0.4) — cards have no stored purchase-order id, so name is all there is. Each row's action defaults to Fill match when a card was found, else Create new; a per-row select offers all three including Skip.
The match branch fills blanks only — base_cost only when currently
falsy or 0, purchase_date and platform_purchase_order_id only when empty;
an empty patch counts the row as skipped. The new branch inserts a For-Sale card
with the raw eBay item name as both description and intake title, then fires a best-effort photo
attach.
3.12.6 · CollX Sales
Until 2026-08-17 this was a read-only reconciler, because the CollX CSV export is order-level only — no card lines — so it could never know which cards were in an order. "That limitation belonged to the CSV, not to CollX." The saved order page carries per-item card name, set, grade, price and often our own SKU, so it is now a booking tool.
gross = the item Price — what the card actually sold for.
net = Seller's Proceeds — what CollX actually paid us. Buyer-paid shipping and tax
never touch us on CollX and are not recorded. Shipping Protection is already inside net —
never subtract it again. And never take gross from the CSV: its
merchandise_value is the list price. Order 7VWGJW2CHYBK reads
$240.00 there but sold for $228.00 — and $228.00 × 0.90 is exactly the $205.20 CollX
paid.
A five-rung owner-locked matching ladder: SKU exact (BFL-EB… /
BK71-…) → cert → name+set+grade (IDF-weighted with a
hard grade gate) → picker → card id typed straight in. Tier 3
auto-ticks only when top[0].score ≥ 0.62 and either it is the only candidate or it leads
the runner-up by ≥ 0.12 — "Anything closer than that is a decision, and decisions belong to a
human." The ranker adds one bonus cardMatch does not have:
−0.12 for a card that is already SOLD, because an unsold card is far more likely to be
the one that just sold.
justBooked versus bookedOrders
Apply re-reads the cards table, so a moment later the already-booked guard saw the rows it had just written and said "Already booked" — which reads as "you did this before" when you did it two seconds ago. "The guard is asking the database a question; this screen is telling the story of one click. They need separate answers." An order booked in this session shows Just booked and is frozen on that, never re-derived.
Other guards: an order already carrying cards is locked and cannot be re-booked; one card may serve exactly one line across the whole batch; refunded or canceled orders book nothing, ever; an already-SOLD card can still be chosen but shouts first. Multi-item nets are split with the same largest-remainder routine Bulk Sale uses (§4.5), so per-card nets sum to the order's net exactly.
3.12.7 · CDP Batch
Matches every row by cert number, SKU as backup, then proposes eBay platform,
asking price, the CDP/eBay listing name, and the card attributes. Blank fields are
pre-ticked; anything that would overwrite an existing value is shown old→new and left for you to
approve. Dropdown values are never created — unmatched Card Type / Brand are surfaced as a
review change with a picker, never guessed.
Two dialects: the snake_case batch-…-export.csv, and the Title Case
inventory-export.csv which was unusable until CDP added a Cert Number
column between 2026-07-26 and 2026-08-01. A pre-August export is refused rather than
converted, because converting would hand every row an empty cert and quietly demote the whole file to
SKU-only matching. Purchase Price and Purchase Date are deliberately
not mapped — "they are CDP's numbers, not the ledger's, and money is never
proposed without being asked for."
Three change kinds with distinct styling and pre-tick behaviour: fill
pre-ticked, overwrite
manual, review no value until
you pick one. A review change is unselectable until a pick sets a value, which then enables
and ticks it in one action.
"Cert and SKU are exact keys; when both miss, the row is NOT automatically a new card. A SKU can drift … and creating from a near-miss makes a duplicate — the single worst outcome this importer can produce." Every miss is scored against the live ledger, and a candidate at ≥0.55 must be corroborated by the player surname (falling back to ≥0.75 only when CDP has no player to check against). A corroborated suggestion renders under the row and disables its checkbox; a different card link unlocks that one row. Select all selects only rows with neither a note nor a suggestion. The motivating case: a 2020 Topps PSA 10 Bo Bichette scored 0.59 against a 2020 Topps Chrome PSA 10 Brendan McKay purely on shared year/brand/grade words.
A matched-but-archived card is pushed to the unmatched list marked
matched #id but it is ARCHIVED — skipped and cannot be ticked for
creation. Photos are collected as candidates for only the missing side and
sent in batches of 8; created cards get one batched attach call after the inserts finish, plus
card_listings rows so the card "arrives knowing where it's live". Listed On is
additive only — only the literal status value listed counts, because
"removing a listing is a destructive edit and this importer only ever adds."
3.12.8 · PSA Grading Return
Matches returning certs to the cards you sent in and allocates the grading bill. The candidate pool
is only cards at location_id = 5 ("PSA Grading"). This is the only AI
path in the import area: runMatch() POSTs certs and candidates to
/api/psa-return and receives per-cert matches with a confidence tier, a reason, and
parsed card attributes.
Fee allocation: autoFee = (totalBill − Σ locked) / (certCount −
lockedCount), so a manually typed fee locks that row and the remainder re-spreads across the
rest. A live hint reads = $47.33/card · 3 set manually.
Drafts: 💾 Save draft upserts the entire review state on the order
number; Resume restores it and re-fetches the pool so any cards added since are
now assignable.
Row guards: a duplicate assignment (two certs on one card) tints the row and
disables Apply entirely; a cert conflict (the chosen card already
has a different cert) tints amber and shows the existing cert; a missing PSA grader in
the lookups blocks Apply outright. Both the create and update branches write
name_source:'grader' and a card_title_events row. After Apply it
automatically pulls PSA photos, looping psaBatch at most 25 times and stopping early on a
quota hit.
3.12.9 · What no importer can ever write
- Refunded eBay orders — excluded from the apply set and re-checked inside the apply loop.
- Canceled, $0-gross, or net ≤ $0 eBay rows.
- Refunded or canceled CollX orders; and any CollX order already carrying cards.
- Sold Price or Date Sold onto a card that is not already
SOLD. - Archived cards — Generic CSV excludes them from matching, CDP refuses to create against them.
- Two rows onto one card in the same run.
- A dropdown value that does not already exist — CDP proposes a picker instead, for Card Type, Brand and Grader alike.
- A listing removal.
3.13Help & Support
Opened exclusively by the header ? and visible to every role. Four labelled groups
under coloured underlines: Guides (User Guide, Function Reference),
Ask (Submit an Issue, Request a Feature), Contact (Contact Us), and
Account (Password).
Both guides are static HTML served from /help/ and open in a new tab with
noopener. The Guides pane also carries six quick recipes, which reference the tabs by
their current labels:
| Recipe | How |
|---|---|
| Record a sale | Ledger → open the card → Status = Sold → platform → total + net → proceeds to → Confirm. |
| Price a stack for a show | Ledger → filter to the stack → read the live filtered total in the KPI band. |
| Month-end eBay | Gear → Data Import → eBay Sales → drop the report → tick → apply. |
| See what the business owes you | Accounting → Statements → the non-card position block. |
| Turn a shoebox into inventory | Scan → upload → pairs/fronts → Scan selected → review → Bring in. |
| Totals look stale? | Hard-refresh (Cmd/Ctrl + Shift + R) — the app caches aggressively for speed. |
The three forms are one component with the kind switching the copy and the field layout. Fields:
Area of the app (eleven fixed options), How bad is it? (issue only —
blocking / annoying / cosmetic), Card ID(s) optional,
Subject (max 140 chars), and a body whose label and placeholder change per kind. Both subject
and body are required. Every submission writes a support_requests row carrying the
partner id, and the beside-button copy makes the attribution explicit: "Submitting as {name} ·
your name and role ride along automatically."
My requests is always rendered at the bottom: the caller's twenty most recent
rows, re-fetched whenever a form is submitted, each showing a status chip
(New / Seen / In progress / Done /
Declined), the type word, the truncated subject, and the area plus date. An unknown
status falls back to the raw string with the new tone.
The Account pane's Change password… closes Help and opens the change-password modal —
the password control was deliberately moved out of the header into Help (owner decision, Aug 13).
Its copy warns: "You'll stay signed in here; other devices will need the new password next time
they sign in."
Every form writes a database row. The intended notifier rides on api/support, which
does not exist in api/. Requests are visible only in-app.
4Algorithms
The nine pieces of logic that carry the most weight and are easiest to break by accident. Each is documented with its rules, its thresholds, and — where one exists — the incident that shaped it.
4.1cardMatch.js — the structured card matcher
From its own header: "Structured card matcher for eBay reconcilers. Far more precise than plain token overlap: it treats YEAR, BRAND, GRADER, GRADE, and CARD # as hard filters (a PSA 9 can't match an SGC 9.5; a Topps can't match a Panini), and weighs rare tokens (player names, set codes) far above common ones (colors) via IDF."
Consumers: EbaySales, EbayListings, EbayPurchases and
CdpImport via buildMatcher; CollXSales via the exported
parseCard, wrapped in its own ranker. cardMatch.js is deliberately
left untouched by CollX — the eBay importers depend on it.
parseCard(s) — structured extraction
| Field | Rule |
|---|---|
yr | first \b(19|20)\d\d\b |
gr | first whole-word hit among PSA, SGC, BGS, BVG, CGC, CSG |
grade | the number following the detected grader, \d+(\.\d)? |
br | first of UPPER DECK, PANINI, TOPPS, BOWMAN, DONRUSS, LEAF, SCORE, FLEER, SAGE, ONYX, PACIFIC, SKYBOX, BBM, POKEMON, FUTERA |
num | # + alphanumerics/hyphens, normalised by stripping every non-alphanumeric, so HS-4 === HS4 |
toks | token set, length > 1, after removing serial/print runs |
Card-number normalisation — "eBay and the app often punctuate insert codes
differently — this used to exclude the correct card as a 'conflict'."
Serial/print-run stripping (\b\d{1,4}\s*/\s*\d{1,4}\b removed
before tokenising) — "they are not matching identity, and their extra tokens were
penalizing the correct card (a plain 'Red Shock' 25/249) below a wrong-color card (a 'Black &
Red Shock')."
Hard filters — applied only when both sides carry the attribute
- year mismatch → skip the candidate outright
- grader mismatch → skip
- grade mismatch → skip
Brand is explicitly NOT a hard filter — "Panini Donruss vs Donruss etc. conflate" — it is handled as a weighted token instead. Year, grader and grade stay hard.
Scoring
idf(t) = log((N + 1) / (df[t] + 1)) + 1
base = Σ idf(t ∈ both) / Σ idf(t ∈ either) // IDF-weighted Jaccard, 0–1
| Condition | Δ | Rationale |
|---|---|---|
| both have a card #, and they are equal | +0.20 | exact number |
| both have a card #, one contains the other | +0.10 | prefix/variant (WS-1SSP ⊃ 1SSP) still counts for |
| both have a card #, genuinely different | −0.20 | counts against — but never a hard exclude, because the two systems punctuate insert codes differently and hard-excluding on a format mismatch drops the correct card |
| query is graded and the card's grade+grader match exactly | +0.25 | a "PSA 10" must strongly prefer the PSA 10 card over a raw one that merely shares the player, set and number |
| query is graded and the candidate is raw | −0.15 | demote |
score, display confidence
return { card: best, score: bs, confidence: Math.max(0, Math.min(1, bs)) }
score is the raw ranking value and can exceed 1 — a token ratio
(0–1) plus bonuses, so a perfect match lands around 1.45. Callers threshold on it,
so it must not be rescaled. confidence is that value clamped to 0–1
for display only: showing the raw score as a percentage produced nonsense like
"145%", which made the number useless for telling a solid match from a guess.
Threshold table across all callers
| Caller | Auto / strong | Weak tier | Notes |
|---|---|---|---|
EbaySales | score ≥ 0.5 → title | ≥ 0.34 → maybe, never auto-ticked | after Item # and base SKU |
EbayListings | score ≥ 0.5 → auto-ticked | ≥ 0.34, not auto-ticked | after base SKU; matches cards → listings |
EbayPurchases | score ≥ 0.4 | — | else the row defaults to "create new" |
CdpImport duplicate guard | ≥ 0.55 + surname corroboration, or ≥ 0.75 when CDP has no player | — | blocks creation; never writes |
CollXSales ranker | ≥ 0.62 and ≥ 0.12 clear of #2 | list floor > 0.12 | plus −0.12 for already-SOLD cards |
DataImport Generic CSV | does not use cardMatch — a local Jaccard scorer, ≥0.8 STRONG / ≥0.45 MAYBE | ||
| Admin raw photo matching | also a local Jaccard scorer — EXACT / ≥0.80 STRONG / ≥0.45 MAYBE | ||
4.2fileDetect.js — Tier-1 auto-detect
Several importers locate columns with a substring test (PsaReturn accepts any header
cell containing "cert"), which is fine once a human has chosen that screen but mis-fires as
an auto-router — a report with columns certs and descriptions would sail
straight through. So gates here match whole cells. The stated consequence:
a file this module routes is always one the importer will accept; a file the importer would
accept is not always one this module routes.
The signature model
Every entry in SIGNATURES is transcribed from the importer that consumes that file:
| Key | Meaning |
|---|---|
gate | the header test the importer runs to accept the file at all |
gateRow | 'first' = the importer inspects row 0 only and would throw on preamble (leading blank lines are skipped — a stray empty first line is not preamble). 'any' = the importer searches for its header row. Mirroring this is "what stops us promising a route that fails." |
require | what the importer throws without |
reads | every column it actually consumes — the denominator of coverage |
disqualify | columns the file must not have |
fileHint | a filename expectation, warned about when unmet |
Order matters — first match wins. The signatures genuinely overlap (an eBay orders report also carries an item-number column; a CDP export also carries a cert column), so they are listed narrowest-first.
| Kind | Label | Routes to | Books? | gateRow | Gate cells |
|---|---|---|---|---|---|
ebay_orders | eBay orders report | ebaysales | yes | any | custom label + order number |
ebay_listings | eBay active listings | ebaylistings | no | any | custom label + item number |
ebay_purchases | eBay purchases report | ebaypurchases | no | any | item ?name + order ?number |
cdp | CDP batch export | cdp | no | first | certification_number|cert number + title |
collx_csv | CollX orders CSV | collxsales | no | first | order_number|order # — checklist only; its prices are list prices |
psa_return | PSA order CSV | psareturn | no | first | a cert column + description|title |
collx_order_page | CollX saved order page | collxsales | yes | page rules, below — the only source of real gross | |
unknown | Unrecognised file | — | no | nothing matched a known importer signature | |
CUSTOM_LABEL = /^custom label(\s*\(.*\))?$/ exists because eBay is not consistent with
itself: the orders report says "Custom label", the active-listings report says "Custom label (SKU)".
disqualify, and why it exists
Only psa_return carries one:
sku, player, team, cabinet, shelf, front image, start price, quantity. The recorded
incident: on 2026-08-15 CDP added a "Cert Number" column to its inventory export, which gave
that file a cert cell and a description cell — a perfect PSA-order gate match, at high confidence,
on a 40-column inventory file. A gate says what a file has; disqualify
says what it must not have. A PSA order CSV is six columns and never carries
inventory or listing columns.
Probe mechanics
| Constant | Value | Why |
|---|---|---|
SNIFF_BYTES | 64 KiB | the front of a text file |
PAGE_SNIFF_BYTES | 4 MiB | far past the main document part of a Chrome .mhtml bundle (images come after) |
PROBE_LINES | 40 | an eBay report front-loads preamble before its header |
- Workbook names go through
XLSX.read(buf, {sheetRows: 40}). Only the first sheet is examined, same as every importer; a multi-sheet workbook is flagged innote. - Page names are decoded with
extractHtml()— the same functioncollxOrderParse.jsuses, so detection and parsing can never disagree about what the page says. - Everything else, including extensions the app does not handle, is read as text: "A mislabelled export is common enough to be worth the peek, and a binary simply yields cells that match nothing."
splitCells()splits naively on[,\t|;], strips BOM and quotes, trims and lower-cases. The naivety is deliberate: comparisons are whole-cell against short literals and none of the target column names contain a comma, so a quoted comma-bearing cell "splits into harmless junk rather than corrupting the cells we care about."
Saved pages (detectPage)
| Condition | Verdict | Confidence |
|---|---|---|
| page shows both "Order Number" and "Seller's Proceeds" | collx_order_page | high, coverage 1 |
| mentions CollX + an order number, but no proceeds line | collx_order_page with the warning "likely a partial save, or the order list rather than one order" | low, coverage 0.5 |
| otherwise | unknown — saved page carried no CollX order labels | none |
Why both conditions are required: "'Mentions CollX' alone was enough to make the app's own User Guide impersonate an order page, because the guide documents the CollX importer."
The confidence model
i = gateIndex(sig, rows) // -1 → this signature yields no verdict
if (disqualify matches that row) → null
present = count of sig.reads found in that header row
coverage = present / sig.reads.length // 2dp
missingRequired = sig.require not found
confidence = missingRequired.length ? 'low'
: coverage >= 0.5 ? 'high'
: 'medium'
"A matched gate missing a required column is low, because we would be routing
a file the importer is about to refuse." reason is always the literal evidence —
the source file's header test, the coverage fraction, and the matched header line trimmed to 160
characters.
Public API: detectFile(file) → one verdict; detectFiles(files) →
verdicts in order; groupByImporter(detections) → {groups, unknown, single},
where single is populated only when there is exactly one group and zero
unknowns. SIGNATURES[].importer is DataImport's own mode
string, deliberately, so routing is setMode(d.importer) with no lookup table between
them going stale.
4.3collxOrderParse.js — the saved-order-page parser
"CollX is a React app whose class names and DOM shape can change with any deploy, but the LABELS a human reads ('Order Number', "Seller's Proceeds") are the contract." The parser therefore strips comments, script and style, turns every tag into a newline, decodes a fixed entity table, and works on the resulting plain text.
- MHTML support.
extractHtml()detectsmultipart/related, finds the boundary, takes the firsttext/htmlpart, and decodes quoted-printable (soft line breaks first, then=XXescapes collected as bytes so multi-byte UTF-8 — Julio Urías, Sam's Card Club — survives) or base64. - Money in integer cents everywhere — "a penny-exact tie-out is the whole point, and floats do not tie out."
afterLabel()takes the LAST occurrence by default, because several labels appear once in a top summary and again in the authoritative totals block at the bottom — "and the bottom one is the one that ties out."- Item extraction. Every item line ends with the marker
(CollX Sale). The item region is everything between the last Items Detail heading (the first is a nav link) and the last marker, split on the marker. A chunk isname · set · [grade] · xQTY · sku-or-collx-id · price: the quantity linex Nanchors it, the price is the last money-looking line after it, and the SKU is the line right after the quantity if it is not money. isOurSku(s) = /^(BFL-EB|BK71-)/i— anything else in that slot (notably CollX's bare 5-digit catalogue id) is CollX-side and "cannot be joined to our data … CollX has no inventory export."searchText = [set, name, grade].join(' ')— the best single string for the matcher, becauseparseCardreads year+set from the set line, card number+player from the name line, and grade from its own.
Warnings it emits (each surfaced per order in the UI): no item lines; missing
gross; missing net; Item prices sum to $X but the order Price is $Y.;
Gross $g + fee $f + protection $p = $e, but the page states net $n.;
"{name}" sold with quantity N — one CollX line, N physical cards. Book each separately.;
and an order status that is refunded or canceled. Hard failures:
File contained no readable text. and No CollX order number found — is this a saved
CollX order page?
4.4Normalisation primitives
stripSetPrefix — CDP's set string
CDP writes Set as <year> <brand> <set> — 2024 Panini
Absolute — because it has no separate year or brand field. The app does. Dropping CDP's string in
raw triples the year and brand, and a later "Generate names" then produces
2024 Panini 2024 Panini Absolute #133 Keon Coleman.
Algorithm: strip a leading 19xx/20xx , then strip a leading brand
from ['Upper Deck','Topps','Panini','Bowman','Donruss','Fleer','Leaf','Score','Sage']
(longest first) only when something follows — so 2023 Topps stays
Topps, because that is the set. Applied to both dialects.
| Value used (831 matched cards, 2026-08-21) | Already agree | Disagree |
|---|---|---|
| raw CDP value | 52 | 478 |
| year + brand stripped | 279 | 251 |
"i.e. 227 of the 'disagreements' were nothing but CDP's formatting."
The subset parser works subtractively — it removes everything it already knows
from the title and keeps the leftover — so it must subtract the full string. Passing the
stripped Absolute left Panini behind, "which was then proposed as the
Subset on ~800 cards."
certKey — the leading-zero fix
cleanCert(v) trims and strips a trailing .0 (a SheetJS float artifact).
certKey(v) is cleanCert with leading zeros stripped,
because SheetJS parses a numeric-looking cell as a number, so 0278119 arrives as
278119 and no longer equals the stored value. SGC certs carry leading zeros far more
often than PSA ones, "which is why this surfaced on a pile of SGC Drake Mayes: 5 rows in the
2026-08-21 export were cards the ledger already had, shown as 'not in the app' purely because of a
lost zero." The stored value is never altered — only the comparison key.
skuBase — two different suffix rules
| Where | Rule | Why |
|---|---|---|
| eBay importers | x.trim().replace(/-[a-z0-9]{4,6}$/i, '') | strips eBay's per-listing suffix |
| CDP importer | upper-case, strip a trailing -[A-Z0-9]{2,6} | CDP's per-listing suffix is not always 4–6 chars: real 2026 data has -230, -228, -452 alongside -d2z7v. The old {4,6} rule left three-character suffixes attached, and "52 of 56 'unmatched' rows on 2026-08-21 were actually cards the ledger already had." |
| Admin CDP photo loader | /^(BFL-[A-Z]*\d+)/ | a positive base extraction rather than a suffix strip |
subsetFromTitle — subtractive parsing
Used only when a CDP structured column is blank; every result is an ordinary
reviewable fill marked in the UI with ⟵name. It strips, in order: the full set string;
#num and the bare number; the player; the year; the brand list (because once the
year+brand prefix is stripped for storage, the brand would otherwise survive here and masquerade as a
subset); bare print runs (/399, #/150 — "a print run, not a subset"); the
player's name word by word as well as whole (CDP's spelling and the title's can
differ — "Niko Kavades" vs "Niko Kavadas" — and a whole-name strip then removes nothing, or
worse matches a fragment and leaves "ah Strong" behind); any surviving #code ("A #code is
never a subset"); a trailing PSA <grade> phrase; and stranded punctuation. Results
shorter than 2, longer than 60, or containing no letters are rejected.
4.5Largest-remainder cent allocation
Used by Bulk Sale (split an order across cards by asking price) and by CollX Sales (split an order's net across its item lines by item price) — the same routine, "verified penny-exact".
function allocate(total, weights)
1. convert total to integer CENTS
2. if every weight is zero:
split evenly and hand the leftover cents to the LOWEST indices
3. otherwise:
rawᵢ = totalCents × wᵢ / Σw
shareᵢ = floor(rawᵢ)
distribute the remaining cents to the LARGEST fractional parts first,
ties broken by INDEX
4. divide back by 100
There is no rounding leak. The index tie-break is deliberate: the gross
allocation and the fee allocation are run over the same weight vector, so an index-stable
tie-break keeps them aligned card-for-card. Bulk Sale then applies
netᵢ = clamp(round(grossᵢ − feeᵢ, 2), 0, grossᵢ), which enforces the same
net-integrity invariant the editor enforces — a card's net can never be negative and can never
exceed its own gross.
4.6useWaterfall — the partner accounting engine
A single useMemo over six inputs (cards, entries, distributions, profitSplits,
profitGifts, yearKey). PARTNERS = ['Kevin', 'Brian', 'BKCards71'] — hard-coded,
exactly three; everything accumulates into three-key maps.
The three rules, verbatim from the header comment
- COST RECOVERY → PAYER. The payer is credited
min(sold_price, total_cost). They get their money back, capped at what they put in. - PROFIT → OWNER. Gain above cost goes to
owned_by. Owner = BKCards71 → the company profit pool (the slider distributes it). Owner = Kevin/Brian → that partner keeps it outright, minus any amount they gifted to BKCards71. - LOSS → PAYER. The shortfall is charged to
paid_by, always, even when someone else owns the card. This is the payer's real out-of-pocket loss and it flows to their personal return.
The asymmetry is the whole point.
The two frames, never mixed
| Frame | Basis | Used by |
|---|---|---|
| Business P&L | SOLD-YEAR — revenue recognised at sale, IRS basis | P&L block, tax schedule, partner tax, profit gifts, profit distribution |
| Capital waterfall | PURCHASE-YEAR COHORT — '2025 & before', 2026, 2027… | capital advanced / recovered / still out, and the two cohort KPI tiles |
cohortKey(c): purchase_year ≥ 2026 → that year as a string, otherwise the
literal '2025 & before'. Everything predating the app is one lump, and selecting
2025 in the toggle shows that lump.
Step by step
| Step | What it does |
|---|---|
| 0 · gift index | giftByCard[card_id] = amount. A flat map — one gift per card wins; a second row for the same card overwrites the first. |
| 1 · P&L | Over sold, in-scope, non-archived cards: accumulate grossSales, cogs, shipMarginTotal, and push a taxSchedule row whose gainLoss = sold_price + shipMargin − total_cost. Sorted ascending by gainLoss. |
| 2 · per-card routing | Second pass over the same population with net = sold_price + shipMargin − total_cost. Rule 1 recovery uses the raw sold price, not the margin-adjusted net — "margin is profit, not recovery". Rule 2 splits by owner, capping each gift at the card's own net so a gift can never exceed the profit. Rule 3 charges the absolute shortfall to the payer. The payer×owner matrix accumulates cost, recovered, count, and profit-or-loss.If the owner is null or unrecognised, the profit lands in no accumulator at all. |
| 3 · capital cohort | A separate loop over the cohort, sold and unsold alike. Cross-partner grading is booked on both sides (gradingForOthers[grader] += fee, gradingByOthers[payer] += fee) but only when the two differ — a self-graded card contributes to neither, because the fee is already inside total_cost. Recovery here credits the full, uncapped sale price to whoever received the money.adjCapitalAdvanced = costAdvanced + gradingForOthers − gradingByOthersstillOut = adjCapitalAdvanced − recoveredByReceiver — which can go negative; that is over-recovery, not an error. |
| 4 · non-card ledger | Skips personal and non-counts-in-books rows. Reimbursement is tested independently of the classification chain. Classification here tests Write-Off before Operating Expense — the reverse of the summary band's order (D-31). |
| 5 · distributions | distTaken takes every kind; profitTaken only profit_draw; capitalTaken only capital_return. |
| 6 · P&L totals | grossProfit = grossSales − cogs + shipMarginTotalnetOrdinaryIncome = grossProfit − opex − writeoffCapEx and capital contributions are deliberately excluded. |
| 7 · company pool split | With a year selected, only that year's split rows apply. With 'all', every split row is applied in iteration order, so the last row per partner wins arbitrarily — the sliders are hidden in All mode, which papers over it, but the displayed targets are not meaningful (D-42). |
| 8 · total owed | totalOwed[p] = nonCardOwed[p] + stillOut[p] − distTaken[p]; sumOwed = Σ totalOwed, which is explicitly not zero — it equals realised gain/loss. |
| 9 · partner tax | partnerNet[p] = profitOwnKept[p] − lossCarried[p]. Correctly excludes gifted profit (it left the partner) and any share of the company pool (that arrives as a distribution, not as card income). |
The shipping-margin term
shipMargin(c):
if (shipping_collected == null) return 0
col = num(shipping_collected)
ext = label_cost == null ? col : num(label_cost) // a null label cost is a WASH
return col − ext
The comment is emphatic: "This term is already inside the DB's generated net_return,
so every profit figure here must carry it too or the screens drift from the DB."
Returned values nothing renders
pl.shipMargin (Statements recomputes it locally), rules.bkOwnedProfit,
dist.capitalTaken, and profit.profitTarget /
profit.profitStillOwed (the UI recomputes both from the live slider draft so the numbers
move before saving).
4.7Canonical name builders
There are five implementations of "build a card name from attributes" in the
codebase, in three formats. All of them uppercase, drop empty tokens, and
collapse consecutive duplicate tokens (so brand BOWMAN + set
BOWMAN CHROME yields one BOWMAN CHROME).
| Where | Format |
|---|---|
CardEditor.buildCanonicalTitleFromBulkEdit.buildCanonicalName | year · brand · set · #number · player · subset · parallel · /print_run · grader · grade — the full PSA layout. The card number goes before the player and the variety after; the code notes this was confirmed against real PSA slabs. The grader and grade are appended only when the grader label is not NONE. |
identify.jsScan.jsx buildName | {YEAR} {BRAND} {SET} {SUBSET} #{CARD#} {PLAYER} {PARALLEL} — seven tokens |
psa.js | {YEAR} {BRAND} #{CARD#} {SUBJECT} {VARIETY} — five tokens, no set and no subset |
The editor's and BulkEdit's are two independent implementations of one format that agree today
and nothing enforces that they keep agreeing. psa.js's five-token form is a third
format outright. D-06.
4.8The platform fee engine
Rates live in sold_platforms (fee_pct, per_order_fee) and
platform_fee_tiers, editable live from Admin → Settings with no deploy. Nothing in the
sale path hardcodes a rate. The only hardcoded rates in the app are the eBay What-If scratchpad's
defaults and the eBay Sales importer's fallback for when the platform list has not loaded yet.
computedNetFor(platformId, total):
1. TIERED? tierFor() picks the bracket with the largest min_price <= total
(rows arrive ascending; the first row is the fallback)
net = round(total × (1 − fee_pct/100) − flat_fee, 2)
→ tiers TAKE PRECEDENCE over the platform's flat rate
2. FLAT? net = round(total × (1 − fee_pct/100) − per_order_fee, 2)
3. NEITHER fee_pct NULL and no per-order fee → return null (no auto-fill)
netLocked(platformId) is true when the platform has tiers, a positive
fee_pct, or a positive per_order_fee. When locked, the Net Sale input is
disabled with the tooltip "Computed from the platform fee — not editable"; unlocked it reads
"Editable — defaults to Total". The field label grows a live suffix showing the maths, e.g.
(−12.35% − $0.40) or (−8% tier). The tiered ladder is how PSA's eBay
consignment pricing is modelled. Everything the engine produces is explicitly an
estimate, "trued up by the actual payout (importer MISMATCH review or hand entry)."
Bulk Sale applies the identical rules to the order total, because one order incurs
one fee (§3.10). eBay Sales uses the same rates but its own
formula, because the eBay fee is charged on the whole payment:
fee = fee_pct% × (sold + ship + tax) + per_order_fee.
4.9Recurrence expansion
A recurring cost is stored as one row, never materialised into per-period rows:
an amount (the per-occurrence cost), a frequency whose months_interval is
the step, a start_date, and an optional end_date.
for k = 0 … 1199:
total = start.month + k × months_interval
y = start.year + floor(total / 12)
m = ((total % 12) + 12) % 12
if end_date and (y,m) is past it → stop
if (y,m) is the current month or later → stop ← in-progress month excluded
if yearKey is 'all' or y matches → count++
- The in-progress month is not counted until it ends. A monthly bill started in January and viewed in mid-August counts 7, not 8.
- Stopping a rule means setting an End Date. Occurrences after that month stop counting immediately, in every year scope, retroactively consistent.
- No
Dateobject is constructed anywhere in the walk — see the UTC-drift guardrail in §3.5. - The 1,200-iteration cap silently truncates a monthly rule running longer than 100 years.
occurrencesInScope (Finances.jsx), ledgerOccForChart (Finances.jsx,
bottom of file) and ledgerOccYear (useWaterfall.js) all implement this walk. They
agree today; if one is edited, the ledger totals, the OpEx donut and the P&L will silently
diverge. Consolidating them is the single highest-value refactor in the money code.
D-29.
5API Surface
Six Vercel serverless routes at the repo root in /api, alongside the Vite
app. Three of them share an identical hardened blueprint; the other three are looser, and two are
unauthenticated.
5.0 · The shared manager guard
api/photos.js, api/psa.js and api/identify.js each define
requireManager(req, db) identically:
- Strip
Bearer(case-insensitive) from theAuthorizationheader. Missing → 401Missing bearer token. db.auth.getUser(token). Invalid → 401Invalid or expired session.- Select
id, name, role, account_statusfrompartnersonauth_user_id. A DB error → 500. - No partner row, or role not in
['company','partner','admin']→ 403Manager role required. account_statusofsuspendedorremoved→ 403Account is not active.
api/users.js uses a variant (getCaller) that checks role but
not account status and returns a flat 403 for every failure mode.
api/psa-return.js and api/analyst.js have no authentication at
all.
5.1 · api/photos.js — the photo importer
maxDuration: 60. Env: SUPABASE_URL,
SUPABASE_SERVICE_ROLE_KEY, PSA_TOKEN. Bucket card-photos.
MAX_BATCH = 8. GET is an unauthenticated probe returning only whether the
PSA token is present, never its value.
| Action | Does | Cap |
|---|---|---|
status | Four parallel head-only counts scoped to non-archived cards — with fronts, missing fronts, missing backs, PSA certs eligible — plus a status tally over psa_cert_status. | — |
psaBatch | Fetches PSA images for eligible certs. Eligibility: a non-empty cert, photo_url IS NULL, not archived, not already in psa_cert_status, and a grader label that is blank or contains "psa" — "Only spend PSA calls on PSA-graded cards (or unlabeled graders)." | limit clamped to [1, 8], default 5 |
attach | The primitive behind the CDP importer, the raw-match approval flow, eBay Purchases and Scan's bring-in. Per item, front and back are guarded entirely independently — a front skip never blocks a missing back from attaching. | items sliced to 8; the client loops |
PSA batch outcomes, and the budget discipline
| Condition | Recorded in psa_cert_status? | Result |
|---|---|---|
| fetch threw | no — retried next run | error |
| HTTP 429 | no | quota, and the loop breaks immediately |
| HTTP ≠ 200 | yes, error | error: PSA HTTP {status} |
| 200, no front image | yes, no_front | no_front |
| 200 with a front | yes, done | done, plus a best-effort back |
| front upload threw | no — retried | error |
- The
psa_cert_statusledger — every definitive verdict is persisted so the same cert is never re-asked; transient failures are deliberately left unrecorded so they retry. - The permanent
cards.psa_no_imageflag — a cert PSA has no image for is marked once and never costs another call. - Immediate
breakon any 429.
attachImage — the shared primitive
1. fetch(sourceUrl) non-ok → "image download HTTP {status}"
2. content type defaults to image/jpeg; extension is png only when the type contains "png"
3. path = cards/{id}.{ext} for a front
cards/{id}-back.{ext} for a back
4. upload with upsert: true
5. public URL + "?v={Date.now()}" cache-buster
6. update cards.photo_url | photo_back_url, plus updated_at
"The two sides use different storage paths and different columns, so a back operation can never
touch a front image, and vice versa." An existing front is skipped as
already has a photo unless overwrite is exactly true.
5.2 · api/psa.js — PSA cert lookup
Cost note in source: "PSA free tier ≈ 100 calls/day; each cert here = up to 2 calls (attrs + images)."
| Action | Auth | Behaviour | Cap |
|---|---|---|---|
GET probe | none | {ok, name, psaTokenPresent, tokenLen} | — |
GET ?cert=12345678 | none | A live, quota-consuming, unauthenticated single-cert lookup returning the parsed result and the raw PSA payload. Described in code as a "live test lookup" — it is the one meaningful auth gap in this file (D-08). | — |
lookup | manager + active | Per cert: GetByCertNumber → a defensive case-insensitive depth-first key search (max depth 6) over the payload for year, brand, player, card number, variety, category and grade → a suggested name → GetImagesByCertNumber. Writes nothing. A failed image call degrades silently to {front:null, back:null}; a 429 breaks the loop. | 25 certs |
backfill | manager + active | For existing cards, preserving the client's ordering ("newest-cert-first"). A 404 sets cards.psa_no_image = true permanently; a 200 with no front does the same with the note "likely pre-Oct-2021"; a 429 breaks. | 50 ids |
5.3 · api/identify.js — Ximilar identification
Endpoint collectibles/v2/sport_id. MAX_BATCH = 10 ("Ximilar accepts up to
10 records per call"). The GET probe is a diagnostic that reports the token's presence,
its length, and its first and last three characters, plus the full Ximilar account
body — unauthenticated (D-09).
{action:'scan', candidateIds}
- Load the rows; filter out anything whose side is not
front(reasonnot a front) and anything with no image. Backs never reach Ximilar. - Mark all survivors
status:'scanning'in one update — this is what drives the pulsing sky badge in the UI. - Process in chunks of 10 with
price_stats: true. - Map each record back into
proposed,suggested_name,confidence(the match distance — lower is better),raw, andscan_error. - A non-ok response or a thrown fetch marks the whole chunk
errorwith a message assembled from whichever ofstatus/error/detailXimilar returned.
It never touches cards. Ids capped at 50.
mapRecord() — the Ximilar → app mapping
pickObject takes the largest-area detected object, else the first.
Identification lives at _objects[0]._identification.best_match, and
best_match === null means "detected a card but couldn't identify which one"
→ identified: false, which the UI surfaces as No match — enter manually.
| App field | Source |
|---|---|
| player / full_name / year / brand / set_name / subset / card_number / team / card_type | best_match.* |
parallel | always null — "Ximilar folds parallel into set/sub_set; user refines in review" |
grade / grader | always null — the numeric grade is not in sport_id output; slab grade-read is a follow-up |
is_rookie | /rookie/i against card_type |
is_auto | the Autograph tag, false when it matches /not\s*signed/i |
is_relic | always false |
alternatives | the first 6 |
| market value | prefers the price bucket whose stats_type matches graded status, falling back to overall, then the first; takes the median, falling back to the mean. Flagged in source as an ESTIMATE only. |
{action:'identify', cardIds} is a legacy calibration harness that proposes from
existing cards' photos and writes nothing — it exists to calibrate the mapping
against real output.
5.4 · api/psa-return.js — the grading-return LLM matcher
Auth: none. Model claude-sonnet-4-6, max_tokens: 12000.
Request { certs: [{cert, description, grade}], candidates: [{id, name, base_cost}] };
payloads are compacted to just the needed string fields before being sent
(base_cost is accepted but not forwarded).
The system prompt defines a two-task contract:
- Task 1 — MATCH. Four explicit rules: use each candidate id at most once (one physical card per cert); when several candidates are the same card, assign them one-to-one and set
is_cluster; if nothing is a confident match,app_idis null and confidence is"none"; confidence ∈high | medium | low | none. Crucially: "The candidates are ungraded, so IGNORE the grade when judging identity." - Task 2 — PARSE. Extract year, brand, set, subset, card number, player, parallel, rookie and auto from the PSA description with precise normalisation:
brandis the manufacturer only;setis the product line without year or brand;card_numberis exactly as printed with no leading#; unknown strings are"".
The route never writes to the database — "Matching only … the app applies
confirmed matches separately." Output is constrained to one exact JSON shape ("No prose, no
markdown, no code fences. Every input cert must appear exactly once"), and
extractJson() is tolerant: it strips fences and leading prose, then slices from the
first { to the last }. Unparseable output is a 502 with
the first 2,000 characters as detail, not a silent empty result.
5.5 · api/analyst.js — Insights CFO commentary
Auth: none. Model claude-sonnet-4-6, max_tokens: 1000.
Request {scope, metrics}; the user message is
Section: {scope}\n\nFigures (JSON):\n{pretty metrics}\n\nWrite the CFO commentary now.
Verbatim from the system prompt: "You are the CFO of a sports-card flipping business, briefing the owner straight. Write 3 to 5 tight sentences. Lead with the position in one line… Cite the figures you were given and never invent any. No headers, no bullet points, no markdown, no em dashes. Plain direct prose only. This view is cards only. It excludes operating expenses, capital, and partner payouts, which live in the Financial tab, so do not comment on net income after expenses." The last clause is a scope fence that stops the model drawing conclusions the card-only dataset cannot support.
Both LLM routes parse the body defensively
(typeof req.body === 'string' ? JSON.parse(…) : req.body || {}) because Vercel may hand
it over parsed or raw depending on runtime. An empty result is a 502, never a blank
success.
5.6 · api/users.js — user administration
The most privileged route. Request shape {action, payload}. Every mutating action
writes a user_audit row carrying actor id + email, action, target id + email and a JSON
detail. syncStatus is the one action that writes no audit row.
| Action | Behaviour | Guardrails |
|---|---|---|
invite | auth.admin.inviteUserByEmail, then insert the partner row with display_name (NOT NULL), account_status:'invited' and the invite stamps. | First name and email required · role must be one of the five · duplicate email refused · finance forced true for manager roles · rollback: if the partners insert fails, the just-created auth user is deleted "so we don't strand an orphan". |
resend | Re-invites and re-stamps invite_sent_at. | No email on file. |
reset | auth.resetPasswordForEmail — this is what produces the recovery link the app's hash-token screen catches. | — |
suspend / reactivate | Sets the auth ban to 876000h (documented as "100 years = effectively permanent until reactivated") or 'none', plus the status column. | Self-protection: You cannot suspend your own account. |
remove | Deletes the auth user, nulls auth_user_id, sets status removed. Success: Login removed. Accounting history preserved. | Self-protection · last-manager guard: if the target holds a manager role, count active managers; ≤ 1 → Cannot remove the last active manager. — "Never strand the system without a manager." |
delete | Hard-deletes the auth user and the partner row. | Three layers: self-protection; role guard — Only User and Viewer accounts can be deleted. Company, Partner and Admin identities are permanent.; referential guard — three parallel head counts on cards.owned_by / paid_by / grading_paid_by, any non-zero total refusing with This user is referenced by {n} card(s) and cannot be deleted. Remove their login instead. |
syncStatus | Walks every partner with status invited and a non-null auth_user_id, reads confirmed_at with the service key, and promotes to accepted. "Only the service key can read that, so the Refresh button asks us." | — |
5.7 · The surface at a glance
| Route | Actions | Auth | Writes | External | Batch cap |
|---|---|---|---|---|---|
photos | status · psaBatch · attach | manager + active | cards, psa_cert_status, storage | PSA images | 8 |
psa | lookup · backfill | manager + active (POST); GET ?cert= is open | backfill only | PSA cert + images | 25 / 50 |
identify | scan · identify | manager + active | scan_candidates only | Ximilar | 50 (chunks of 10) |
psa-return | (single) | none | none | Anthropic | — |
analyst | (single) | none | none | Anthropic | — |
users | invite · resend · reset · suspend · reactivate · remove · delete · syncStatus | manager (status unchecked) | partners, auth.users, user_audit | Supabase Admin | — |
6Invariants and Guardrails
Consolidated. Each row is a rule the system actually enforces, with the place it is enforced. Rules marked advisory are surfaced to the user but not blocked.
| # | Rule | Enforced in |
|---|---|---|
| Money and derived values | ||
| G1 | Derived values are computed in the database and are structurally uneditable — rendered as spans, no input exists. | Postgres generated columns; Card Editor CALC_FIELDS; excluded from Bulk Edit |
| G2 | Net Sale can never be negative and can never exceed gross. Tolerance +0.005. | cards_net_integrity DB constraint, mirrored in English in Mark Sold, ordinary Save, Card Journey hand entry, and as a clamp in Bulk Sale and CollX allocation |
| G3 | sold_price is forced equal to net_proceeds on every save — "one number, two columns, never out of step". | saveDetail, Mark Sold, Bulk Sale, eBay Sales, CollX Sales |
| G4 | The shipping margin is already inside net_return; the Shipping Ledger is a breakdown, never an additional line. | PRD §2.3.1 Option C; useWaterfall.shipMargin; the block's own parenthetical |
| G5 | Recovery is capped at cost per card, so Cost Out − Recovered = Loss. | useWaterfall Rule 1; asserted in the Out-of-Pocket footnote |
| G6 | A profit gift can never exceed the card's own net profit. | Editor gift modal validation; Math.min(gift, net) in the waterfall |
| G7 | net_return is suppressed everywhere for non-sold cards — render, sort, numeric filter, and CSV. | Ledger column definition and csvCellText |
| G8 | Projected Profit (Sale) never shows a number without an asking price. | Ledger column render → italic set price |
| G9 | Not-For-Sale is excluded from for-sale inventory and gets its own KPI tile with an owner split. | inInventory; the Ledger KPI band |
| G10 | Archived cards are excluded from every KPI, every integrity check, Collection entirely, and Bulk Edit's population. | liveCards; Integrity's live filter; Collection's held; BulkEdit's query |
| State changes | ||
| G11 | Never act on the browser's cached row for a state change — re-read first, and abandon the action if it changed elsewhere. (Incident #1692.) | CardEditor.changeStatus |
| G12 | Always rebuild the form from what was just written. (Incident #809 — a stale form resurrected a reversed sale 39 seconds later.) | Every write path calls reloadCard() then rebuilds editForm |
| G13 | Snapshot before you wipe. The sale snapshot lands in both the audit row and the journey event before SALE_CLEAR runs. | saleSnapshot() |
| G14 | Leaving SOLD is never a plain confirm — it always opens the four-story un-sell chooser. Writing off an already-sold card is refused outright. | CardEditor |
| G15 | A caller must fetch the full row before opening the editor; a partial projection plus a full-schema patch is data loss. | BulkEdit.openCard, the Integrity drill-downs, Scan's re-select — all use CARD_SELECT |
| G16 | Hard delete requires role === 'admin' and an already-archived card. There is no path to permanent deletion that skips Archive. | Render condition + a re-test inside the handler |
| G17 | Restore is the one state change with no confirmation — it is non-destructive and reversible. | CardEditor |
| G18 | Damaged is a condition, not an exit: the card stays in inventory, stays sellable, and no money moves. | Write-off modal copy; the Accounting watch line |
| Bulk operations | ||
| G19 | A bulk-edit field is only written if its checkbox is ticked; controls are disabled until then. | BulkEditModal |
| G20 | Touching a cost column on a selection that contains — or might contain — a SOLD card requires an explicit acknowledgement before Apply enables. | Layer 1 |
| G21 | A ticked field with nothing typed will erase; that requires its own acknowledgement. | Layer 2 |
| G22 | Honest counting — unseen rows are counted as unknown, unknowns are enough to demand the acknowledgement, and the shortfall is disclosed in the confirm text. | BulkEditModal |
| G23 | Bulk name generation skips every name_locked card, and the confirm names the skipped count. | BulkEdit.generateNames |
| G24 | Every card in a bulk sale must have an asking price; until then the whole order form is dimmed and non-interactive. | BulkSale |
| G25 | Allocated parts sum exactly to the order total; the index tie-break keeps the gross and fee allocations aligned card-for-card. | allocate() in BulkSale and CollX |
| Importers | ||
| G26 | Nothing writes until an explicit Apply, and nothing is ever pre-selected except CDP's fill changes (which are, by definition, filling a blank). | All seven importers |
| G27 | Sold Price / Date Sold only write to cards already marked Sold; the refusal is shown, not silent. | Generic CSV |
| G28 | Two rows can never target one card in the same run — collisions are flagged, excluded from bulk-select, and re-checked at Apply. | Generic CSV, eBay Sales, CollX, PSA Return |
| G29 | Refunded or canceled orders book nothing, ever — excluded from the apply set and re-checked inside the loop. | eBay Sales, CollX Sales |
| G30 | An order already carrying cards is locked and cannot be re-booked; an order booked in this session is frozen as "Just booked" rather than re-derived. | CollX Sales |
| G31 | A near-miss on the exact keys is not proof a card is new — creation is blocked behind a corroborated duplicate suggestion, unlockable per row. | CDP "Not in the app" |
| G32 | A dropdown value is never created by an importer; unmatched Card Type / Brand / Grader become a picker. | CDP |
| G33 | Listing links are additive only — an importer never removes one. | CDP __listings |
| G34 | Detection is stricter than the importers; a low-confidence verdict routes you there to look but never auto-loads; a mixed drop switches nothing. | fileDetect.js + DataImport |
| G35 | An existing photo is never overwritten unless overwrite === true; front and back are guarded independently. | api/photos.js; the raw-match approver |
| Platform, budget and identity | ||
| G36 | Photos are downloaded server-side and re-hosted in card-photos; the app never keeps a long-term pointer to a third-party host. | attachImage() — every path |
| G37 | Definitive PSA verdicts are never re-asked; transient failures are; a 429 stops the batch immediately. | psa_cert_status, psa_no_image, the 429 break |
| G38 | Fees are data, editable live; the sale path hardcodes no rate. | sold_platforms + platform_fee_tiers |
| G39 | Managers always have finance access — the flag is forced true on both the client and the invite route. | Users tab + api/users.js |
| G40 | The system can never be left without an active manager, and a manager identity can never be hard-deleted. | api/users.js remove / delete |
| G41 | A partner referenced by any card cannot be deleted; the error names the count and points at removing the login instead. | api/users.js delete |
| G42 | A lookup value is never deleted — only retired — so cards already using it are never orphaned. | Dropdowns tab (no delete path exists) |
| G43 | A deep-linked admin section is honoured only if that tab exists for the caller's role. | AdminConsole |
| Records and reporting | ||
| G44 | card_events is a record, never a calculation — nothing financial reads it, so a reversed sale stays visible forever. | cardEvents.js doctrine #1 |
| G45 | Events are append-only; a correction is a new event, never an edit. | RLS grants select + insert only |
| G46 | Best-effort side-writes never block real work — card_events, user_audit, card_title_events and card_listings all swallow failures (audit now warns to console). | All write paths |
| G47 | An integrity or health check must never fake an all-clear: an empty dispositions lookup returns unknown, and any throw returns unknown. | useIntegrityPulse |
| G48 | The Ledger's footer data-integrity badge fails closed — green only on a verified exact match. | Inventory.jsx |
| G49 | The full-DB CSV live-pulls from Supabase, never the client cache, and always carries the two photo-URL columns. | fetchAllCardsForExport, buildCsv |
| G50 | The AI analyst is fenced to cards-only and told never to invent a figure; the PSA-return matcher writes nothing at all. | api/analyst.js, api/psa-return.js system prompts |
| G51 | Split percentages should total 100%. advisory — the amber warning is the only enforcement. | Profit Distribution block |
| G52 | Fee tiers should not overlap or leave gaps. advisory — adjacency is implied by ordering, not validated. | Settings tab |
7Integrity Model
Two distinct systems share a name. The Integrity tab is a data-quality worklist of 18 checks with no severity model. The header badge watches three invariants and is the only place broken-versus-attention exists.
7.1 · Why the badge is not the tab in miniature
Verbatim from useIntegrityPulse: "This is NOT the Integrity tab in miniature.
The tab reports ~4,500 flags, but most of that is BACKLOG — cards with no purchase date, no cost,
no owner. Those are a data-entry queue, not an alarm, and a badge permanently reading '4,497' is a
badge nobody reads." So the pulse watches the handful of things that should always be zero in
a healthy book, each meaning real money is wrong somewhere.
7.2 · Scope and the fail-closed prerequisite
const LIVE = (q) => q.is('deleted_at', null).or('archived.is.null,archived.eq.false')
Every check is scoped to rows that are neither soft-deleted nor archived (a null archived
counts as live). Deleted and archived cards can never light the badge.
Before any counting, the hook loads dispositions and derives
soldReal (category === 'Sold' && is_sold), loss
(category === 'Loss'), and soldOk = soldReal ∪ loss — the set of
dispositions a SOLD card is legitimately allowed to carry.
If either soldOk or loss is empty, the hook returns immediately with
severity:'unknown' and error:'dispositions unavailable'. Without this
guard the not in () filters would match everything, painting the badge red
across the whole inventory. The reasoning is spelled out in source: "If the lookup comes back
empty, do NOT report a false all-clear and do not flag every card. Say we don't know." The
file also carries the lesson that produced it: "The audit trail sat broken for three days
behind a silent catch."
7.3 · The four badge checks
All four are count:'exact', head:true queries — no card rows are
transferred. Total cost: four head-only counts plus one small lookup, "so this is safe on
sign-in and after every write."
| Tier | Check | Predicate | Means |
|---|---|---|---|
| broken | residue | LIVE ∧ sale_status ≠ 'SOLD' ∧ (net_proceeds > 0 ∨ sold_price > 0 ∨ platform_sold_price > 0 ∨ date_sold ∨ sold_platform_id ∨ platform_sale_order_id) — six residue signals | A half-reversed sale: phantom revenue or a hole. |
dispSold | LIVE ∧ sale_status = 'SOLD' ∧ disposition_id NOT IN soldOk | Reported together as the single line "Status and disposition disagree". "The exact shape a broken reversal leaves behind." | |
dispUnsold | LIVE ∧ sale_status ≠ 'SOLD' ∧ disposition_id IN soldOk | ||
| attention | missing | LIVE ∧ sale_status = 'SOLD' ∧ disposition_id NOT IN loss ∧ (sold_platform_id IS NULL ∨ year_sold IS NULL) | A sold card whose revenue cannot be attributed. Losses and gifts are excluded — they are legitimately $0 with no platform. |
broken = residue + dispSold + dispUnsold
attention = missing
severity = broken > 0 ? 'broken' : attention > 0 ? 'attention' : 'clean'
total = broken + attention
The badge's number is the combined total, so a red badge reading 7 may be 2
contradictions plus 5 incomplete records; only the tooltip breaks it down. The exported
PULSE labels are Books clean · Needs attention ·
Books broken · Check unavailable.
7.4 · How the badge maps onto the tab
| Badge part | Tier | Tab check | Difference |
|---|---|---|---|
| Sale data on a card that isn't sold | broken | #2 saleonunsold | Same shape. |
| Status and disposition disagree | broken | #3 statusdisp | The badge computes it as two head-count queries and sums them; the tab computes it row-wise. |
| Sold, missing platform or sale year | attention | #1 soldmissing | Narrower: the badge checks only platform and year (not sold_price) and excludes Loss dispositions. |
The remaining 15 tab checks — the whole cost, grading, ownership, sanity and collection-facing backlog — deliberately have no badge representation.
7.5 · Failure and lifecycle
The whole body is wrapped in try/catch; any throw yields severity:'unknown' with the
message. The comment states the rule precisely: "Never break the header over a health check —
but never fake an all-clear." The check re-runs when the partner loads, every time the Admin
Console closes, and on demand via the returned refresh().
8Known Defects, Inconsistencies and Risks
62 items. Everything the source review turned up, consolidated and numbered. Nothing here is speculative — each entry traces to code that exists. Severity is a judgement about consequence, not about how hard it is to fix: high can produce a wrong number or a data loss; medium misleads, blocks a workflow, or is a latent trap; low is cosmetic or a maintenance burden.
Several of these are deliberate trade-offs that were correct at the time and are recorded here so nobody re-discovers them in production. Where a divergence is intentional, the entry says so.
8.1 · Naming, labelling and duplicated logic
| ID | Sev | Defect | Impact | Suggested fix |
|---|---|---|---|---|
| D-01 | low | The gear button's tooltip reads Settings (coming soon), and the comment above it says "not built yet". | The button opens a fully functional Admin Console. The copy is simply wrong and undermines trust in other tooltips. | Change the title to Admin Console (or My Account for non-managers) and delete the stale comment. |
| D-02 | medium | Tab keys no longer match labels: insights renders "Financials", finances renders "Accounting". The components' own headings still read Executive Dashboard / Finances, and Financials' subtitle points at "the Financial tab". | Anyone writing tests, docs or a bug report has to hold two vocabularies at once. Users are pointed at a tab name that no longer exists. | Sweep the visible strings first (they are cheap and user-facing), then rename the files and keys in one commit. Until then, key all tests on the label. |
| D-03 | low | The Ledger search placeholder names five fields; the matcher searches seven (SKU and Card Type are also matched). | Users do not know they can search by SKU. | Extend the placeholder, or trim it to "Search anything…". |
| D-04 | medium | MANAGER_ROLES is duplicated six times: App.jsx (exported), Scan.jsx (an inline array), and each of the four API routes. | A future role change must be applied in six places. The four server copies are deliberate and must stay independent for security; the Scan.jsx copy is not. | Have Scan.jsx import canManage. Leave the server copies, with a comment saying why they are duplicated. |
| D-05 | high | "Sold" is defined three different ways: sale_status === 'SOLD' (Ledger, integrity pulse, Shipping Ledger, Write-Offs); year_sold != null (Collection, Card Editor header); and the broad disposition.is_sold || sale_status === 'SOLD' || !!year_sold (useWaterfall). | A card with one attribute but not the other reads inconsistently between screens. It also means Financials' Revenue and Accounting's Gross Sales can cover slightly different populations (see X1 in §9.3). | Pick one definition, export it from a single module, and have the integrity pulse's residue check be the only place that looks for the disagreement. Divergence should be something the system detects, not something it embodies. |
| D-06 | medium | Five canonical-name builders in three formats: the editor's and BulkEdit's full PSA layout (independently implemented, agreeing by convention only); identify.js and Scan.jsx's seven-token form; and psa.js's five-token form. | A card named by one path and re-generated by another can change name for no user-visible reason. | Extract one builder into src/lib/, import it everywhere client-side, and accept the server copy as a deliberate duplicate with a test asserting the two produce identical output. |
| D-07 | low | Two surfaces are called "Ledger" (the card grid, and Accounting's expense register); two flows are called "gift" (gifting profit to BKCards71 on a sale, and gifting the card away as a write-off). | Support requests and documentation are ambiguous. | Rename the Accounting sub-view to Expenses or Entries; rename the write-off story to Gave away (write-off). |
8.2 · Security and exposure
| ID | Sev | Defect | Impact | Suggested fix |
|---|---|---|---|---|
| D-08 | high | GET /api/psa?cert=12345678 performs a real, unauthenticated, quota-consuming PSA lookup and returns the parsed result and the raw PSA payload. | Anyone who finds the URL can burn the ~100-call daily budget and read PSA data through your token. | Delete the GET lookup branch, or gate it behind requireManager like the POST path. Keep the token-presence probe. |
| D-09 | medium | GET /api/identify returns the first and last three characters of XIMILAR_TOKEN plus the full Ximilar account details, unauthenticated. | Six characters of a secret plus account metadata leak to any caller. | Reduce the probe to {ok, tokenPresent}; move the account call behind the manager guard. |
| D-10 | high | api/psa-return.js and api/analyst.js accept unauthenticated POSTs and spend ANTHROPIC_API_KEY on behalf of anyone who finds the URL. psa-return permits up to 12,000 output tokens per call. | Uncapped third-party spend, and an open relay to a model. | Apply the same requireManager blueprint the other three routes use. Both routes already receive a session in the client, so the change is additive. |
8.3 · Ledger and grid
| ID | Sev | Defect | Impact | Suggested fix |
|---|---|---|---|---|
| D-11 | high | The CSV export of the Net Proceeds column exports the wrong field. Its numeric branch does typeof c[col.key] !== 'undefined' → num(c[col.key]), and the key is sold_price — so the file writes raw sold_price while the grid displays net_proceeds ?? sold_price. | An exported file silently disagrees with the screen for any legacy row where the two columns differ. | Special-case the key in csvCellText, exactly as net_return already is. |
| D-12 | medium | The Total Sold $ KPI and the filtered rollup both sum sold_price, while the grid column labelled "Sold Price" reads platform_sold_price (gross) and the one labelled "Net Proceeds" reads net_proceeds. | Three money fields, three labels, and a roll-up that matches neither column heading. | Rename the columns to Gross (platform) and Net proceeds, and state on the KPI tile which field it sums. |
| D-13 | low | Unticking both photo checkboxes shows zero rows, contradicting the inline comment which says "both checked or neither shows everything" — Set.has() on an empty Set matches nothing. | Confusing, but the status-row pill does correctly label the state No Photos Shown with count 0, so it is at least surfaced. | Either implement the documented behaviour (size === 0 → pass all) or fix the comment. The pill suggests the current behaviour was accepted. |
| D-14 | low | The asking_price render comment claims a blank shows as — to stay distinct from a real $0.00; the render is money(), which returns '' for null. | Blank and unpriced are visually identical after all — which undercuts the deliberate 0..0 filter guardrail sitting next to it. | Implement the —, since the guardrail depends on the user being able to see the difference. |
| D-15 | medium | useCards.reload() discards a partially-paged load on a mid-paging error and leaves cards at its previous value while surfacing the error. | The grid can show stale rows under a FAILED: banner. | Clear cards on a paging failure, or surface a distinct "showing cached data" state. |
| D-16 | low | countsInFinancials(c) is defined in Inventory.jsx, documented as the money-on-the-books driver, and never called. No KPI or rollup consults it. | A reader assumes the flag is honoured in the Ledger's totals. It is not — they use isSold / inInventory. | Delete it, or wire it into the KPI band if the flag is meant to matter there. |
| D-17 | medium | DEFAULT_VIEW order ≠ COLUMNS order, and any column toggle re-canonicalises the whole array. On first paint sku renders third; the moment the user ticks anything it jumps to position 18. applyConfig does the same to every saved view. | Column order is effectively not user-controllable, and a saved view's order is not what gets restored. | Either honour the stored order (append new columns at the end) or drop columns ordering from the saved config and document that order is fixed. |
| D-18 | low | showArchived is not part of isFiltered and is not reset by Clear all filters. | The "Clear all filters" pill can be absent while the grid is still showing archived rows. | Include it in isFiltered, or add a separate always-visible indicator when archived rows are shown. |
| D-19 | medium | Under the Archived facet: the status chips all read 0 (statusCounts skips archived rows); the footer reads Showing N of N live rows. while displaying nothing but archived rows; and the photo-funnel counts are computed over an archived-only population. | Three separate readouts contradict what is on screen. | Make statusCounts, the footer label and photoCounts all derive from gridBase rather than assuming a live population. |
| D-20 | medium | damaged and showcase both define value and filterVal but omit the filterable flag, so the funnels they were clearly written for do not exist. cert_number, date_sold, purchase_date, player and description have no funnel either. | The at-risk (damaged) pile can only be found by sorting. Date columns are neither checklist- nor range-filterable, because they are strings rather than numeric. | Add filterable to damaged and showcase (one word each). Add a date-range funnel type for the two date columns. |
| D-21 | low | Year columns can only be range-filtered, never picked from a list: card_year, purchase_year and year_sold all define a good filterVal, but the numeric flag makes openFilterMenu always take the min/max branch. | Filtering to 2025 means typing 2025 into both boxes instead of ticking a year. | Allow a column to declare both, and offer a two-mode popover (list / range) for years. |
| D-22 | medium | Saved views persist layout only. Filters, search text, the card-type chip, the status facet, the photo funnel and showArchived are not part of config. | A view called "Sales only" restores the columns but not the filter that made it a sales view — the name promises more than the feature delivers. | Add an optional filters block to config with a checkbox on save ("also save the current filters"). |
| D-23 | low | Performance and determinism: no secondary sort tiebreak in the grid (the Recent Sales modal has one); distinctValuesFor is not memoised and walks the whole searched array on every render of an open checklist, twice per apply; the auto-size pass runs title/filterVal/value over ~1,973 cards × 64 columns. | Equal values rely on sort stability; funnels feel sluggish on large sets. | Add an id tiebreak; memoise distinctValuesFor on [key, searched]; cache the measurement pass. |
| D-24 | low | Fields with no column and no export path: photo_back_url (grid-invisible, ignored by hasPhoto, present only as a CSV trailing column), gifted_to (rendered only as 🎁 gift in Recent Sales), damage_note (a tooltip only), shipping_collected, label_cost, notes, archived_at, counts_in_financials, grade_qualifier (folded into Grade) and disposition_legacy. Also: cardImageSrc falls back to c.image_url, a field not in CARD_SELECT and therefore always undefined; and Recent Sales silently caps at 400 rows (with a notice) though its net headline covers all of them. | Real data cannot be filtered, sorted or exported. | Add columns for the ones that matter (photo_back_url as a has-back funnel, gifted_to, damage_note); delete the vestigial image_url fallback. |
8.4 · Financials, Accounting and the waterfall
| ID | Sev | Defect | Impact | Suggested fix |
|---|---|---|---|---|
| D-25 | medium | The chart titled Gross Profit by Year sums the DB column net_return, while the KPI tile titled Gross Profit computes Σ sold_price − Σ total_cost. net_return also carries the shipping margin, so the two legitimately differ. The same applies to Gross Profit by Platform and Grade Lift. | A user comparing the tile to its own chart sees two different numbers under one name. | Either retitle the charts Net Return by …, or make the tile carry the ship margin too. The second is the better answer because it aligns Financials with the DB and with Accounting. |
| D-26 | low | A card with asking_price exactly 0 falls into the < $15 price band, while Data Quality counts null or 0 as "no asking price". | The two blocks disagree by the number of $0-priced cards. | Route 0 to the No price band. Negative asking prices also currently match no band and vanish. |
| D-29 | high | Three near-identical copies of the recurrence walk: occurrencesInScope, ledgerOccForChart and ledgerOccYear. | They agree today. If one is edited, the ledger totals, the OpEx donut and the P&L will diverge silently. | Extract one function into src/lib/recurrence.js. This is the single highest-value refactor in the money code. |
| D-31 | medium | The category classification chain is ordered differently in two places: the summary band tests startsWith('Operating Expense') before includes('Write-Off'); useWaterfall tests Write-Off first. | Any category name containing both tokens would be classified differently in the ledger band and in the P&L. No such name exists today. | Unify the chain into one exported classifier. |
| D-32 | high | Category names are logic: startsWith('Operating Expense'), includes('Write-Off'), === 'New Cards', and the donut's exact en-dash prefix strip. | Renaming a category in the admin UI silently drops it out of the P&L. The most fragile coupling in the money code. | Add boolean flags to fin_categories (is_opex, is_writeoff, is_card_lot) and migrate the existing rows; keep the name strings only as a display label. |
| D-33 | high | The Write-Offs block maps hard-coded disposition_id primary keys {15, 6, 7, 8, 16} to story names. | Reseeding or reordering the dispositions table silently reclassifies every write-off. The editor's write-off stories carry the same literals. | Key on disposition.category === 'Loss' plus the label, or add a stable story_key column to dispositions. |
| D-34 | medium | For a recurring reimbursable entry, the cost is multiplied by the occurrence count but reimbursed_amount is not — one reimbursed amount is netted against N occurrences of cost. | Recurring reimbursables are effectively unsupported and will overstate what the company owes. | Either multiply the reimbursed amount too, or block the reimbursable checkbox when a recurring frequency is selected. |
| D-35 | medium | The Accounting year toggle is the literal ['all','2025','2026']. | When 2027 arrives, that year is unreachable until someone edits the array. | Derive the list from min(entry year, purchase year, sale year) through the current year. |
| D-36 | medium | The active checkbox on a recurring entry is rendered, stored and round-tripped, but no calculation reads it. | Unticking Active changes nothing about any total. It is a trap that looks like a control. | Wire it into the occurrence walk, or remove it from the panel and rely on End Date, which does work. |
| D-37 | medium | Inline dropdown adds land inert: a category created from the entry panel has no is_capex / is_capital_contribution flags, and a frequency created there has no months_interval. | A new capital category lands in no summary bucket. A frequency typed as "Quarterly" produces a single-occurrence entry. | Prompt for the flags/interval inline, or badge newly-created rows as "needs setup" until an admin completes them. |
| D-38 | high | normPartner matches by case-insensitive name prefix (kevin*, brian*, bk*); anything else is returned unchanged and then silently dropped from every accumulator by the !== undefined guards. | A partner whose name does not start with one of the three prefixes contributes nothing to any waterfall figure, with no warning anywhere. A renamed partner would silently zero out. | Match on partners.id rather than the name string. Failing that, count unmatched names and surface the count on the Statements page. |
| D-39 | medium | The Excel Non-Card Ledger sheet handles recurrence differently from the screen: it includes a recurring entry only when the scope is "all", and exports the unit amount rather than the scoped total. | The exported sheet will not foot to the on-screen ledger total. | Use the shared occurrence function (see D-29) and export the scoped total, with the unit amount in an adjacent column. |
| D-40 | medium | In the capital waterfall, the partner who received the sale proceeds is derived from received_method.label — a lookup label, not a partner FK. | Renaming a "Proceeds To" value re-routes capital recovery. A value that does not prefix-match a partner name credits nobody. | Add a nullable partner_id to received_methods and read that instead. |
| D-41 | medium | The reimbursement test is independent of the classification chain, so a reimbursable capital contribution counts in both nonCardOpexPaid and capitalContrib — and is therefore counted twice in nonCardOwed. | Overstates what the company owes that partner. | Verify against real data; if such entries exist, make the two paths mutually exclusive. |
| D-42 | medium | In "All years" mode, every profit-split row is applied in iteration order, so the last row encountered per partner wins arbitrarily. | The Target profit share figures shown in All mode are not meaningful. The sliders are hidden there, which papers over it. | Show — for target and still-owed in All mode, or aggregate the pool per year and apply each year's own split. |
| D-43 | medium | A distribution recorded with the default kind payout reduces totalOwed but is invisible to the Profit Distribution table (only profit_draw feeds profitTaken). capitalTaken is computed and returned but never rendered anywhere. | A partner can be paid a profit share that the profit table still shows as owed. | Either surface all three kinds in the profit block, or make Kind a required choice with no default. |
| D-44 | medium | giftByCard is a flat map, so a second gift row for the same card overwrites the first. Separately, the waterfall caps each gift at the card's net while the Profit Gifts table reads the raw amount. | A gift larger than its card's profit makes the table total and Σ profitGifted disagree. | The upsert on card_id already enforces one gift per card at write time — assert it on read too, and clamp the display to the card's net so both sides agree. |
| D-59 | medium | The Shipping Ledger block filters on sale_status === 'SOLD', while useWaterfall's isSold is broader. | A card sold under a disposition flag but without sale_status = 'SOLD' contributes its shipping margin to grossProfit yet is absent from the breakdown table, so the breakdown does not tie out. | Use the exported isSold in the block. Depends on D-05. |
| D-61 | medium | The reimbursement state and amount can contradict: marking an entry Fully reimbursed while leaving reimbursed_amount at 0 leaves the full amount showing as owed. reimbursed_to_id and reimbursed_date are captured and exported but used in no calculation. | The Non-Card Position block can be wrong in either direction with no visible signal. | On selecting Fully reimbursed, default the amount to the scoped total; warn when the state and the amount disagree. |
| D-62 | medium | Nothing enforces that split percentages total 100% — the amber warning is advisory only. | A split summing to 90% leaves 10% of the company pool unallocated, and nothing else on the page flags it. | Block Save Split below/above 100%, or add the residual as an explicit "unallocated" row. |
8.5 · Editing, events and audit
| ID | Sev | Defect | Impact | Suggested fix |
|---|---|---|---|---|
| D-27 | medium | Year Sold is an editable field in the Sale group while every sale flow derives it from Date Sold. | The two can be made to disagree by hand — which is precisely what integrity check #5 (wrongsaleyear) exists to catch, and which corrupts tax buckets. | Make it read-only (calc) and derive it in the database from date_sold. |
| D-28 | low | The Names and Notes field groups exist in EDIT_FIELDS but have no GROUP_META entry, so their group dot renders with backgroundColor: undefined. | Two invisible dots. Harmless, but the group band is no longer exhaustive. | Add two colours. |
| D-30 | medium | Bulk Sale is not transactional: the loop is sequential and stops on the first error, leaving a partly-booked lot. Neither it nor its host writes a user_audit row. | Recovery is manual. The only record of a lot is the per-card journey events. | Wrap the writes in an RPC, or at minimum write one bulk_sale audit row with the lot ids before the loop so a partial run is traceable. |
| D-47 | high | Admin hard delete writes no user_audit row and does not remove the back photo file from storage. | The only irreversible operation in the app leaves no trace but the absence of a row. Orphaned back images accumulate. | Call logAudit(partner, 'card_hard_delete', {...}) before the delete, and remove both storage paths. |
| D-48 | low | BulkEdit and BulkEditModal insert into user_audit inline rather than through logAudit. | They happen to use the correct column shape, so they work — but they are exactly the pattern audit.js was written to eliminate, and they do not get its console warning on failure. | Migrate both call sites. They are the last two in the editing layer. |
| D-49 | low | order_canceled and sale_corrected render in the timeline but are missing from EVENT_SORT, so both fall through to the default 50 — alongside sold rather than at 55 with the other reversals. | A canceled order can sort ahead of the sale it reverses when dates are equal or missing. | Add both at 55 (and consider 56 for sale_corrected, since a correction follows a cancellation). |
| D-50 | low | Removing a photo from the editor clears the column but leaves the storage object in the bucket. | Orphaned files accumulate and the bucket grows unbounded. | Delete the object on remove, as hard delete does for the front. |
| D-55 | low | AdminConsole passes onEditCard to <BulkEdit>, but BulkEdit does not declare that prop — it opens its own CardEditor instead. | Latent dead wiring; the shell's deep-link handler is never invoked from this path. | Remove the prop, or use it and drop BulkEdit's local editor. |
| D-56 | low | Small dead code in Inventory.jsx: the ARCHIVED predicate is applied twice (once in gridBase, again in filtered); toggleAllYears contains a dead ternary (cur === null ? null : null), so All can never turn all years off; onSignOut is destructured from props and never used. | None functionally — All is a reset, not a toggle, which is the desired behaviour anyway. | Clean up; add a comment saying All is intentionally a reset. |
8.6 · Importers and admin
| ID | Sev | Defect | Impact | Suggested fix |
|---|---|---|---|---|
| D-45 | medium | The fee-tier table has no gap or overlap validation — adjacency is implied by ordering, not enforced. | Two tiers claiming the same price range, or a gap between them, silently produces a wrong net estimate on every sale in that band. | Validate on save that tiers are strictly ascending and contiguous from the lowest bracket. |
| D-46 | low | IdentifySection (the AI Identify / Ximilar test panel) is fully implemented but is not in SECTIONS and is never rendered. | Dead code that would cost ~20 Ximilar credits per card if reconnected carelessly. | Delete it, or register it behind an explicit developer flag. |
| D-57 | low | EbayListings and EbayPurchases store cardMatch's raw score into the displayed confidence field, so they can print above 100%. | The percentage becomes useless for distinguishing a solid match from a guess — the exact problem confidence was added to solve. | Display r.confidence, threshold on r.score, as EbaySales already does. |
| D-58 | low | The Generic CSV importer still does a raw inline user_audit insert instead of calling logAudit(). | Its shape is correct so it works, but it silently loses the helper's console warning on failure. | Migrate the call site. |
| D-60 | low | The Purchases → "Turn into Live Card(s)" loop aborts on the first insert error. | A partial batch is possible; the message does report how many were actually created. | Continue past failures and report them per row, as every importer does. |
8.7 · Architectural risks
| ID | Sev | Risk | Impact | Suggested fix |
|---|---|---|---|---|
| D-51 | high | Scan's photo upload and candidate delete have no client-side role gate and no server route — they rely entirely on RLS on scan_candidates and the scan-uploads bucket. | If those policies are permissive, any signed-in account can upload into the bucket and delete other people's candidates. | Audit the two policies explicitly and write them down. Consider gating delete on canManage in the UI as well, for consistency with every other destructive action. |
| D-52 | low | api/users.js checks role but not account_status, unlike the other three guarded routes. | A suspended manager's token would be rejected by Supabase auth in practice, but the asymmetry is a latent gap and makes the guard harder to reason about. | Reuse the same requireManager blueprint across all four routes. |
| D-53 | high | The Financials and Accounting tabs are gated client-side only. Neither component re-checks the role internally — Insights accepts partner and never reads it; Finances does not even destructure it. | All actual protection of fin_entries, fin_distributions, fin_profit_splits and fin_profit_gifts comes from RLS. If a policy is permissive, a viewer with a browser console can read the financial tables. | Verify and document the RLS policies on all nine fin_* tables against the can_access_finance flag. Treat the client gate as UX, never as security. |
| D-54 | low | Help & Support notes that email notification "rides on api/support", which does not exist. | Submitted issues and requests are visible only in-app; nobody is notified. | Either build the route or remove the expectation from the copy. |
If only a handful can be done: D-32 (category names as logic) and D-33 (magic disposition ids) because a routine admin action silently breaks the books; D-38 (prefix-matched partner names) because a rename silently zeroes a partner's entire position; D-29 (three recurrence copies) because divergence there would be undetectable; and D-10 / D-08 because they are open endpoints spending real money.
9Appendices
9.1Event types
card_events vocabulary. Sort is EVENT_SORT, stamped at
write time as sort_hint; an unknown type defaults to 50.
Struck marks the four reversal types whose money renders with a line through it.
| Type | Icon | Title shown | Tone | Sort | Struck | Written by |
|---|---|---|---|---|---|---|
purchased | 🛒 | Bought | sky | 10 | — | Add-mode save; the new card in a kept-payment re-intake; hand entry |
sent_to_grading | 📮 | Sent to grading | indigo | 20 | — | Hand entry |
graded | 🏅 | Graded | indigo | 30 | — | Hand entry |
listed | 🏷️ | Listed | slate | 40 | — | Hand entry |
price_changed | 💲 | Asking price changed | slate | 45 | — | Hand entry |
sold | 💰 | Sold | emerald | 50 | — | Mark Sold; Bulk Sale (source:'bulk'); eBay Sales; CollX Sales; hand entry |
sale_reversed | ↩️ | Sale struck (mistake) | amber | 55 | yes | Un-sell → Strike |
returned_refunded | ↩️ | Returned & refunded | amber | 55 | yes | Un-sell → Returned & refunded |
reintake_kept_paid | 🔁 | Kept payment, card back | amber | 55 | yes | Un-sell → We kept the money (on the original card) |
order_canceled | 🚫 | Order canceled & refunded | amber | 50 missing | yes | Un-sell → Order canceled; eBay Sales correction path |
sale_corrected | 🔧 | Sale corrected | cyan | 50 missing | no — its amount is the money that stands | eBay Sales correction path; hand entry |
gifted | 🎁 | Gifted | rose | 60 | — | Status → Gifted |
written_off | ✖ | Written off | rose | 60 | — | Status → Write Off |
damaged | ⚠ | Damaged | amber | 60 | — | Hand entry |
found | 🔍 | Found | emerald | 60 | — | Hand entry |
lost | ❓ | Lost | rose | 60 | — | Hand entry |
loaned | 🤝 | Loaned out | slate | 60 | — | Hand entry |
at_show | 🎪 | At a show | slate | 60 | — | Hand entry |
archived | 📦 | Archived | purple | 70 | — | The Archive button |
restored | ♻️ | Restored | purple | 70 | — | The Restore button |
note | 📝 | Note | slate | 80 | — | Hand entry; CDP card creation |
| anything else | • | the raw event_type | slate | 50 | — | degrades gracefully |
Sources
source | Set by | Badge in the timeline |
|---|---|---|
app | the default in logCardEvent — every in-app flow | none |
bulk | Bulk Sale | none |
importer | eBay Sales, CollX Sales, CDP | none |
manual | Card Journey hand entry | added by hand (cyan) |
seed | reconstructed history | reconstructed from card record (grey) |
Platform labels are resolved per type: sold → sold_platforms,
purchased → purchase_platforms, listed →
listing_platforms. Any other type shows no platform even when
platform_id is populated.
Types available in the hand-entry form
Note · Found · Lost · Loaned out ·
At a show · Damaged · Sent to grading · Graded ·
Listed · Asking price changed · Sold — historical ·
Returned & refunded — historical · Order canceled & refunded —
historical · Sale corrected — historical · Sale struck — historical.
Deliberately absent: purchased, live sold,
gifted, written_off, archived, restored,
reintake_kept_paid — those are only ever written by the flow that performs the action.
The five "historical" entries exist to record reversals that predate the event log; the code names
Yamamoto #1692 and Misiorowski #1811 as the cases it was built for.
9.2Audit actions
user_audit columns: id · action · detail (jsonb) · created_at ·
actor_partner_id · actor_email · target_partner_id · target_email. There is
no actor column — see §2.4 for the incident that
produced the helper.
| Action | Written by | Detail payload |
|---|---|---|
User administration — api/users.js | ||
invited | invite | role, finance flag, target email |
invite_resent | resend | target email |
password_reset_sent | reset | target email |
suspended | suspend | target |
reactivated | reactivate | target |
removed | remove | Login deleted; accounting identity preserved. |
deleted | delete | Hard deleted — no accounting references. |
role_changed | Users tab inline edit | {from, to, finance} |
| Card editing | ||
unmark_sale | Un-sell → Strike | {card_id, cleared: <full sale snapshot>} |
return_refund_sale | Un-sell → Returned & refunded | {card_id, return_cost, cleared} |
order_canceled_refund | Un-sell → Order canceled | {card_id, cleared} |
reintake_kept_paid | Un-sell → Kept the money | {from_card, new_card} |
write_off | Status → Write Off | {card_id, story, date, cost} |
bulk_edit | BulkEditModal Apply inline insert | {fields, values, count, cleared, cost_fields_touched, sold_cards_in_selection, ids} |
bulk_generate_names | BulkEdit Generate names inline insert | {count, skipped, ids} |
| Importers | ||
csv_import | Generic CSV inline insert | {file, rows, updated, failed, fields} |
ebay_sales_import | eBay Sales | {updated, created} |
ebay_listings_import | eBay Listings | {stamped} |
ebay_purchases_import | eBay Purchases | {created, filled} |
collx_sales_import | CollX Sales | {booked, orders, failed} |
cdp_import | CDP apply | {cards, changes} |
cdp_create_cards | CDP create | {created, failed, photos, file} |
psa_return_import | PSA Return | {order, updated, created} |
| Configuration | ||
fees_update | Settings — platform save, tier save, tier delete, tier add | the changed values |
Ordinary field saves · the For-Sale ↔ Not-For-Sale flip · Mark Sold · Gift (the card) · Archive · Restore · photo changes · Bulk Sale · hard delete. The last is the notable omission: it is the only irreversible operation in the app and it leaves no trace but the absence of a row (D-47).
9.3Reconciliation targets and identities
Hard internal identities — must hold to the penny
| # | Assertion | Where it is asserted |
|---|---|---|
| R1 | Σ taxSchedule[].gainLoss == pl.grossProfit | The Tax Schedule footer literally renders grossProfit as the column total. Both sides carry the ship margin, so it holds by construction — if the rows do not sum to it, the footer is a lie. |
| R2 | grossProfit == grossSales − cogs + shipMargin | useWaterfall step 6 |
| R3 | netOrdinaryIncome == grossProfit − opex − writeoff | P&L block; CapEx and owner capital excluded by design |
| R4 | adjCapitalAdvanced == costAdvanced + gradingForOthers − gradingByOthers | Capital Waterfall rows 1–4 |
| R5 | stillOut == adjCapitalAdvanced − recovered | Capital Waterfall rows 4–6 |
| R6 | Σ gradingForOthers == Σ gradingByOthers | Cross-partner grading is booked on both sides of the same event. If these differ, a partner name failed normPartner — this is the cheapest live detector for D-38. |
| R7 | totalOwed == nonCardOwed + stillOut − distTaken | Total Owed block |
| R8 | sumOwed is explicitly not zero — it equals realised gain/loss | Reconciliation block |
| R9 | For every matrix cell: cost − recovered == loss | Out-of-Pocket footnote (recovery is capped at cost) |
| R10 | nonCardOwed == nonCardOpexPaid + capitalContrib | Non-Card Position block |
| R11 | partnerNet == profitOwnKept − lossCarried | Partner Accounting block |
| R12 | profitStillOwed == pool × pct/100 − profitTaken | Profit Distribution block |
| R13 | companyPool == bkOwnedProfit + Σ profitGifted | Definitional — the pool is fed only by BK-owned profit and gifts |
| R14 | Σ splitPct == 100 | Not enforced — advisory warning only (D-62) |
| R15 | Profit Gifts table total == Σ rules.profitGifted | Same rows, but the waterfall caps each gift at the card's net, so a gift larger than its card's profit makes them disagree (D-44) |
| R16 | ship.margin == pl.shipMargin | Two independent computations of one term, with different sold-tests (D-59) |
| R17 | Ledger footer Complete + Front Only + Back Only + No Photos == Cards | Photo Import coverage table, mutually exclusive by construction |
Cross-tab reconciliations
| # | Assertion | Caveat |
|---|---|---|
| X1 | Financials Revenue (year Y) == Accounting Gross Sales (year Y) | Both are Σ sold_price over sold cards in Y — but the two tabs use different isSold definitions, so a card sold without a year_sold appears in Accounting and not in Financials (D-05). |
| X2 | Financials COGS == Accounting Cost of Goods Sold | Same populations, same caveat. |
| X3 | Financials Gross Profit ≠ Accounting Gross Profit, in general | They differ by exactly the shipping margin. Financials is revenue − cogs; Accounting is revenue − cogs + shipMargin. This is not a bug, but it must be explained or it will be chased. |
| X4 | Financials Gross Profit by Year == Accounting gross profit per year | Both are effectively Σ net_return, so these do agree — while the Financials KPI tile does not agree with its own chart (D-25). |
| X5 | Financials Grading Spend by Payer vs the waterfall's grading rows | Financials sums all grade_fee by payer; the waterfall books only cross-partner fees. Financials will always be the larger number. |
| X6 | Financials Invested Capital vs Accounting Still Out | Not comparable. Invested Capital is the cost basis of currently-held for-sale inventory; Still Out is advanced-minus-recovered across a purchase cohort including sold cards. Different frames entirely. |
External acceptance targets
These are the figures the migration and every subsequent phase have been reconciled against. Any change to the money code should be re-checked against all of them before shipping.
| Figure | Value |
|---|---|
| Cards sold | 822 (582 / 240 across the two years) |
| Net Return | −$4,717.42 |
| Total Sold $ | $85,426.58 |
| Cards in for-sale inventory | 1,125 |
| Money in inventory | $75,603.83 |
| Live cards · archived | 1,958 · 15 |
| Ledger seed — 2025 "Cost in Final Ledger" | $40,538.10 |
| Tax summary per partner | Kevin $48,391.06 · Brian $20,444.30 · BK −$24,616.43 |
9.4Glossary
| Term | Meaning |
|---|---|
| Adjusted capital advanced | cost advanced + grading paid for others − grading others paid for you. What a partner has really put in, once cross-partner grading is netted. |
| Asking price | What a held card is listed at. A blank asking price is deliberately treated as 0 for sorting and range filtering so unpriced cards can be found with a 0..0 filter. |
| Base SKU | A SKU with its per-listing suffix stripped, so the same physical card matches across eBay, CDP and the ledger. The suffix rule differs by importer (§4.4). |
| Books (as in "books money") | An importer that writes sale figures onto cards. Only eBay Sales and CollX Sales do. |
| Candidate | A row in scan_candidates — a potential card, isolated from inventory until a human brings it in. |
| Canonical name | A Card Name generated from the card's own attributes in PSA slab order. Distinct from an intake name (whatever the source called it) and a user name (hand-typed, which locks it). |
| Cohort | A purchase-year bucket used by the capital waterfall: '2025 & before', 2026, and so on. Everything predating the app is one lump. |
Comp (comp_as_is) | A comparable-sales estimate of what the card is worth as it stands. Feeds Unrealized P/L and the Dead Money reprice suggestion. |
| Company pool | BK-owned profit plus every gifted amount. The only money the profit-split sliders distribute. |
| Disposition (UI: Sale Type) | A managed lookup carrying accounting behaviour — category, is_sold, is_inventory. It is deliberately not editable from the Dropdowns tab. |
| Fill vs overwrite | An importer change kind. Fill targets an empty field (pre-ticked in CDP, never pre-ticked in Generic CSV); overwrite would replace a real value and is always manual. |
Gross (platform_sold_price) | What the platform reported the item sold for. Reference only — never summed into revenue. |
| Item Type / Card Type | UI labels that cross the underlying tables: Item Type is card_types, Card Type is sports. Renamed in the UI, not in the schema. |
| Live | Not archived and not soft-deleted. The default population for almost everything. |
| Manager | company, partner or admin. The set every privileged server route re-derives independently. |
| Net proceeds / Net sale | What actually arrived after platform fees. Mirrored into sold_price on every write because the generated net_return reads that column. |
| Net Return | The card's realised profit, generated by the database: sold_price + shipping margin − total_cost. |
| NFS / Not For Sale | A held card deliberately excluded from for-sale inventory. It has its own KPI tile with an owner split and never appears in the inventory count, the money-in-inventory figure, or the card-type chips. |
| Pulse | The header integrity badge and its hook. Watches four invariants, not the 18 tab checks. |
| Residue | Sale data left on a card that is not marked sold — the signature of a half-reversed sale. One of the two broken invariants. |
| Shipping margin | shipping_collected − (label_cost ?? shipping_collected). A null label cost is a wash. Already inside net_return; the Shipping Ledger is a breakdown, never an addition. |
| Sold-year basis | Revenue recognised at sale (IRS basis). The frame for the P&L, the tax schedule and partner tax — as opposed to the purchase-cohort frame used by the capital waterfall. |
| Still out | A partner's adjusted capital advanced minus what they have recovered on sales. Can legitimately go negative (over-recovery). |
| Tier 1 | The auto-detect layer. Reads a dropped file's header row and routes it to the right importer — routing, never applying. |
| Total cost | Generated: base_cost + tax + shipping + handling + grade_fee. Return costs on a refunded sale are added into handling, so they land here. |
| Wash | A shipping arrangement where what the buyer paid equals what shipping cost, so the margin is zero. Represented by a null label_cost. |
| Zero-Total Refund Detection | The arithmetic test that finds fully-refunded eBay orders, which the eBay report has no column for. Aggregated per order, never per line. |
10Edition 4 Additions (2026-08-29)
10.1 Intelligence tab & the valuation engine
The Scan tab became Intelligence (internal key unchanged): AI Card Scan · My Card
Values · Prospect (placeholder) · Industry (placeholder). api/value.js computes market
values: grade-matched sold comps → IQR outlier trim → recency-weighted median; <3 comps at exact
grade steps one grade down, disclosed. Returns value, sample_n, low/high, confidence
(strong ≥8 / fair ≥3 / thin), last-3 sales with links, and effective_days read from the
response’s real coverage. 7-day per-card cooldown enforced server-side. At-grading
rule: grader set + no grade ⇒ grade renders “Pending,” card valued as RAW with bare grader words
stripped from the query. Storage: cards.market_value/_at/_n/_days (single truth, in
CARD_SELECT) + card_valuations history (org-walled RLS). Doctrine D-12: external values
are display data — they never touch cost basis, net return, ledger, waterfall, or tax.
10.2 Financial Ledger importers
Bank Statement importer — src/lib/bankStatementParse.js +
src/components/BankImport.jsx, mounted as a modal on the Finances Ledger (deliberately
NOT a Data Import mode). Layout sniffing: a masked card-number column ⇒ credit-card file; a
running-balance column ⇒ checking. Classification (conservative; unrecognized ⇒ human review):
transfers (PAYMENT - THANK YOU ↔ WEB PMT TO) skip; marketplace payout credits and
PSA transfers skip (income lives on cards); eBay O* buys and PSACARD.COM skip (card
costs); PIRATE SHIP skips OpEx (label costs belong per-sale — Shipping Ledger); subscription
patterns match the org’s recurring rules and mark “covered,” never double-booking; SERVICE CHARGE
books to Bank Fees (vendor auto-ensured). Dedupe: same absolute amount within ±3 days of an existing
entry (banks post late), flagged and un-ticked. Same-amount-across-months pattern ⇒
“monthly bill?” banner ⇒ one-click recurring-rule creation. Unknown last-4 ⇒ inline pay-method
creation. Booked rows are structurally excluded from re-send; failures carry per-row plain-English
reasons via friendlyDbError().
Receipt Photo importer — api/receipt-parse.js (server-side Claude
vision; key never in the browser) + src/components/ReceiptImport.jsx. Up to 10 images per
run ⇒ per-receipt vendor, order_date, order_number, grand_total, card_last4/brand, items with
qty/unit_price/line_total, suggested title. Multi-item receipts always split one entry per item;
any gap between item sum and grand total (tax/shipping) is allocated proportionally to the penny
(largest-remainder — the CollX allocation doctrine) and disclosed per line. Quantity detail
(“2 @ $15.94”) and the order number are persisted in fin_entries.description.
Reimbursable pre-set from the pay method’s default (10.3); unknown cards addable inline with an
“always reimbursable to X” answer stored on the method.
10.3 Pay-method correlation model
fin_pay_methods gained last4 (text), kind
(credit/debit/checking/cash/paypal/venmo), reimbursable_default (bool), and
owner_contributor_id. Importers correlate statement/receipt lines to methods by last4 as
data — never by name parsing. Personal methods default reimbursable to their owner; company methods
do not. Naming convention: BK CC (Kevin) - 0881.
10.4 Ledger CSV export
Ledger-view button; emits <slug>_Ledger_<scope>.csv with one row per entry
including occurrences-in-scope and total-in-scope computed by the same
occurrencesInScope() the screen uses — the export foots to the view by construction
(sidesteps the Excel export’s K-25 recurrence mismatch).
10.5 Recurring-rule semantics (doctrine D-17)
The Active checkbox was removed: no calculation ever read it. Dates are the single truth — no
end_date ⇒ running; end_date ⇒ generation stops after that month. The entry panel renders a derived
status line. The fin_entries.active column remains in the schema, inert.
Constraint note: reimbursed_state accepts none | partial | full —
“owed” is represented by reimbursable=true + state='none'.
10.6 UI doctrine (owner rulings, 8/29)
Toolbar actions are activators: fixed labels, lit while their panel is open, one panel at
a time; the exit is an in-panel terracotta Cancel ✕ (#7f3126). Importers are
modals over their surface. File pickers are real buttons, never bare inputs. Result feedback is loud
and doubled (top + bottom banners); booked = green ✓, grayed, never re-sent; failed = red, active,
plain-English reason; duplicates arrive un-ticked. Every money input carries a frozen $
(MoneyInput). All user-facing errors pass through friendlyDbError().
10.7 Realtime: the [cards-live] resolution
Months of CHANNEL_ERROR: transport failure were caused by two trailing newline
characters pasted into the Vercel VITE_SUPABASE_ANON_KEY: REST survived (fetch strips
whitespace from header values) but the realtime websocket carries the key in the URL
(%0A%0A), so the handshake was refused. Fixed by .trim() on both env values in
src/lib/supabase.js; console now logs [cards-live] SUBSCRIBED. RLS was never
involved. Rollout of live updates beyond the cards grid is now unblocked.
10.8 Vendor posture
Ximilar retired as scan vendor (subscription ended 7/31; residual credits usable). Card Hedge AI
(api.cardhedger.com, OpenAPI at /docs, X-API-Key auth, agent lane from ~$0.01/call) is the
scan vendor of record; awaiting per-image pricing and resale-license terms before the adapter ships.
Valuation remains behind a vendor-swappable interface.
S&T Card Manager — Functional Reference · Edition 4.0 · August 29, 2026. Compiled from a
full read of src/ and api/. Every behaviour described here is behaviour
that ships today; planned work is deliberately out of scope. Defect IDs (D-01 …
D-62) and guardrail IDs (G1 … G52) are stable within this
edition and safe to cite in tickets.