The new credit statement email gets the math right for self-funded outlets — that part is correct. But for HQ-funded outlets it repeats the same mistake that produced the −$41,622.68 email a FairPrice Area Manager received on 4 May. This page walks through it with real numbers from the production database, then shows the fix.
jodgig_2026_clean — production dump, 10 Mar 2026
PR head 5f8e848
Before reading the code, hold this mental model. Two kinds of wallet hold credits, and one flag on each outlet decides which wallet its jobs spend from.
One column: companies.available_credits. A single shared wallet for the whole company.
Every outlet with job_credit_deduction = 0 spends from this one pool. We call those HQ-funded outlets.
One column: locations.available_credits. Each outlet owns its own wallet.
An outlet with job_credit_deduction = 1 spends only from its own balance. We call those self-funded outlets.
Counts from the dump: SELECT job_credit_deduction, COUNT(*) FROM locations WHERE status = 1.
Most outlets are HQ-funded — the path the bug lives in is the common path, not the edge case.
SUM(jod_jobs.total_job_salary) for jobs whose status is 1 (opening) or 2 (active). Credits committed to jobs that have not finished yet.
available − reserved. The credits actually free to fund new jobs. This is the number the email exists to show.
Four files. A command that loops companies, a service that does the credit math, an email sender, and a Blade template. The data flow below is the part that matters.
CreditStatementCommand.phpLoops every enabled company, asks the service for credit pools, filters them per recipient by user_type, hands off to the email sender. 371 lines, new.
CreditPoolService.phpGiven a company, builds one pool entry per outlet: available, reserved, paid-14-days, applicable balance, runway. 164 lines, new.
EmailNotificationService::sendCreditStatement()Maps the pools into the template payload and dispatches the mail job. +103 lines.
credit_statement.blade.phpOne table row per outlet: Location, Available, Reserved, Paid 14 days, Balance, Runway. 286 lines, new.
buildPools()Data flow when the command runs. The red box is where the bug lives; the amber box is computed and then thrown away.
computeBalance()Every outlet's "Balance" column comes from this one method. It has two branches — one for self-funded outlets, one for HQ-funded outlets.
protected function computeBalance(int $locationId, bool $isLocationFunded, float $reserved): float
{
$location = Location::find($locationId);
if (!$location) { return 0.0; }
if ($isLocationFunded) {
// SELF-FUNDED outlet — this branch is CORRECT
$availableCredits = $location->available_credits;
return $availableCredits - $reserved; // outlet wallet − its own reservations ✓
} else {
// HQ-FUNDED outlet — this branch is WRONG
$company = Company::find($location->company_id);
if (!$company) { return 0.0; }
$availableCredits = $company->available_credits;
return $availableCredits - $reserved; // company pool − ONE outlet's reservations ✗
}
}
location.available_credits − its own reserved is the correct applicable balance.
The structure of buildPools() — batch-load, then compute in memory — is also sound.
The problem is one branch, and it is the branch most outlets go through.
The $reserved value handed to computeBalance() is one outlet's
reservations — loadReservations() groups the sum by location_id.
For a self-funded outlet that is right. For an HQ-funded outlet it is wrong, because the
company pool is shared by many outlets, and one outlet's reservations are not the
reservation load on the pool.
company.available_credits − (this one outlet's reservations).
The correct value is company.available_credits − (reservations of all HQ-funded outlets on the pool).
It subtracts the wrong set — same mistake as the −$41k bug, pointing the other way.
First, the original bug, reproduced against the dump. FP Hyper's company pool holds $5,427.18. Its 9 enabled outlets are all self-funded.
| Method | What it subtracts | Reserved | Result | Verdict |
|---|---|---|---|---|
| Old reminder getCreditsReservedByCompany |
Every outlet's reservations, self-funded included | 43,685.25 | −38,258.07 | wrong |
| Correct method getCreditsReservedByCompanyDeduction |
Only HQ-funded outlets' reservations | 0.00 | +5,427.18 | correct |
The $43,685.25 matches issue #2562 to the cent. The dump gives −$38,258.07;
the AM saw −$41,622.68 on 4 May because live reservations had grown since this
10 March snapshot — same formula, later day. FP Hyper has zero HQ-funded
outlets, so the correct method subtracts $0 and lands on +$5,427.18.
computeBalance(). Per-outlet balances come out right
(see below). If you only test against FairPrice Hyper, the PR looks correct —
the bug is invisible until a company has two or more HQ-funded outlets.
| Outlet | Own credits | Reserved | PR #2573 balance | OK? |
|---|---|---|---|---|
| FairPrice Xtra VivoCity | 70.11 | 0.00 | 70.11 | ✓ |
| Fairprice Xtra JEM | 2,437.87 | 0.00 | 2,437.87 | ✓ |
| Fairprice Xtra Jurong Point | 0.00 | 2.85 | −2.85 | ✓ |
| Fairprice Xtra Hougang 1 | 10,042.13 | 96.80 | 9,945.33 | ✓ |
| Fairprice Xtra AMK | 11,871.79 | 5,227.20 | 6,644.59 | ✓ |
| Fairprice Xtra Changi Business Park | 18,859.68 | 17,327.20 | 1,532.48 | ✓ |
| Fairprice Xtra NEX | 4,838.18 | 0.00 | 4,838.18 | ✓ |
| FairPrice Xtra SportsHub | 8,814.53 | 774.40 | 8,040.13 | ✓ |
| FairPrice Xtra Parkway Parade | 20,315.06 | 20,256.80 | 58.26 | ✓ |
This is a mixed company: 15 self-funded outlets and 16 HQ-funded outlets. The company pool holds $70,179.51. Total reservations across the 16 HQ-funded outlets are $28,844.60, so the one true applicable balance for that pool is:
All 16 HQ-funded outlets share that pool, so all 16 rows should show $41,334.91.
Here is what PR #2573's computeBalance() actually produces —
$70,179.51 − (that one outlet's reservations):
| HQ-funded outlet | Its reserved | PR #2573 shows | Correct | Overstated by |
|---|---|---|---|---|
| Kopitiam T4 | 7,052.00 | 63,127.51 | 41,334.91 | +21,792.60 |
| NTUC Nursing Home (Aljunied) | 5,833.00 | 64,346.51 | 41,334.91 | +23,011.60 |
| NTUC Nursing Home (Tampines 2) | 5,188.30 | 64,991.21 | 41,334.91 | +23,656.30 |
| Kopitiam Corner (Simei) | 3,840.00 | 66,339.51 | 41,334.91 | +25,004.60 |
| NTUC Nursing Home (Chai Chee) | 3,530.50 | 66,649.01 | 41,334.91 | +25,314.10 |
| NTUC Nursing Home (Henderson) | 1,842.00 | 68,337.51 | 41,334.91 | +27,002.60 |
| NTUC Nursing Home (Queenstown) | 1,074.50 | 69,105.01 | 41,334.91 | +27,770.10 |
| NTUC Health Nursing Home (Jurong) | 276.30 | 69,903.21 | 41,334.91 | +28,568.30 |
| Heavenly Wang Airport Terminal 1 | 208.00 | 69,971.51 | 41,334.91 | +28,636.60 |
| Selarang Camp Ops | 0.00 | 70,179.51 | 41,334.91 | +28,844.60 |
| NTUC Nursing Home | 0.00 | 70,179.51 | 41,334.91 | +28,844.60 |
| NTUC Nursing Home (Tampines) | 0.00 | 70,179.51 | 41,334.91 | +28,844.60 |
| Heavenly Wang Airport Terminal 2 | 0.00 | 70,179.51 | 41,334.91 | +28,844.60 |
| Kopitiam Food Hall | 0.00 | 70,179.51 | 41,334.91 | +28,844.60 |
| Senja Hawker Centre | 0.00 | 70,179.51 | 41,334.91 | +28,844.60 |
| Cantine by Kopitiam (Jurong Point) | 0.00 | 70,179.51 | 41,334.91 | +28,844.60 |
| 16 outlets, one pool — PR #2573 prints 10 different balances; every one is too high. The pool is overdrawn-safe at $41,334.91, but the email tells managers there is up to $70,179.51 free. | ||||
| Where | Problem | Effect |
|---|---|---|
buildCompanyPool()lines 124–140 |
Sums applicable_balance across every pool — self-funded and HQ-funded mixed together — then processCompany() never reads the result. |
wrong + dead code |
runway_daysline 116 |
burn14d > 0 ? floor(...) : 0 — an outlet with no spend in 14 days gets runway 0, not "no data". |
misleading "0" |
| Email template credit_statement.blade.php:151 |
Prints Company Balance: {{ company.available_credits }} — the raw company wallet — to every recipient, including Area and Location managers. |
wrong scope |
The runway "0" is visible in the PR's own Area Manager screenshot: outlet "JP Kitchen" shows Balance 16,060.22 next to Runway 0, because Paid 14 days was 0.00.
The model from issue #2562: a company has one company pool plus N self-funded outlet pools. Compute each pool's applicable balance once, against the reservations that actually draw from it.
companies.available_credits − SUM(reservations of every HQ-funded outlet).
All HQ-funded outlets reference this single number. Do not recompute it per outlet.
locations.available_credits − that outlet's reservations.
PR #2573 already does this correctly. Keep the self-funded branch as it is.
computeBalance()JodJobRepositoryEloquent::getCreditsReservedByCompanyDeduction() —
line 3059 — already sums reservations WHERE locations.job_credit_deduction = 0.
That is exactly the HQ-funded reservation total. No new query needed; call the method that
is already there.
| Company | Pool available | HQ-funded reserved | Correct applicable | Matches expected? |
|---|---|---|---|---|
| FairPrice Hyper (78) | 5,427.18 | 0.00 | +5,427.18 | ✓ issue #2562 says +$5,427 |
| NTUC Foodfare (106) | 70,179.51 | 28,844.60 | +41,334.91 | ✓ one number for all 16 outlets |
Once the pool is one number, the table stops repeating it. HQ-funded outlets that share a pool collapse into one merged row; self-funded outlets keep a row each. This is the layout already being agreed in the design discussion — the merged-cell table — and it depends on the math above being correct first.
| Outlet | Credit ownership | Balance | Est. days before next top up |
|---|---|---|---|
| Kopitiam T4 | HQ pool | 41,334.91 | computed once |
| Kopitiam Corner (Simei) | |||
| … + 14 more HQ-funded outlets |
available_credits − getCreditsReservedByCompanyDeduction(); every HQ-funded
outlet reuses it.
3. Fix or delete buildCompanyPool() — right now it is wrong and unused.
4. Render runway as "no data" when there is no 14-day spend, not 0.
5. Drop the company-wide balance line from Area and Location manager emails.
6. Test against a company with 2+ HQ-funded outlets — NTUC Foodfare or McDonald's — not just FP Hyper.