Credit audit: five payments pointed at the wrong outlet
Our data analyst found rows in production where the outlet on a payments row is different from the outlet on the job that payment belongs to (BI question 349). The most extreme pair looked alarming: one payment tied to IKEA Alexandra whose job was at McDonald's Rivervale Mall, and a second payment with the exact mirror image.
We investigated all of it on 7 July 2026 — code, production audit trail, and the finance side with Shaun, Rhesa, Renz and Mei Chun. This post is the write-up so the whole team understands what happened, why the SOA was never wrong, and what we changed.
This is a study note for the team. The work items that came out of it are PORTAL_V2_BACKEND_GLOBAL#2626 (the data fix), PORTAL_V2_BACKEND_GLOBAL#2628 (portal guard), PORTAL_V2_BACKEND_GLOBAL#2627 (UKG sync guard), and PORTAL_V2_REPORT#155 (SOA list endpoint).
The verdict in numbers
| Question | Answer |
|---|---|
| Mismatched payments | 5, out of roughly 470k rows, since 2024 |
| What field is wrong | Only payments.location_id — the job, slot and user links all check out on every row |
| CAFs needed | 0 — a CAF would unbalance the SOA (explained below) |
| Credits at stake in the 2025–26 rows | 122.8, and they were debited from the correct company pool anyway |
| Job outlet moves in the last 18 months | 200+ — moving jobs between outlets is routine; it is only dangerous after a clock-in |
The one idea everything hangs on
When a worker clocks in, we create a payments row and copy the job's location_id onto it (app/Helpers/JodJob.php, makeInitPaymentInsertData). From that moment the payment carries a frozen photo of where the job was at clock-in time.
But a job's outlet is not frozen. Two writers can change jod_jobs.location_id after the job is posted:
- The portal. HQ and area users move jobs between outlets all the time — the audit trail shows 200+ location changes in the last 18 months. This is a normal roster workflow.
- The UKG sync. For UKG-sourced jobs (FairPrice),
ukg:update-jobrewrites the job'slocation_id— and even itscompany_id— in place when the shift moves in UKG.
Nothing re-syncs existing payments when the job moves. So if a move lands after a clock-in, the payment keeps pointing at the old outlet forever. That is the whole bug class:
Three ledgers, two readers
To understand why finance said "the SOA is correct, the dashboard is wrong", you need to know where credits live:
- Live pools —
companies.available_creditsandlocations.available_credits. The running balances. At clock-out we debit a pool, and we pick which pool usingpayments.location_id(the frozen photo). - The
creditstable — the history of top-ups, outlet assignments, returns, and adjustments. The SOA only counts rows withstatus = 1. A row withstatus = 0is invisible to the SOA and the dashboard, but any pool change it made stays. - The
paymentstable — one row per worker shift, with the frozenlocation_idand thecredit_before_transaction/credit_after_transactionsnapshots taken at clock-out.
And two readers that disagree:
| SOA export (jodgig-reports-api) | Dashboard, listings, BI 349 | |
|---|---|---|
| Attributes a shift by | the job: jod_jobs.company_id / jod_jobs.location_id, via getSOAData | the payment: payments.location_id |
| A stale payment photo is | invisible — the shift already sits under the correct outlet | visible — the shift shows under the wrong outlet |
| Verdict for the 5 rows | correct all along, which is why Rhesa and Mei Chun found it balanced | these are the surfaces that looked wrong |
One inconsistency inside the reports repo itself: the SOA list endpoint (getListSOAData) joins the outlet name and applies its location filter through payments.location_id, unlike the export. That is PORTAL_V2_REPORT#155.
What the audit trail told us
We use owen-it/laravel-auditing, and config/audit.php has console => false. That means writes from tinker, seeders, cron and queue workers leave no audit rows. The absence of an audit row is itself evidence: it tells you a console process or raw SQL did the write.
| Payment | What happened |
|---|---|
390023, 390147 (Oct 2024, the IKEA and McDonald's pair) | Manual SQL inserts during the October 2024 outage, when the team created payments by hand. Zero audit rows, one row has created_at = NULL (Eloquent never does that), and both jobs share the identical updated_at second from a hand-run batch UPDATE. The pair was crossed by hand. |
449606 (FairPrice, Oct 2025) | Born clean at clock-in at Northpoint. The UKG sync moved the job to Chinatown Point — a different co-op company — 73 seconds after a zero-hour clock-out. No audit row, because console writes are not audited. |
465807 (Moon Moon Food, Dec 2025) | Audited portal edit: the job moved from Orchard/Taka to Vivocity the day after the clock-in. |
471624 (NTUC Foodfare, Jan 2026) | Audited portal edit, and a race: the admin moved the job from Chai Chee to Henderson 54 seconds after the worker clocked in, while batch-fixing five mis-posted jobs. |
The 54-second race, step by step
Payment 471624, NTUC Nursing Home, 17 January 2026 — the clearest picture of how a routine roster fix creates the mismatch:
| Time | Event |
|---|---|
| 18:46:06 | Job 607915 posted at Chai Chee (outlet 2265) — one of five jobs posted to the wrong outlet by mistake |
| 18:49:33 | Worker applies; approved at 18:50:19 |
| 18:52:52 | Worker clocks in — payment 471624 is born and copies outlet 2265 |
| 18:53:46 | Admin moves the job to Henderson (outlet 2226) while batch-fixing jobs 607915–607919 — 54 seconds too late for this one |
| 18:56:20 | Clock-out debits 122.8 credits from company 106's pool. Both nursing homes bill to company 106, so the money landed correctly — only the label is wrong |
The invisible mover
Payment 449606, FairPrice, October 2025 — the case that shows the audit blind spot:
| Time | Event |
|---|---|
| 2 Oct 08:32 | Clock-in at FairPrice Northpoint City (outlet 1583, North co-op) — payment born clean, audited via doClockInSlot |
| 6 Oct 13:33:51 | Zero-hour clock-out — 0 credits, pools untouched |
| 6 Oct 13:35:04 | The UKG sync moves job 566014 to FairPrice Chinatown Point (outlet 1987, South co-op). No audit row exists — console writes are not audited — which is why nobody saw it for nine months |
Why we did not raise a CAF
A CAF (Credit Adjustment Form) creates a credits row with status = 1. The SOA counts those on the credit side. The SOA balances today, so a CAF would make it wrong by exactly the CAF amount. Shaun called this early and he was right.
There was also nothing to compensate: two of the modern rows moved 0 credits, and the NTUC one (122.8 credits) was debited from the company pool — both nursing homes bill to the same company, so the money landed in the correct pool. Only the label was wrong. The 2024 pair did cross-charge GoGoX and McDonald's by 90 and 70 credits (net 20), but those statements were reconciled into issued SOAs long ago. We documented it and left it; if finance ever wants a true-up, the mechanism is a hidden status = 0 / is_adjustment credits row with a comment, not a CAF.
The credit_before_transaction / credit_after_transaction columns are snapshots of whichever pool was debited at that moment. They cannot be recomputed after the fact. We do not edit history.
What we changed
- Remap the five payments so
payments.location_idequals the job's outlet — a one-off seeder with guards, #2626. The seeder turnsaudit.consoleon for itself, so the fix is recorded in theauditstable. After it runs, BI 349 shows zero rows. - Lock a job's outlet once a payment exists in the portal — #2628. Moves stay allowed before anyone clocks in, because moving jobs is a normal workflow.
- Make the UKG sync skip jobs that have payments and alert Sentry instead of silently moving them — #2627.
- Unify the SOA list endpoint with the SOA export so both attribute a shift through the job — PORTAL_V2_REPORT#155.
Lessons
- A frozen copy of a mutable value is a contract. If we snapshot
location_idonto a payment, then either the source must stop changing (lock the job's outlet after clock-in), or every writer of the source must update the snapshot. We had neither. console => falseis a forensic blind spot. The UKG mover was invisible for nine months because cron and queue writes leave no audit rows. We are considering turningAUDIT_CONSOLEon.- When two reports disagree, find the column each one reads. The whole Slack discussion — "SOA is right, dashboard is wrong, a CAF would make it worse" — collapses into one sentence: the SOA reads the job, the dashboard reads the payment.
- Put an alert on the invariant. BI 349 is exactly the check
payments.location_id = jod_jobs.location_id. A Metabase alert on it means the next drift is caught the same day, not two years later.
Evidence and code references
All paths are in jodgig-api (PORTAL_V2_BACKEND_GLOBAL) unless noted.
app/Helpers/JodJob.php:130-140— payment born at clock-in; copieslocation_idandjob_idfrom the same$slot->jobapp/Services/JodJobService.php:3368-3390— clock-out debit: pool chosen viapayments.location_id; writes the before/after snapshotsapp/Services/JodJobService.php:3500-3541— reversal path: refunds the pool and writes a "Returned credit"creditsrowapp/Services/JodJobService.php:950and:727— HQ and area portal edits can changejod_jobs.location_idon active or completed jobsapp/Jobs/UKGUpdateJob.php:145-162— UKG sync moveslocation_idandcompany_idin place; the only guard checks whether the job has endedapp/Services/CreditService.php:59-135— top-up and outlet-assign flows: bump pools and writecreditsrows withstatus = 1config/audit.php:168—console => false: tinker, seeders, cron and queue writes are never audited- jodgig-reports-api
SlotUserRepositoryEloquent::getSOAData— SOA consumption filtered viaslots.jobonjod_jobs.company_id/jod_jobs.location_id - jodgig-reports-api
CreditRepositoryEloquent::getCompanyCreditHistory— SOA credit side:credits.status = 1only; CAF rows matched bycomment LIKE '%CAFS%' - jodgig-reports-api
PaymentRepositoryEloquent.php:938-950— the SOA list endpoint keys outlet name and location filter offpayments.location_id
How each verdict was reached (the production queries run on 2026-07-07):
- Linkage check — for all 5 payments,
slots.jod_job_id = payments.job_idand theslot_userpivot user equals the payment user, so onlylocation_idis bad and the remap is safe - Payment audits — the 2025/26 rows were born clean via
doClockInSlotwith a matching location; the 2024 pair has zero audit rows and one NULLcreated_at, so they were inserted outside the app - Job audits — jobs
598447and607915were moved by portal users after clock-in (timestamped); job566014moved with no audit row, so a console process (the UKG sync) did it - Systemic scan — 200+ portal location moves in 18 months, so the guard must key on "payments exist", not forbid moves generally
- Money check —
449606and465807moved 0 credits;471624was debited from the correct company pool; the 2024 pair crossed 90 and 70 credits between GoGoX and McDonald's