For Adi · Credit Statement Command

PR #2573 reintroduces the exact bug that issue #2562 was opened to kill

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.

All figures from jodgig_2026_clean — production dump, 10 Mar 2026 PR head 5f8e848

How credits work, and the bug we already know about

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.

Wallet A

The company pool

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.

Wallet B

A self-funded outlet pool

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.

857
HQ-funded outlets
(share a company pool)
455
Self-funded outlets
(own wallet)
315
Companies, many of them
a mix of both

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.

Two numbers every credit calculation needs

Reserved

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.

Applicable balance

available − reserved. The credits actually free to fund new jobs. This is the number the email exists to show.

The bug we already know about — issue #2562
On 4 May 2026 an Area Manager at FairPrice Hyper received the old daily reminder email stating her balance was −$41,622.68. The real figure was +$5,427. The cause: the old code subtracted every outlet's reservations — including self-funded outlets that never touch the company pool — from the company pool. It netted two separate wallets against each other. PR #2573 is part of the work to replace that email.

What PR #2573 builds

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.

Orchestrator

CreditStatementCommand.php

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

The credit math — bug lives here

CreditPoolService.php

Given a company, builds one pool entry per outlet: available, reserved, paid-14-days, applicable balance, runway. 164 lines, new.

Email sender

EmailNotificationService::sendCreditStatement()

Maps the pools into the template payload and dispatches the mail job. +103 lines.

Template

credit_statement.blade.php

One table row per outlet: Location, Available, Reserved, Paid 14 days, Balance, Runway. 286 lines, new.

The data flow inside buildPools()

Data flow when the command runs. The red box is where the bug lives; the amber box is computed and then thrown away.

credit-statement:send command
CreditPoolService::buildPools()
loadLocations()
loadReservations()
loadPayments14d()
buildLocationPools()one pool entry per outlet
computeBalance() · per outletthe bug is here
buildCompanyPool()computed, then discarded
filterPoolsForUser() → sendCreditStatement()
Bug lives here
Computed, then thrown away
Normal step

The heart of it: 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.

app/Services/CreditPoolService.php — lines 143–163
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  ✗
    }
}
Credit where it is due
The self-funded branch is exactly right. A self-funded outlet owns its wallet, so 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.

One shared pool, counted as if each outlet owned it

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.

The error in one sentence
For an HQ-funded outlet, the code returns 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.

Worked example A — FairPrice Hyper (company 78)

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.

Company-level calculation — FairPrice Hyper, the −$41k reproduced
MethodWhat it subtractsReservedResultVerdict
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.

The trap — why FP Hyper alone makes PR #2573 look fine
Because all 9 FP Hyper outlets are self-funded, PR #2573 sends every one of them through the correct branch of 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.
PR #2573 against FP Hyper — 9 self-funded outlets, all go through the correct branch
OutletOwn creditsReservedPR #2573 balanceOK?
FairPrice Xtra VivoCity70.110.0070.11
Fairprice Xtra JEM2,437.870.002,437.87
Fairprice Xtra Jurong Point0.002.85−2.85
Fairprice Xtra Hougang 110,042.1396.809,945.33
Fairprice Xtra AMK11,871.795,227.206,644.59
Fairprice Xtra Changi Business Park18,859.6817,327.201,532.48
Fairprice Xtra NEX4,838.180.004,838.18
FairPrice Xtra SportsHub8,814.53774.408,040.13
FairPrice Xtra Parkway Parade20,315.0620,256.8058.26

Worked example B — NTUC Foodfare (company 106)

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:

$70,179.51
Company pool
available_credits
− $28,844.60
Reservations from all
16 HQ-funded outlets
= $41,334.91
Correct applicable balance
(one number, all 16)

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

PR #2573 against NTUC Foodfare — 16 HQ-funded outlets, one shared pool
HQ-funded outlet Its reserved PR #2573 shows Correct Overstated by
Kopitiam T47,052.0063,127.5141,334.91+21,792.60
NTUC Nursing Home (Aljunied)5,833.0064,346.5141,334.91+23,011.60
NTUC Nursing Home (Tampines 2)5,188.3064,991.2141,334.91+23,656.30
Kopitiam Corner (Simei)3,840.0066,339.5141,334.91+25,004.60
NTUC Nursing Home (Chai Chee)3,530.5066,649.0141,334.91+25,314.10
NTUC Nursing Home (Henderson)1,842.0068,337.5141,334.91+27,002.60
NTUC Nursing Home (Queenstown)1,074.5069,105.0141,334.91+27,770.10
NTUC Health Nursing Home (Jurong)276.3069,903.2141,334.91+28,568.30
Heavenly Wang Airport Terminal 1208.0069,971.5141,334.91+28,636.60
Selarang Camp Ops0.0070,179.5141,334.91+28,844.60
NTUC Nursing Home0.0070,179.5141,334.91+28,844.60
NTUC Nursing Home (Tampines)0.0070,179.5141,334.91+28,844.60
Heavenly Wang Airport Terminal 20.0070,179.5141,334.91+28,844.60
Kopitiam Food Hall0.0070,179.5141,334.91+28,844.60
Senja Hawker Centre0.0070,179.5141,334.91+28,844.60
Cantine by Kopitiam (Jurong Point)0.0070,179.5141,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.
Why the QA screenshots did not catch it
The two screenshots in the PR were Jod Talent (a test company) and Unity Pharmacy (company 79). Unity Pharmacy has 50 self-funded outlets and exactly one HQ-funded outlet. With a single HQ-funded outlet, "one outlet's reservations" and "all HQ-funded reservations" are the same value — so the bug cannot show. The bug needs two or more HQ-funded outlets on the pool. Neither QA company had that.

Three smaller problems in the same area

WhereProblemEffect
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_days
line 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.

Compute the company pool once, the right way

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.

Company pool — computed once

available − HQ-funded reserved

companies.available_credits − SUM(reservations of every HQ-funded outlet).

All HQ-funded outlets reference this single number. Do not recompute it per outlet.

Self-funded pool — unchanged

own available − own reserved

locations.available_credits − that outlet's reservations.

PR #2573 already does this correctly. Keep the self-funded branch as it is.

The change to computeBalance()

Now — wrong for HQ-funded
Fixed
// HQ-funded branch $company = Company::find( $location->company_id); $available = $company->available_credits; // subtracts ONE outlet's // reservations from a // SHARED pool return $available - $reserved;
// Company pool computed ONCE, // before the per-outlet loop: $hqReserved = $this->jodJobRepository ->getCreditsReservedByCompanyDeduction( $company); $companyApplicable = $company->available_credits - $hqReserved; // every HQ-funded outlet row // reuses $companyApplicable
The right method already exists
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.

Verification — the fix against the same real data

Company pool, computed the correct way — both companies from section 03
CompanyPool availableHQ-funded reservedCorrect applicableMatches 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

What this means for the email

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.

Target layout — NTUC Foodfare HQ-funded outlets, one shared pool, one balance
OutletCredit ownershipBalanceEst. days before next top up
Kopitiam T4 HQ pool 41,334.91 computed once
Kopitiam Corner (Simei)
… + 14 more HQ-funded outlets
Summary for the rebuild
1. Keep the self-funded branch — it is correct. 2. Compute the company pool once per company: 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.