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-virtualuseVirtualizer in the Ledger grid, useWindowVirtualizer in 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) and scan-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), Anthropic claude-sonnet-4-6 (two routes).
Hosting
Vercel, production at bkcards71.vercel.app. Static help pages are served from /help/.
Rule — derived money is computed in the database

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 keyLabel the user seesComponentAccentGate
1collectionCollectionCollection.jsx#2dd4bf tealnone — landing tab
2insightsFinancialsInsights.jsx#8b5cf6 violetcanSeeFinance
3financesAccountingFinances.jsx#10b981 emeraldcanSeeFinance
4ledgerLedgerInventory.jsx#f59e0b ambernone
5scanScanScan.jsx#38bdf8 skynone
Note — two things called "Ledger"

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)
RolecanManagecanEditcanSeeFinanceMeaning
companyyesyesalwaysManager. BKCards71 — the entity.
partneryesyesalwaysManager. Full access, manages users.
adminyesyesalwaysManager. Full access, manages users. Also the only role that can hard-delete a card.
usernoyesonly if can_access_finance === trueCan edit cards.
viewernonoonly if can_access_finance === trueRead-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

SurfaceClient gateServer gateReal boundary
Financials / Accounting tabsTwice: the tab is filtered out of TABS, and the view is guarded at mountnone — no API route involvedRLS 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 itn/aInside 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 administrationRow actions hidden by role/statusapi/users.js re-derives MANAGER_ROLES and re-checks against the DBService-role route. Client-side hiding is cosmetic.
PSA lookup, Ximilar scan, photo attach, bring-into-appButtons disabled with a "Requires a manager account" tooltiprequireManager() in photos.js, psa.js, identify.js — bearer token → auth.getUser → partner row → role → account_statusServer. Suspended/removed callers are rejected with Account is not active.
Scan upload and Scan deletenonenoneRLS on scan_candidates and the scan-uploads bucket — the only protection. Worth auditing.
Card edit / save / bulk editcanEdit conceptually; the editor itself does not re-checknoneRLS on cards.
Card hard deleteButton rendered only for role === 'admin' on an already-archived card; handler re-tests the rolenoneRLS on cards DELETE.
Rule — the UI's checks are never trusted

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:

  1. Loading gate — while supabase.auth.getSession() is in flight, a single pulsing Loading….
  2. Recovery / invite gate — if the URL hash matches /type=(recovery|invite)/ and Supabase has minted a temporary session, <SetPasswordScreen> renders and nothing else.
  3. Unauthenticated gate — no session → <Login>.
  4. Authenticated app<SignedInApp>, a deliberately separate inner component so that useCards() 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

FromTriggerTo
manager runs inviteinvited
invitedinvitee sets a password; a manager presses Refresh (syncStatus)accepted
invited / acceptedthe user signs in and the app actually loadsactive
anysuspendsuspended (auth ban 876000h ≈ 100 years)
suspendedreactivateactive (ban 'none')
anyremoveremoved — the login is deleted, auth_user_id nulled, the accounting identity survives
user/viewer onlydeleterow 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.

Guardrail — one card array, every money screen

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.

