joecastelino

tekion-day-collection-report

Pull Tekion's DAY COLLECTION report (cashier collections / money actually collected per day, by payment mode + department + cashier) for any AMG store and any date(s) via the internal cashier API, then render a per-day branded PDF and email it to Joe. Replaces reading the Day Collection screen by hand.

joecastelino 2 1 Updated 1mo ago
GitHub

Install

npx skillscat add joecastelino/jay-skill-pack/tekion-day-collection-report

Install via the SkillsCat registry.

SKILL.md

Tekion Day Collection Report (API + PDF)

What it answers: "What did we collect on ?" — total closing amount,
split by payment mode (card / cash / check / cashier's check / charge / other),
by department (Service / Parts / Vehicle Sales), by asset type (RO / Parts Sale /
Vehicle Sale), by card brand, by cashier ("Received by"), plus refunds/reversals
and full transaction detail.

The Tekion UI screen is DC Day Collection → /ro/invoices/day-collection
(App Grid, Services group). One day at a time, no export worth using — so pull it
via API.

THE ENDPOINTS (internal, cracked 2026-08-31 at SCT)

All POST, all in-page from an authenticated persistent browser (?allCashiers=true):

Endpoint Purpose
/api/cashier/u/api/transactions?allCashiers=true the transaction rows (paginate pageInfo.start/rows)
/api/cashier/u/api/summaryByType?allCashiers=true tender-type totals — use to reconcile
/api/cashier/u/api/summaryByCard?allCashiers=true card-brand totals (visa/mc/amex/discover)
/api/lookup/ids body {"TENANT_USER_MINIMALV2":{"ids":[...]}} cashierId → display name

Request body shape (transactions):

{"sort":[{"key":"sortKey","field":"invoiceNumber","order":"DESC"}],
 "pageInfo":{"start":0,"rows":50},
 "filters":[{"field":"status","key":"status","operator":"NIN","values":[
   "CREATED","IN_PROGRESS","FAILED","PENDING","VOIDED","CANCELLED",
   "WAITING_FOR_SWIPE","EXPIRED","REQUESTED","PENDING_APPROVAL","REJECTED"]}],
 "date": <epoch_ms local midnight>, "endDate": <epoch_ms 23:59:59.999>,
 "cashierIds":[]}

summaryByType / summaryByCard take the same date/endDate/filters (no sort/pageInfo).

⚠️ THE MONEY-FIELD TRAP (cost 3 debug rounds — read this)

Each row has THREE amount fields:

  • amount.amount ✅ USE THIS — cents, already signed for refunds.
  • effectiveTransactionAmount.amount ❌ refunds come back positive here, and
    partial-capture rows differ (e.g. 332878: amount 53.86 vs effective 9.79).
  • dueAmountBeforeTransaction — informational only.

Only amount.amount reconciles exactly to Tekion's own summaryByType
totals. My first four sign-handling theories (refund flag, refundId,
neg-if-positive) all mismatched by $44–$2,100/day; plain amount.amount matched
to the cent on all 3 days. All amounts are CENTS.

refund:true rows already carry a negative amount — do NOT negate again.

RUN IT

# 1) pull (browser must be logged in AND already on the target dealer)
python3 /home/itadmin/tekion-reports/day_collection_pull.py \
  --port 9225 --dealer 876 \
  --dates 2026-08-28,2026-08-29,2026-08-30 \
  --out /tmp/sct_daycoll.json --names /tmp/sct_names.json

# 2) render one PDF per day
cd /home/itadmin/tekion-reports && python3 render_day_collection.py ST 'Stevens Creek Toyota'
# -> out/ST-DayCollection-Fri_8-28.pdf etc.

# 3) email (jay_mail.send_report; SMTP + positively verified)

day_collection_pull.py asserts currentActiveDealerId == --dealer before
pulling (guards against the silent wrong-store read) and prints
RECONCILED / MISMATCH per day against summaryByType. Never ship the report
without a RECONCILED line.

Dealer IDs: AR=6195, BC=1251, BT=1249, ST/SCT=876, SV=826, TL=1092, VC=1891.

GETTING AN AUTHENTICATED PORT

The 1:16AM cron-tekion.sh Caliber scraper owns :9223 for ~12h and drifts the
dealer — pgrep -af 'cron-tekion.sh|run-scraper' first. If it's live, use :9225
and clone the live session over (fastest re-auth, no OTP):

ls = eval9223("JSON.stringify(Object.fromEntries(Object.entries(localStorage)))")
inject = {k:v for k,v in json.loads(ls).items() if not k.startswith("amplitude")}  # amplitude_* is ~5MB -> 413
post9225("/navigate", {"url":"https://app.tekioncloud.com/login"})   # establish origin
for k,v in inject.items(): post9225("/eval", {"js": f"localStorage.setItem({json.dumps(k)},{json.dumps(v)});'ok'"})
post9225("/navigate", {"url":"https://app.tekioncloud.com/home"})

Then UI-switch dealer: /mouse the dealer pill at ~(1130,32), wait 3s,
scrollIntoView the [class*="root_dealerInfoItem_container"] row, re-read its
rect, /mouse the fresh center, wait ~10s, verify currentActiveDealerId flipped.

HOW THE ENDPOINTS WERE FOUND (reusable recon)

Bare in-page fetch('/api/...') normally 500s "Token doesn't exist" — but for
these cashier endpoints a hand-built header set works, because they only need
the standard header block:

{'tekion-api-token': localStorage.t_token, 'roleId': currentActiveRoleId,
 'userId': __user_id, 'tenantname':'americanmotorscorporation',
 'dealerId': currentActiveDealerId, 'tek-siteId': currentActiveSiteId,
 'clientId':'web','locale':'en_US','program':'DEFAULT',
 'applicationId':'ARC_NA','subApplicationId':'US','productIds':'ARC'}

Discovery path: arm an XHR hook (also capture setRequestHeader into
this.__hdr — that's how you learn the required header set), then click the
page's own Reset button to refire the search, then read window.__H.
Fetch-only hooks MISS these — they're XHR.

/eval truncates ~20000 chars → stash the response in window.__R and pull it
back in 15000-char slices.

RE-SUMMARIZING FROM THE SAVED JSON (⚠️ shape trap)

Once day_collection_pull.py has written /tmp/<store>_daycoll.json, the saved
byType is a dict, NOT the list-of-buckets the live API returns. The
reconciliation comprehension in this skill only works on the live response:

# LIVE response only:
{b["key"].upper(): b["projections"]["sumByTenderType"]/100 for b in v["byType"]}
# From the SAVED file this raises: TypeError: string indices must be integers

To re-summarize from the saved file (e.g. Joe asks for the numbers again in chat),
sum the rows instead — same answer, no shape assumptions:

import json
from collections import defaultdict
d = json.load(open('/tmp/sct_daycoll.json'))
for k, v in d.items():
    rows = v['rows']
    tot = sum(r['amount']['amount'] for r in rows)/100
    fo  = sum(r['amount']['amount'] for r in rows
              if r.get('departmentType') in ('SERVICE','PARTS'))/100
    vs  = sum(r['amount']['amount'] for r in rows
              if r.get('departmentType') == 'VEHICLE_SALES')/100
    bt = defaultdict(float)
    for r in rows:
        bt[r.get('tenderType') or r.get('paymentMode') or '?'] += r['amount']['amount']/100
    print(k, len(rows), tot, fo, vs, dict(bt))

THE ANSWER SHAPE JOE WANTS

PDFs go by email (Slack can't take PDFs); post page-1 PNGs to Slack with
MEDIA: lines alongside a table. Give him, per day: Txns · Total Collected ·
Fixed Ops (Svc+Parts) · Vehicle Sales, a 3-day total row, then the tender
breakdown per day. Always call out that vehicle sales dominate (~90% at SCT) and
offer the fixed-ops-only rerun + a daily cron — don't wait to be asked.

PITFALLS

  • 216 of 273 Fri rows had assetNumber: null (counter parts sales) — fall back to
    invoiceNumber for the display invoice #.
  • Customer name: searchDetail.customerName, fall back to payeeDetails.name.
  • Times are epoch ms; render in Pacific.
  • departmentType is the useful grouping (SERVICE/PARTS/VEHICLE_SALES);
    departmentId is unresolvable (DEPARTMENT is not a valid /api/lookup/ids key,
    and /api/dealer/u/departments 404s).
  • Day Collection includes Vehicle Sales — it dwarfs fixed ops (SCT 8/28:
    $344K of $404K). If Joe wants fixed-ops-only collections, filter
    departmentType IN (SERVICE, PARTS) and say so on the report.
  • Report logo: SCT is the only store with a real logo asset (logo_st.png);
    everything else gets a text wordmark. logo_0.png is ALSO Stevens Creek Toyota
    despite the generic name
    — never use it for another store. vision_analyze the
    first page before emailing.
  • No pdftoppm/pdf2image on this box; QA a PDF via
    python3 /home/itadmin/tekion-reports/_png.py <pdf> (PyMuPDF → /tmp/dcpN.png),
    then vision_analyze. Plain import fitz inside a terminal heredoc can blow up
    on inspect.signature — run it from a file, not a heredoc.
  • _png.py overwrites /tmp/dcpN.png on every run. If you want a page-1
    thumbnail per day for Slack, copy dcp0.png to a distinct name
    (/tmp/dc_fri.png etc.) immediately after each render, before the next PDF.
  • Jay's memory store is at its char cap — none of this lives in memory.
    This skill is the ONLY record of the Day Collection method. Don't assume a
    memory entry exists; load this skill.

FILES

  • /home/itadmin/tekion-reports/day_collection_pull.py — pull + reconcile
  • /home/itadmin/tekion-reports/render_day_collection.py — per-day PDF (landscape letter)
  • /home/itadmin/tekion-reports/_png.py — PDF→PNG for vision QA

Related

tekion-standard-reports-performance, tekion-part-sales-ledger-report,
persistent-browser-server, jay-gmail-draft-verification

Categories