Skip to main content

Credit audit: five payments pointed at the wrong outlet

· 10 min read

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.

note

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

QuestionAnswer
Mismatched payments5, out of roughly 470k rows, since 2024
What field is wrongOnly payments.location_id — the job, slot and user links all check out on every row
CAFs needed0 — a CAF would unbalance the SOA (explained below)
Credits at stake in the 2025–26 rows122.8, and they were debited from the correct company pool anyway
Job outlet moves in the last 18 months200+ — 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:

  1. 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.
  2. The UKG sync. For UKG-sourced jobs (FairPrice), ukg:update-job rewrites the job's location_id — and even its company_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 poolscompanies.available_credits and locations.available_credits. The running balances. At clock-out we debit a pool, and we pick which pool using payments.location_id (the frozen photo).
  • The credits table — the history of top-ups, outlet assignments, returns, and adjustments. The SOA only counts rows with status = 1. A row with status = 0 is invisible to the SOA and the dashboard, but any pool change it made stays.
  • The payments table — one row per worker shift, with the frozen location_id and the credit_before_transaction / credit_after_transaction snapshots taken at clock-out.

And two readers that disagree:

SOA export (jodgig-reports-api)Dashboard, listings, BI 349
Attributes a shift bythe job: jod_jobs.company_id / jod_jobs.location_id, via getSOADatathe payment: payments.location_id
A stale payment photo isinvisible — the shift already sits under the correct outletvisible — the shift shows under the wrong outlet
Verdict for the 5 rowscorrect all along, which is why Rhesa and Mei Chun found it balancedthese 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.

PaymentWhat 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:

TimeEvent
18:46:06Job 607915 posted at Chai Chee (outlet 2265) — one of five jobs posted to the wrong outlet by mistake
18:49:33Worker applies; approved at 18:50:19
18:52:52Worker clocks in — payment 471624 is born and copies outlet 2265
18:53:46Admin moves the job to Henderson (outlet 2226) while batch-fixing jobs 60791560791954 seconds too late for this one
18:56:20Clock-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:

TimeEvent
2 Oct 08:32Clock-in at FairPrice Northpoint City (outlet 1583, North co-op) — payment born clean, audited via doClockInSlot
6 Oct 13:33:51Zero-hour clock-out — 0 credits, pools untouched
6 Oct 13:35:04The 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

  1. Remap the five payments so payments.location_id equals the job's outlet — a one-off seeder with guards, #2626. The seeder turns audit.console on for itself, so the fix is recorded in the audits table. After it runs, BI 349 shows zero rows.
  2. 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.
  3. Make the UKG sync skip jobs that have payments and alert Sentry instead of silently moving them — #2627.
  4. 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_id onto 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 => false is a forensic blind spot. The UKG mover was invisible for nine months because cron and queue writes leave no audit rows. We are considering turning AUDIT_CONSOLE on.
  • 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; copies location_id and job_id from the same $slot->job
  • app/Services/JodJobService.php:3368-3390 — clock-out debit: pool chosen via payments.location_id; writes the before/after snapshots
  • app/Services/JodJobService.php:3500-3541 — reversal path: refunds the pool and writes a "Returned credit" credits row
  • app/Services/JodJobService.php:950 and :727 — HQ and area portal edits can change jod_jobs.location_id on active or completed jobs
  • app/Jobs/UKGUpdateJob.php:145-162 — UKG sync moves location_id and company_id in place; the only guard checks whether the job has ended
  • app/Services/CreditService.php:59-135 — top-up and outlet-assign flows: bump pools and write credits rows with status = 1
  • config/audit.php:168console => false: tinker, seeders, cron and queue writes are never audited
  • jodgig-reports-api SlotUserRepositoryEloquent::getSOAData — SOA consumption filtered via slots.job on jod_jobs.company_id / jod_jobs.location_id
  • jodgig-reports-api CreditRepositoryEloquent::getCompanyCreditHistory — SOA credit side: credits.status = 1 only; CAF rows matched by comment LIKE '%CAFS%'
  • jodgig-reports-api PaymentRepositoryEloquent.php:938-950 — the SOA list endpoint keys outlet name and location filter off payments.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_id and the slot_user pivot user equals the payment user, so only location_id is bad and the remap is safe
  • Payment audits — the 2025/26 rows were born clean via doClockInSlot with a matching location; the 2024 pair has zero audit rows and one NULL created_at, so they were inserted outside the app
  • Job audits — jobs 598447 and 607915 were moved by portal users after clock-in (timestamped); job 566014 moved 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 check449606 and 465807 moved 0 credits; 471624 was debited from the correct company pool; the 2024 pair crossed 90 and 70 credits between GoGoX and McDonald's