FamilyColumnsNotes
Itemid, descriptionThe two frozen, locked grid columns. id is minted by the database on insert, never by the client. description is the Card Name.
Identitysku, player, card_year, set_name, subset, parallel, card_number, print_run, is_rookie, is_auto, is_relic, brand_id, sport_id, type_idThe 11 CDP/intake attribute columns (set_namename_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 / provenanceintake_title, intake_source, name_source, name_locked, listing_name, purchase_name, cdp_namename_sourcecanonical | 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 & locationsale_status, disposition_id, location_id, showcase, damaged, damage_note, archived, archived_at, archived_by, counts_in_financials, deleted_atsale_statusSOLD | 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 locationhome_location, show_box, show_column, show_divider, show_caseRendered as one derived string: Case {x} when show_case is set, else Bin {box, column, divider}.
Acquisitionpurchase_date, purchase_year, purchase_platform_id, pay_method_id, owned_by, paid_by, platform_purchase_order_idowned_by / paid_by / grading_paid_by are three separate FKs to partners, which is why every join must be disambiguated by constraint name.
Gradinggrader_id, grade, grade_qualifier, cert_number, psa_order_number, grading_paid_by, psa_no_imagegrade 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.
Costsbase_cost, tax, shipping, handling, grade_fee, total_costFive typed inputs, one generated total. Return costs on a refunded sale are added into handling.
Valuationcomp_as_is, exp_sale_10, asking_price, proj_profit_asis, proj_profit_10, proj_profit_saleThree typed inputs, three generated projections.
Saledate_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_returnplatform_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 / miscphoto_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.
Note — three money columns, three meanings

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
Rule — Option C: shipping margin lives inside the card's profit

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.

TableShown asManaged in Dropdowns?Extra columns
brandsBrandyes
card_typesItem Typeyes
sportsCard Typeno — bulk-editable elsewhere but not managed here
gradersGraderyes
locationsLocationyesPSA Grading is id 5, hard-coded in the PSA Return importer
purchase_platformsPurchase Platformyes
pay_methodsPay Methodyes
listing_platformsListed OnyesA row labelled none is filtered out of the editor — unchecking everything is how you say "not listed"
sold_platformsSold Platformyesfee_pct, per_order_fee — the flat fee model. FB is relabelled Facebook for display only
received_methodsProceeds ToyesDoubles as the receiver in the capital waterfall — see §8 D-40
platform_fee_tiers(Settings tab)own editorsold_platform_id, min_price, fee_pct, flat_fee. Tiers override the flat model
dispositionsSale Typedeliberately excludedcategory, 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

idMeaningWritten by
3Sold – Straight SaleMark Sold (default), Bulk Sale, eBay Sales, CollX Sales
4Sold – TradeMark Sold, second of two hard-coded options
5Sold – Sold Then Refundedan accepted pairing for an unsold card in the status/disposition coherence check
6Lost write-offWrite-off story
7Forgery write-offWrite-off story
8Ripped Off write-offWrite-off story
12For Sale – In InventoryAdd Card, status flip, Scan bring-in, eBay Purchases, PSA Return, Purchases conversion
13Not For Sale – In Inventorystatus flip
15Gifted write-offStatus → Gifted
16Destroyed write-offWrite-off story
Defect — magic numbers keyed to primary keys

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

TableShapeWritten byRules
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. sourceapp | 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. statuspending | 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. statusnew | seen | in_progress | done | declined.
purchases order/item metadata, linked_card_id, converted_card_id, match_status Admin Console → Purchases (manual matching only) match_statusunmatched | 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.

TableOrderPurpose
fin_entriesentry_date ascThe non-card general ledger. One row per cost — including a recurring rule, which is one row, never N rows.
fin_categoriessort_ordername, is_capex, is_capital_contribution, default_counts_in_books, sort_order. Drives all classification.
fin_vendorsnameWho you paid.
fin_contributorsnameDoubles as partners for distributions, splits and gifts.
fin_frequenciessort_ordername, months_interval. A falsy interval means non-recurring.
fin_pay_methodsnameCard/account used.
fin_distributionsdist_date ascdist_date, partner, amount, kind, note. kindpayout | capital_return | profit_draw.
fin_profit_splitsyear, partner_id, pct, upserted on (year, partner_id).
fin_profit_giftscard_id, amount, gift_date, note, from_partner_id. Upserted on conflict card_idone gift per card.

fin_entries columns that carry behaviour

ColumnValuesEffect
amountnumericThe unit cost — one occurrence, not the annual total.
counts_in_booksboolfalse = informational only; excluded from every total, rendered at 60% opacity with an info chip.
scopebusiness | personalpersonal 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_idnone | partial | fullOnly reimbursable and reimbursed_amount affect arithmetic. The state, the date and the payee are labels and audit trail.
start_date, end_date, frequency_iddates + FKThe recurring rule. Blank end_date = runs until stopped. Both dates are forced to null on save when the entry is not recurring.
activeboolWritten, displayed, round-tripped — and read by nothing. Unticking it changes no total. D-36.
allocated_to_cardsnumericOnly 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_idFKWhose personal expense; forced to null on save unless scope === 'personal'.
Defect — category names are logic

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.

Guardrail — the accounting identity outlives the login

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 prefixkevin* → 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

Rule — the badge is always visible, and it is driven by severity, not volume

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.

StateConditionIcon silhouetteColourText shown
settlingfirst 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.
cleanevery invariant reads zeroshield-with-checkemeraldthe word Clean
attentionincomplete, not wrongtriangle-with-!amberthe numeric total
brokena contradiction — the books are wrong right nowoctagon-with-Xredthe numeric total
unknownthe check failed, or the dispositions lookup came back emptycircle-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


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)

PredicateDefinitionWhy it is written that way
isArchivedc.archived === trueStrict — a null flag is live.
isNotForSalec.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.
inInventoryfalse if archived / sold / NFS; else c.disposition ? c.disposition.is_inventory === true : trueFalls back to true with no joined disposition, so it is safe pre-classification. NFS is explicitly excluded from for-sale inventory.
hasPhototypeof c.photo_url === 'string' && trim !== ''The BK placeholder is rendered, never stored, so it never counts. photo_back_url is ignored by this predicate.
countsInFinancialsdisposition flag, falling back to the card columnDefined, 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.

#GroupColsRule colourContents
1Item2#94a3b8id, description — both locked and frozen
2Identity21#64748bplayer, 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
3Status & Location5#0ea5e9Status pill, Location, Listed On, Showcase, Damaged
4Acquisition7#8b5cf6purchase date/year/platform, owned by, paid by, pay method, purchase order id
5Grading5#ec4899grader, grade (rendered combined, e.g. 10 Pristine), cert, PSA order #, grading paid by
6Costs6#f59e0bgrade fee, base cost, tax, shipping, handling, total cost
7Valuation6#14b8a6comp as-is, projected profit as-is, expected sale (10), projected profit (10), asking price, projected profit (sale)
8Sale11#22c55eSale Type, date/year sold, sold platform, proceeds to, order/item ids, Sold Price (gross), Net Proceeds, Net Return
9Storage Location2#6366f1Home, 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.

Guardrail — four column-level money rules
  • 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 stale net_return on an unsold card can never leak anywhere.
  • Projected Profit (Sale) never shows a number without an asking price — it renders the italic placeholder set price instead.
  • A blank asking price is deliberately coerced to 0 for sort and numeric filtering, so a 0..0 range 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
FilterCountBehaviour
Global search7 fieldsid, sku, description, player, cert, brand label, sport label. Plain substring, lower-cased, no tokenising. The placeholder advertises only five.
Status facet5All / 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 chips1 + NAvailable: {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 funnel1On 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 checklists37Distinct 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–max19Inclusive 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 archived1Rendered 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

Rule — KPIs are whole-dataset and immune to grid filters

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.

TileValueYear-scoped?
Net Return TotalΣ net_return over sold cards in the enabled years; red below zero, green aboveyes
Cards Soldcount of isSold in enabled yearsyes
Total Sold $Σ sold_price in enabled yearsyes
Inventory Countcount of inInventoryno — point in time
Money in InventoryΣ total_cost over inInventory, amberno
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

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

ButtonScopeRows
Export View CSV (teal)viewThe 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)dbIgnores 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


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.

Defect — sold-ness is defined differently here

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 appearheld 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.

Note — the dropdowns are native <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

LabelCountMoney sub-line
For Saleneither sold nor NFSΣ total_cost
Not For Salesale_status === 'NOT_FOR_SALE'Σ total_cost
Soldyear_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.

Rule — Financials is before operating expenses; Accounting is after

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

Note — only six figures on the page move when you click a year

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.

VisualPopulationObeys the year pill?
KPI tiles 1–4, 7 (Revenue, COGS, Gross Profit, ROI, Avg Days to Sell)soldFyes
KPI tiles 5, 6, 8 (Invested Capital, Unrealized P/L, Top-Card Concentration)invno
Gross Profit by Yearsoldno — a one-bar chart would be useless
Revenue vs COGS by Yearsoldno
Gross Profit by PlatformsoldFyes — the only chart that does
Units Sold Over Time · ROI Distribution · Grade Lift · Best & Worst Flipssoldno
Avg Acquisition (brand, platform) · Pipeline chips · Grade Distribution · Grading Spendliveno
Aging · Money by Sport · Dead Money · Mix pies · Price bands · Top 10 by costinvno
Data Qualitymixedno

KPI band — eight tiles

TileFormulaHow to read it
RevenueΣ sold_price over soldFSub-line gives the card count.
Cost of Goods SoldΣ total_cost over soldFBasis + grading + shipping.
Gross Profitrevenue − cogs; margin = grossProfit / revenueThe single profit figure at the cards layer.
ROIgrossProfit / cogsA lifetime-of-sold-cards ratio, not annualised — a week-long flip and a three-year hold contribute identically.
Invested CapitalΣ total_cost over invCost 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 inventoryIf 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 Sellmean of daysBetween(purchase_date, date_sold), counting only non-null values ≥ 0Cash conversion.
Top-Card Concentrationmax(total_cost over inv) / investedCapitalSingle-name risk.

The seven sections

SectionAccentContents and the notable rules
Capital HealthvioletGross 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 PerformancetealRevenue 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 DisciplineskyAverage 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 & VelocityamberAging 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 PerformanceroseFour 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 & ExposureindigoTwo 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 FlipsgreenTwo 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 QualityslateSix 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

FlagDefinitionRed whenWhat it degrades
Listed with no asking priceinventory with null-or-0 asking and at least one listing row> 0A buyer can see it on a marketplace with no price.
For sale, no asking price (unlisted)the rest of the no-asking setneverBacklog to price when you list it.
Sold, missing Date Soldsold with no date_sold> 0Avg Days to Sell, and pushes points into the bare-year buckets of Units Sold Over Time.
Sold for $0sold_price === 0> 0Revenue and margin.
$0 total costlive with total_cost === 0> 0Inflates ROI; those cards are skipped in the ROI histogram, so it undercounts.
Inventory w/o compinventory with null comp_as_isneverThe 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.

Rule — the Zone convention

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).

Defect — the year list is a literal

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}
Guardrail — never reintroduce 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.

Note — 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.

Note — a preserved React gotcha

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.

Defect — two sharp edges on inline adds

(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))

Sub-view 2 — Statements, in render order

#BlockWhat it says
1KPI tilesGross 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.
2P&L BridgeFive 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.
3Net Owed by Company → PartnerOne bar per partner, from totalOwed.
4Capital: Recovered vs Still OutStacked 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.
5Operating Expenses by CategoryDonut. Legend labels strip the exact prefix 'Operating Expense (Non-Card) – '.
6Business 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.
7Cards Only · Capital WaterfallSix rows per partner: cost advanced → ± cross-partner grading → adjusted capital advanced → − recovered → still out.
8Shipping 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)".
9Write-OffsGrouped 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.
10Non-Card Position (Ledger)Out-of-pocket unsettled + capital contributions = non-card owed.
11Total Owed (All Sources)Card capital still out + non-card owed − distributions taken = NET OWED BY COMPANY. Footnote: "These do NOT sum to zero."
12Reconciliation · Why the three don't sum to zeroThe 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.
13DistributionsViolet. + 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.
14Out-of-Pocket by Payer & OwnerThe 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.
15Loss Carried by PartnerFor personal returns.
16Partner Accounting · Tax pass-throughprofit kept − losses carried = net to personal return, sold-year basis.
17Profit GiftsJoins 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.
18Profit DistributionSliders 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.
19Tax SchedulePer-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.

Defect — the Non-Card Ledger sheet will not foot to the 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).

Rule — nothing touches inventory until a human says so

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
Guardrail — durable photos are a first-class principle

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

ActionManager required?Enforced where
Upload photosnoRLS only
Delete selected candidatesnoRLS only
Look up on PSAyesUI + api/psa.js
Scan selected (Ximilar)yesUI + api/identify.js
Bring into App (single and bulk)yesUI + 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).

Guardrail — a caller MUST fetch the full row

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

AppearanceMeaning
green border, green focus ringEditable. 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-allowedTemporarily locked — an editable field the platform's fee data is currently computing (Mark Sold's Net Sale).
sky borderThe 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.

Situationname_sourcename_lockedAlso written
Add modecanonical if generated, else usertrue unless canonicalintake_title = name, intake_source = 'app'
Edit, name changedfrom nameModetrue unless canonicala card_title_events row (best-effort)
Edit, name unchangednot writtennot 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.

Guardrail — never act on the browser's cached row (incident #1692)

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.

Guardrail — the net-integrity rule, in English

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.

Note — two different things called "gift"

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."

Rule — Damaged is a condition, not an exit

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?"

ModeLabelFinancial effect
strikeStrike the sale — it was a mistakeNo money ever moved. The sale block is cleared; the ledger takes zero penalty. Old values survive in the audit log.
returnReturned & refundedSale 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.
canceledOrder canceled & refunded — the card never shippedSale cleared. The card was never out of inventory. "This is what eBay's 'canceled' orders are — not a return."
keptWe kept the money AND got the card backThe 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."

Guardrail — always rebuild the form from what was just written (incident #809)

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

ButtonBehaviour
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 clickAll 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.

Rule — events are a RECORD, never a calculation

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 setsale_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

LayerTriggerEffect
1 · cost-basis acknowledgement amberany of base_cost, tax, shipping, handling, grade_fee is ticked and the selection contains — or might contain — a SOLD cardApply 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 redany ticked non-toggle field has nothing typed, which means it will be erasedApply is disabled until ticked: "🧨 This will ERASE values on all N cards …"
3 · the confirm dialogalwaysAn itemised Label → new value summary (selects resolve to their label, toggles to Yes/No, empties to (clear)) plus up to three stacked warnings.
Guardrail — honest counting

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.

Note — name generation is one-way

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.

Note — why 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'."

Defect — Bulk Sale is not transactional

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.

#TabKeyVisible toPurpose
1My ProfileprofileeveryoneSelf-service name edit; everything else read-only.
2UsersusersmanagerInvite, edit, suspend, remove, delete; the activity log.
3DropdownsdropdownsmanagerNine managed lookups.
4Photo ImportdatatoolsmanagerCoverage table, PSA backfill, CDP CSV photos, raw fuzzy matching.
5Data ImportimportmanagerSeven importers + Tier-1 auto-detect (§3.12).
6PurchasespurchasesmanagerManual matching of purchase records to cards; conversion to live cards.
7Integrityintegritymanager18 standing data-quality checks.
8Bulk Editbulkeditmanager§3.9.
9SettingssettingsmanagerPlatform 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.

Guardrail — archived cards are excluded from every check

"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
#KeyLabelLogicInline fix
Category 1 — Sale & financial consistency emerald
1soldmissingSold, missing detailssale_status === 'SOLD' && (sold_price == null || sold_platform_id == null || year_sold == null) — an exact day is optional; the year is what drives accountingSold $
2saleonunsoldSale data on unsold cardsale_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."
3statusdispStatus and disposition disagreeNo 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."
4soldnoorderSold with no order numberSOLD 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."
5wrongsaleyearWrong sale yeardate_sold present and year_sold missing or ≠ the date's year. "This corrupts your 2025/2026 tax buckets."
6soldzeroSold for $0 or lessSOLD with sold_price ≤ 0 — confirm gift vs. data errorSold $
Category 2 — Cost & acquisition sky
7missingcostMissing / $0 costbase_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
8badpurchasedateBad purchase dateno purchase_date, or purchase_year missing or ≠ the date's yearPurchase Date
9negmoneyNegative moneyany 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
10dupcertDuplicate certsevery live card sharing a non-blank cert — the same physical card entered more than once, which double-counts cost and inventory
11gradednocertGraded without certlooks graded but no cert, excluding cards out at the grader (location label matching /GRAD/ and not sold), where the cert is legitimately still pendingCert #
12certnotgradedCert but not gradeda cert but no grader/grade — a cert implies a graded cardGrade
Category 4 — Ownership & references amber
13missingownerpayerMissing owner or payerowned_by == null || paid_by == null — without them the partner waterfall and capital return cannot be computed— (bulk-editable from the drill-down)
14skucollisionSKU collisionslive 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
15soldbeforeboughtSold before boughtISO string compare: date_sold < purchase_date — usually a typo'd year
16futuredateFuture dateeither date after today — a future sale date lands revenue in the wrong tax year
17impossibleyearImpossible card yearcard_year blank, < 1900, or > thisYear + 1 (next year is allowed, for pre-release product)Card Year
Category 6 — Collection-facing teal
18showcasenophotoShowcased, no photoshowcase === true and no front image
Note — the tab has no severity model

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:

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.

Guardrail — promoting to a manager role forces finance access

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.

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.

Guardrail — nothing is attached until a human approves it

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.

Note — there is no "make photos local" control

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.

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).

Defect — a fully-built pane that is never rendered

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 bandButtons (left → right)modeBooks money?
Import slateGeneric CSVgenericwrites prices/dates, never a status
PSA skyPSA Grading Returnpsareturnno
eBay emeraldPurchases · Listings · Salesebaypurchases · ebaylistings · ebaysalesSales only
CollX cyanSalescollxsalesyes
CDP fuchsiaCDP Batchcdpno

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

Rule — routing, never applying

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:

3.12.2 · Generic CSV — propose → review → approve

Header: CSV Import — any file, propose → review → approve. Four stages: upload → map → review → apply.

Guardrail — the four contract promises
  • 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.

Note — a deliberate near-miss, recorded in the code

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: CERTSKUEXACTSTRONG (≥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.

Rule — Zero-Total Refund Detection

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.

Guardrail — the auto-tick rule, and the duplicate-target guard

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 onlybase_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.

Rule — the CollX money doctrine (owner-confirmed)

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-…) → certname+set+grade (IDF-weighted with a hard grade gate) → pickercard 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.

Guardrail — 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.

Guardrail — the fuzzy duplicate guard on "Not in the app"

"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


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:

RecipeHow
Record a saleLedger → open the card → Status = Sold → platform → total + net → proceeds to → Confirm.
Price a stack for a showLedger → filter to the stack → read the live filtered total in the KPI band.
Month-end eBayGear → Data Import → eBay Sales → drop the report → tick → apply.
See what the business owes youAccounting → Statements → the non-card position block.
Turn a shoebox into inventoryScan → 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."

Note — email notification is not wired

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

FieldRule
yrfirst \b(19|20)\d\d\b
grfirst whole-word hit among PSA, SGC, BGS, BVG, CGC, CSG
gradethe number following the detected grader, \d+(\.\d)?
brfirst 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
tokstoken set, length > 1, after removing serial/print runs
Note — two fixes baked into the parser

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

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.20exact number
both have a card #, one contains the other+0.10prefix/variant (WS-1SSP ⊃ 1SSP) still counts for
both have a card #, genuinely different−0.20counts 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.25a "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.15demote
Rule — threshold on 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

CallerAuto / strongWeak tierNotes
EbaySalesscore ≥ 0.5title≥ 0.34maybe, never auto-tickedafter Item # and base SKU
EbayListingsscore ≥ 0.5 → auto-ticked≥ 0.34, not auto-tickedafter base SKU; matches cards → listings
EbayPurchasesscore ≥ 0.4else the row defaults to "create new"
CdpImport duplicate guard≥ 0.55 + surname corroboration, or ≥ 0.75 when CDP has no playerblocks creation; never writes
CollXSales ranker≥ 0.62 and ≥ 0.12 clear of #2list floor > 0.12plus −0.12 for already-SOLD cards
DataImport Generic CSVdoes not use cardMatch — a local Jaccard scorer, ≥0.8 STRONG / ≥0.45 MAYBE
Admin raw photo matchingalso a local Jaccard scorer — EXACT / ≥0.80 STRONG / ≥0.45 MAYBE

4.2fileDetect.js — Tier-1 auto-detect

Rule — detection is STRICTER than the importers, never looser

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:

KeyMeaning
gatethe 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."
requirewhat the importer throws without
readsevery column it actually consumes — the denominator of coverage
disqualifycolumns the file must not have
fileHinta 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.

KindLabelRoutes toBooks?gateRowGate cells
ebay_orderseBay orders reportebaysalesyesanycustom label + order number
ebay_listingseBay active listingsebaylistingsnoanycustom label + item number
ebay_purchaseseBay purchases reportebaypurchasesnoanyitem ?name + order ?number
cdpCDP batch exportcdpnofirstcertification_number|cert number + title
collx_csvCollX orders CSVcollxsalesnofirstorder_number|order #checklist only; its prices are list prices
psa_returnPSA order CSVpsareturnnofirsta cert column + description|title
collx_order_pageCollX saved order pagecollxsalesyespage rules, below — the only source of real gross
unknownUnrecognised filenonothing 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)".

Guardrail — 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

ConstantValueWhy
SNIFF_BYTES64 KiBthe front of a text file
PAGE_SNIFF_BYTES4 MiBfar past the main document part of a Chrome .mhtml bundle (images come after)
PROBE_LINES40an eBay report front-loads preamble before its header

Saved pages (detectPage)

ConditionVerdictConfidence
page shows both "Order Number" and "Seller's Proceeds"collx_order_pagehigh, coverage 1
mentions CollX + an order number, but no proceeds linecollx_order_page with the warning "likely a partial save, or the order list rather than one order"low, coverage 0.5
otherwiseunknown — saved page carried no CollX order labelsnone

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

Rule — parse the labels a human reads, not the markup

"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.

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 agreeDisagree
raw CDP value52478
year + brand stripped279251

"i.e. 227 of the 'disagreements' were nothing but CDP's formatting."

Note — the unstripped set is retained on purpose

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

WhereRuleWhy
eBay importersx.trim().replace(/-[a-z0-9]{4,6}$/i, '')strips eBay's per-listing suffix
CDP importerupper-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
Guardrail — the parts sum exactly to the total, and the two runs stay aligned

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

Rule — upside follows ownership, downside follows the wallet
  1. COST RECOVERY → PAYER. The payer is credited min(sold_price, total_cost). They get their money back, capped at what they put in.
  2. 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.
  3. 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

FrameBasisUsed by
Business P&LSOLD-YEAR — revenue recognised at sale, IRS basisP&L block, tax schedule, partner tax, profit gifts, profit distribution
Capital waterfallPURCHASE-YEAR COHORT'2025 & before', 2026, 2027capital 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

StepWhat it does
0 · gift indexgiftByCard[card_id] = amount. A flat map — one gift per card wins; a second row for the same card overwrites the first.
1 · P&LOver 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 routingSecond 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 cohortA 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 − gradingByOthers
stillOut = adjCapitalAdvanced − recoveredByReceiver — which can go negative; that is over-recovery, not an error.
4 · non-card ledgerSkips 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 · distributionsdistTaken takes every kind; profitTaken only profit_draw; capitalTaken only capital_return.
6 · P&L totalsgrossProfit = grossSales − cogs + shipMarginTotal
netOrdinaryIncome = grossProfit − opex − writeoff
CapEx and capital contributions are deliberately excluded.
7 · company pool splitWith 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 owedtotalOwed[p] = nonCardOwed[p] + stillOut[p] − distTaken[p]; sumOwed = Σ totalOwed, which is explicitly not zero — it equals realised gain/loss.
9 · partner taxpartnerNet[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).

WhereFormat
CardEditor.buildCanonicalTitleFrom
BulkEdit.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.js
Scan.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
Defect — five implementations, three formats

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

Rule — fees are data, not code

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++
Defect — three near-identical copies

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:

  1. Strip Bearer (case-insensitive) from the Authorization header. Missing → 401 Missing bearer token.
  2. db.auth.getUser(token). Invalid → 401 Invalid or expired session.
  3. Select id, name, role, account_status from partners on auth_user_id. A DB error → 500.
  4. No partner row, or role not in ['company','partner','admin']403 Manager role required.
  5. account_status of suspended or removed403 Account 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.

ActionDoesCap
statusFour 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.
psaBatchFetches 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
attachThe 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

ConditionRecorded in psa_cert_status?Result
fetch threwno — retried next runerror
HTTP 429noquota, and the loop breaks immediately
HTTP ≠ 200yes, errorerror: PSA HTTP {status}
200, no front imageyes, no_frontno_front
200 with a frontyes, donedone, plus a best-effort back
front upload threwno — retriederror
Guardrail — PSA's free tier is ~100 calls/day, protected three ways
  1. The psa_cert_status ledger — every definitive verdict is persisted so the same cert is never re-asked; transient failures are deliberately left unrecorded so they retry.
  2. The permanent cards.psa_no_image flag — a cert PSA has no image for is marked once and never costs another call.
  3. Immediate break on 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)."

ActionAuthBehaviourCap
GET probenone{ok, name, psaTokenPresent, tokenLen}
GET ?cert=12345678noneA 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).
lookupmanager + activePer 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
backfillmanager + activeFor 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}

  1. Load the rows; filter out anything whose side is not front (reason not a front) and anything with no image. Backs never reach Ximilar.
  2. Mark all survivors status:'scanning' in one update — this is what drives the pulsing sky badge in the UI.
  3. Process in chunks of 10 with price_stats: true.
  4. Map each record back into proposed, suggested_name, confidence (the match distance — lower is better), raw, and scan_error.
  5. A non-ok response or a thrown fetch marks the whole chunk error with a message assembled from whichever of status / error / detail Ximilar 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 fieldSource
player / full_name / year / brand / set_name / subset / card_number / team / card_typebest_match.*
parallelalways null — "Ximilar folds parallel into set/sub_set; user refines in review"
grade / graderalways 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_autothe Autograph tag, false when it matches /not\s*signed/i
is_relicalways false
alternativesthe first 6
market valueprefers 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:

Guardrail — the model proposes, the app applies only what a person confirms

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.

Rule — the persona's constraints are the guardrail

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.

ActionBehaviourGuardrails
inviteauth.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".
resendRe-invites and re-stamps invite_sent_at.No email on file.
resetauth.resetPasswordForEmail — this is what produces the recovery link the app's hash-token screen catches.
suspend / reactivateSets 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.
removeDeletes 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."
deleteHard-deletes the auth user and the partner row.Three layers: self-protection; role guardOnly 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.
syncStatusWalks 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

RouteActionsAuthWritesExternalBatch cap
photosstatus · psaBatch · attachmanager + activecards, psa_cert_status, storagePSA images8
psalookup · backfillmanager + active (POST); GET ?cert= is openbackfill onlyPSA cert + images25 / 50
identifyscan · identifymanager + activescan_candidates onlyXimilar50 (chunks of 10)
psa-return(single)nonenoneAnthropic
analyst(single)nonenoneAnthropic
usersinvite · resend · reset · suspend · reactivate · remove · delete · syncStatusmanager (status unchecked)partners, auth.users, user_auditSupabase 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.

#RuleEnforced in
Money and derived values
G1Derived 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
G2Net 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
G3sold_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
G4The 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
G5Recovery is capped at cost per card, so Cost Out − Recovered = Loss.useWaterfall Rule 1; asserted in the Out-of-Pocket footnote
G6A profit gift can never exceed the card's own net profit.Editor gift modal validation; Math.min(gift, net) in the waterfall
G7net_return is suppressed everywhere for non-sold cards — render, sort, numeric filter, and CSV.Ledger column definition and csvCellText
G8Projected Profit (Sale) never shows a number without an asking price.Ledger column render → italic set price
G9Not-For-Sale is excluded from for-sale inventory and gets its own KPI tile with an owner split.inInventory; the Ledger KPI band
G10Archived 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
G11Never 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
G12Always 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
G13Snapshot before you wipe. The sale snapshot lands in both the audit row and the journey event before SALE_CLEAR runs.saleSnapshot()
G14Leaving 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
G15A 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
G16Hard 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
G17Restore is the one state change with no confirmation — it is non-destructive and reversible.CardEditor
G18Damaged 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
G19A bulk-edit field is only written if its checkbox is ticked; controls are disabled until then.BulkEditModal
G20Touching a cost column on a selection that contains — or might contain — a SOLD card requires an explicit acknowledgement before Apply enables.Layer 1
G21A ticked field with nothing typed will erase; that requires its own acknowledgement.Layer 2
G22Honest counting — unseen rows are counted as unknown, unknowns are enough to demand the acknowledgement, and the shortfall is disclosed in the confirm text.BulkEditModal
G23Bulk name generation skips every name_locked card, and the confirm names the skipped count.BulkEdit.generateNames
G24Every card in a bulk sale must have an asking price; until then the whole order form is dimmed and non-interactive.BulkSale
G25Allocated 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
G26Nothing 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
G27Sold Price / Date Sold only write to cards already marked Sold; the refusal is shown, not silent.Generic CSV
G28Two 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
G29Refunded or canceled orders book nothing, ever — excluded from the apply set and re-checked inside the loop.eBay Sales, CollX Sales
G30An 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
G31A 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"
G32A dropdown value is never created by an importer; unmatched Card Type / Brand / Grader become a picker.CDP
G33Listing links are additive only — an importer never removes one.CDP __listings
G34Detection 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
G35An 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
G36Photos 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
G37Definitive PSA verdicts are never re-asked; transient failures are; a 429 stops the batch immediately.psa_cert_status, psa_no_image, the 429 break
G38Fees are data, editable live; the sale path hardcodes no rate.sold_platforms + platform_fee_tiers
G39Managers always have finance access — the flag is forced true on both the client and the invite route.Users tab + api/users.js
G40The system can never be left without an active manager, and a manager identity can never be hard-deleted.api/users.js remove / delete
G41A partner referenced by any card cannot be deleted; the error names the count and points at removing the login instead.api/users.js delete
G42A lookup value is never deleted — only retired — so cards already using it are never orphaned.Dropdowns tab (no delete path exists)
G43A deep-linked admin section is honoured only if that tab exists for the caller's role.AdminConsole
Records and reporting
G44card_events is a record, never a calculation — nothing financial reads it, so a reversed sale stays visible forever.cardEvents.js doctrine #1
G45Events are append-only; a correction is a new event, never an edit.RLS grants select + insert only
G46Best-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
G47An integrity or health check must never fake an all-clear: an empty dispositions lookup returns unknown, and any throw returns unknown.useIntegrityPulse
G48The Ledger's footer data-integrity badge fails closed — green only on a verified exact match.Inventory.jsx
G49The full-DB CSV live-pulls from Supabase, never the client cache, and always carries the two photo-URL columns.fetchAllCardsForExport, buildCsv
G50The 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
G51Split percentages should total 100%. advisory — the amber warning is the only enforcement.Profit Distribution block
G52Fee 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

Rule — the badge watches invariants, not backlog

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.

Guardrail — an empty lookup reports "unknown", not "clean" and not "everything"

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."

TierCheckPredicateMeans
brokenresidueLIVE ∧ sale_status ≠ 'SOLD' ∧ (net_proceeds > 0 ∨ sold_price > 0 ∨ platform_sold_price > 0 ∨ date_sold ∨ sold_platform_id ∨ platform_sale_order_id) — six residue signalsA half-reversed sale: phantom revenue or a hole.
dispSoldLIVE ∧ sale_status = 'SOLD' ∧ disposition_id NOT IN soldOkReported together as the single line "Status and disposition disagree". "The exact shape a broken reversal leaves behind."
dispUnsoldLIVE ∧ sale_status ≠ 'SOLD' ∧ disposition_id IN soldOk
attentionmissingLIVE ∧ 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 partTierTab checkDifference
Sale data on a card that isn't soldbroken#2 saleonunsoldSame shape.
Status and disposition disagreebroken#3 statusdispThe badge computes it as two head-count queries and sums them; the tab computes it row-wise.
Sold, missing platform or sale yearattention#1 soldmissingNarrower: 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.

Note — this section is a feature

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

IDSevDefectImpactSuggested fix
D-01lowThe 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-02mediumTab 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-03lowThe 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-04mediumMANAGER_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-05high"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-06mediumFive 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-07lowTwo 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

IDSevDefectImpactSuggested fix
D-08highGET /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-09mediumGET /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-10highapi/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

IDSevDefectImpactSuggested fix
D-11highThe 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-12mediumThe 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-13lowUnticking 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-14lowThe 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-15mediumuseCards.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-16lowcountsInFinancials(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-17mediumDEFAULT_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-18lowshowArchived 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-19mediumUnder 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-20mediumdamaged 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-21lowYear 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-22mediumSaved 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-23lowPerformance 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-24lowFields 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

IDSevDefectImpactSuggested fix
D-25mediumThe 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-26lowA 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-29highThree 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-31mediumThe 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-32highCategory 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-33highThe 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-34mediumFor 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-35mediumThe 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-36mediumThe 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-37mediumInline 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-38highnormPartner 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-39mediumThe 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-40mediumIn 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-41mediumThe 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-42mediumIn "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-43mediumA 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-44mediumgiftByCard 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-59mediumThe 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-61mediumThe 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-62mediumNothing 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

IDSevDefectImpactSuggested fix
D-27mediumYear 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-28lowThe 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-30mediumBulk 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-47highAdmin 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-48lowBulkEdit 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-49loworder_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-50lowRemoving 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-55lowAdminConsole 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-56lowSmall 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

IDSevDefectImpactSuggested fix
D-45mediumThe 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-46lowIdentifySection (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-57lowEbayListings 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-58lowThe 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-60lowThe 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

IDSevRiskImpactSuggested fix
D-51highScan'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-52lowapi/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-53highThe 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-54lowHelp & 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.
Defect — the five to fix first

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.

TypeIconTitle shownToneSortStruckWritten by
purchased🛒Boughtsky10Add-mode save; the new card in a kept-payment re-intake; hand entry
sent_to_grading📮Sent to gradingindigo20Hand entry
graded🏅Gradedindigo30Hand entry
listed🏷️Listedslate40Hand entry
price_changed💲Asking price changedslate45Hand entry
sold💰Soldemerald50Mark Sold; Bulk Sale (source:'bulk'); eBay Sales; CollX Sales; hand entry
sale_reversed↩️Sale struck (mistake)amber55yesUn-sell → Strike
returned_refunded↩️Returned & refundedamber55yesUn-sell → Returned & refunded
reintake_kept_paid🔁Kept payment, card backamber55yesUn-sell → We kept the money (on the original card)
order_canceled🚫Order canceled & refundedamber50 missingyesUn-sell → Order canceled; eBay Sales correction path
sale_corrected🔧Sale correctedcyan50 missingno — its amount is the money that standseBay Sales correction path; hand entry
gifted🎁Giftedrose60Status → Gifted
written_offWritten offrose60Status → Write Off
damagedDamagedamber60Hand entry
found🔍Foundemerald60Hand entry
lostLostrose60Hand entry
loaned🤝Loaned outslate60Hand entry
at_show🎪At a showslate60Hand entry
archived📦Archivedpurple70The Archive button
restored♻️Restoredpurple70The Restore button
note📝Noteslate80Hand entry; CDP card creation
anything elsethe raw event_typeslate50degrades gracefully

Sources

sourceSet byBadge in the timeline
appthe default in logCardEvent — every in-app flownone
bulkBulk Salenone
importereBay Sales, CollX Sales, CDPnone
manualCard Journey hand entryadded by hand (cyan)
seedreconstructed historyreconstructed from card record (grey)

Platform labels are resolved per type: soldsold_platforms, purchasedpurchase_platforms, listedlisting_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.

ActionWritten byDetail payload
User administration — api/users.js
invitedinviterole, finance flag, target email
invite_resentresendtarget email
password_reset_sentresettarget email
suspendedsuspendtarget
reactivatedreactivatetarget
removedremoveLogin deleted; accounting identity preserved.
deleteddeleteHard deleted — no accounting references.
role_changedUsers tab inline edit{from, to, finance}
Card editing
unmark_saleUn-sell → Strike{card_id, cleared: <full sale snapshot>}
return_refund_saleUn-sell → Returned & refunded{card_id, return_cost, cleared}
order_canceled_refundUn-sell → Order canceled{card_id, cleared}
reintake_kept_paidUn-sell → Kept the money{from_card, new_card}
write_offStatus → Write Off{card_id, story, date, cost}
bulk_editBulkEditModal Apply inline insert{fields, values, count, cleared, cost_fields_touched, sold_cards_in_selection, ids}
bulk_generate_namesBulkEdit Generate names inline insert{count, skipped, ids}
Importers
csv_importGeneric CSV inline insert{file, rows, updated, failed, fields}
ebay_sales_importeBay Sales{updated, created}
ebay_listings_importeBay Listings{stamped}
ebay_purchases_importeBay Purchases{created, filled}
collx_sales_importCollX Sales{booked, orders, failed}
cdp_importCDP apply{cards, changes}
cdp_create_cardsCDP create{created, failed, photos, file}
psa_return_importPSA Return{order, updated, created}
Configuration
fees_updateSettings — platform save, tier save, tier delete, tier addthe changed values
Not audited at all

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

#AssertionWhere it is asserted
R1Σ taxSchedule[].gainLoss == pl.grossProfitThe 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.
R2grossProfit == grossSales − cogs + shipMarginuseWaterfall step 6
R3netOrdinaryIncome == grossProfit − opex − writeoffP&L block; CapEx and owner capital excluded by design
R4adjCapitalAdvanced == costAdvanced + gradingForOthers − gradingByOthersCapital Waterfall rows 1–4
R5stillOut == adjCapitalAdvanced − recoveredCapital Waterfall rows 4–6
R6Σ gradingForOthers == Σ gradingByOthersCross-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.
R7totalOwed == nonCardOwed + stillOut − distTakenTotal Owed block
R8sumOwed is explicitly not zero — it equals realised gain/lossReconciliation block
R9For every matrix cell: cost − recovered == lossOut-of-Pocket footnote (recovery is capped at cost)
R10nonCardOwed == nonCardOpexPaid + capitalContribNon-Card Position block
R11partnerNet == profitOwnKept − lossCarriedPartner Accounting block
R12profitStillOwed == pool × pct/100 − profitTakenProfit Distribution block
R13companyPool == bkOwnedProfit + Σ profitGiftedDefinitional — the pool is fed only by BK-owned profit and gifts
R14Σ splitPct == 100Not enforced — advisory warning only (D-62)
R15Profit Gifts table total == Σ rules.profitGiftedSame 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)
R16ship.margin == pl.shipMarginTwo independent computations of one term, with different sold-tests (D-59)
R17Ledger footer Complete + Front Only + Back Only + No Photos == CardsPhoto Import coverage table, mutually exclusive by construction

Cross-tab reconciliations

#AssertionCaveat
X1Financials 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).
X2Financials COGS == Accounting Cost of Goods SoldSame populations, same caveat.
X3Financials Gross Profit Accounting Gross Profit, in generalThey 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.
X4Financials Gross Profit by Year == Accounting gross profit per yearBoth are effectively Σ net_return, so these do agree — while the Financials KPI tile does not agree with its own chart (D-25).
X5Financials Grading Spend by Payer vs the waterfall's grading rowsFinancials sums all grade_fee by payer; the waterfall books only cross-partner fees. Financials will always be the larger number.
X6Financials Invested Capital vs Accounting Still OutNot 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.

FigureValue
Cards sold822 (582 / 240 across the two years)
Net Return−$4,717.42
Total Sold $$85,426.58
Cards in for-sale inventory1,125
Money in inventory$75,603.83
Live cards · archived1,958 · 15
Ledger seed — 2025 "Cost in Final Ledger"$40,538.10
Tax summary per partnerKevin $48,391.06 · Brian $20,444.30 · BK −$24,616.43

9.4Glossary

TermMeaning
Adjusted capital advancedcost advanced + grading paid for others − grading others paid for you. What a partner has really put in, once cross-partner grading is netted.
Asking priceWhat 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 SKUA 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.
CandidateA row in scan_candidates — a potential card, isolated from inventory until a human brings it in.
Canonical nameA 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).
CohortA 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 poolBK-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 overwriteAn 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 TypeUI labels that cross the underlying tables: Item Type is card_types, Card Type is sports. Renamed in the UI, not in the schema.
LiveNot archived and not soft-deleted. The default population for almost everything.
Managercompany, partner or admin. The set every privileged server route re-derives independently.
Net proceeds / Net saleWhat actually arrived after platform fees. Mirrored into sold_price on every write because the generated net_return reads that column.
Net ReturnThe card's realised profit, generated by the database: sold_price + shipping margin − total_cost.
NFS / Not For SaleA 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.
PulseThe header integrity badge and its hook. Watches four invariants, not the 18 tab checks.
ResidueSale data left on a card that is not marked sold — the signature of a half-reversed sale. One of the two broken invariants.
Shipping marginshipping_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 basisRevenue 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 outA partner's adjusted capital advanced minus what they have recovered on sales. Can legitimately go negative (over-recovery).
Tier 1The auto-detect layer. Reads a dropped file's header row and routes it to the right importer — routing, never applying.
Total costGenerated: base_cost + tax + shipping + handling + grade_fee. Return costs on a refunded sale are added into handling, so they land here.
WashA 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 DetectionThe 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 importersrc/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 YOUWEB 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 importerapi/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-01D-62) and guardrail IDs (G1G52) are stable within this edition and safe to cite in tickets.