Appearance
Accounting
Every dollar the app reports comes out of one double-entry ledger. A work order, a purchase order and a payment are not accounting objects themselves — each of them produces a Transaction whose transaction_paths debit and credit the shop's chart of accounts. The nineteen report pages and the twelve dashboard KPIs read that ledger and nothing else, which is why they reconcile.
The data model
Five tables, all scoped to a shop.
| Table | What one row is |
|---|---|
chart_of_accounts | One account: code, type, translated name/description. Unique on (shop_id, code). |
transactions | One accounting document: type, number, amount (cents), transaction_date, morph to transactable, nullable period_id. |
transaction_paths | One leg of a transaction: chart_of_account_id, type (debit/credit), amount, relative_amount, and on a payment's tender legs the nullable payment_method_id. |
periods | One closed accounting period: from_date (exclusive), to_date, closed_at. |
daily_closings | One closed business day: business_date (unique per shop), expected_cash / counted_cash (cents), notes, snapshot (jsonb), closed_at. |
transactions.number auto-increments per shop through Model::$incrementingColumn. amount is a bigint of cents behind the Money cast, like every monetary column in the app.
relative_amount is the whole reporting story
TransactionPath::boot() writes it on every save:
php
// app/Models/TransactionPath.php
$path->relative_amount = $path->chartOfAccount->type->relativeAmount($path);ChartOfAccountType::relativeAmount() returns +amount when the path moves the account in its natural direction (debit on an asset/COGS/expense, credit on a liability/equity/revenue) and -amount otherwise. That turns every ledger aggregate into a plain SUM(relative_amount) — no per-account-type CASE in report queries, and a signed figure whose meaning does not depend on which column it landed in.
It is a stored column, not a computed one. A path written by a raw query or a mass update() that bypasses the model event ships a stale or missing relative_amount, and every report silently disagrees with the trial balance. Write paths through the model. :::
Writing to the ledger
App\Services\Transactions\TransactionService (reached through TransactionFacade) is the only thing that creates transactions and paths. It has three entry points:
| Method | Called by | Produces |
|---|---|---|
save(Transactable $t) | AddAccountingWorkOrder, AddAccountingPurchaseOrder | INVOICE / CREDIT_NOTE for a WorkOrder, PURCHASE / REFUND for a PurchaseOrder |
applyPayment($transaction, $payments, $date) | AddPaymentToWorkOrder | a PAYMENT transaction morphed onto the document's transaction |
getNextInvoiceNumber($workOrder) | AddAccountingWorkOrder | the shop's next invoice_number |
applyPayment() writes one tender path per payment method, carrying its payment_method_id, plus one settlement path against receivables (or payables). It deliberately bypasses syncPaths(), which keys paths by account: the seeded Credit Card and Debit Card both post to 1010, and merging them would lose the split the daily closing reports. The ledger balance is the same either way. Payments recorded before the column existed have payment_method_id = null and cannot be backfilled where two methods share an account.
Transactable (shop(), transaction(), products()) is implemented by WorkOrder and PurchaseOrder; save() throws InvalidArgumentException for anything else.
Where it is triggered from
BillWorkOrder— the billing flow: sets status toBILLED, then runsSaveInventory,ProcessWorkOrderStorage,AddAccountingWorkOrderandAddPaymentToWorkOrderinside oneDB::transaction.ProcessPurchaseOrder— runsSaveInventorythenAddAccountingPurchaseOrder.UpdateTransactionInventory— listens forStockMutationUpdatedand, when a stock mutation'sunit_pricechanged on a work order line, re-runsAddAccountingWorkOrderthroughDebounceJobActionwith a 5-second debounce. Cost changes reach the ledger after the fact, so COGS is restated rather than left at the old price.SyncBilledWorkOrderLaborCost— listens forTimeEntrySavedand re-books an already-invoiced work order when a technician closes an entry on it, so hours clocked after billing still cost something. It goes through the sameDebounceJobAction/AddAccountingWorkOrderpair as above, keyed on the work order id:CompleteJobandStartJobInBayboth stop several timers in a loop, and without the debounce each punch would rebuild the same ledger entry. It skips a work order whose invoice date sits in a closed period:guardAgainstClosedPeriod()would refuse the write, and a punch-out is the wrong place to raise that.
save() is idempotent per document: persistTransaction() does a firstOrNew on (transactable_type, transactable_id), and syncPaths() upserts the paths it built and deletes every path on an account no longer in the payload. Editing an invoiced work order re-books it in place.
transactions.amount is the document total, not the sum of debits
php
// app/Services/Transactions/TransactionService.php
'amount' => abs($this->resolveTotal($transactable)),It is what the counterparty owes. Summing the debit paths would fold in internal entries such as COGS, and UpdateTransactablePaymentStatus — which marks a document paid when payments equal amount — could then never settle it.
What gets booked
Work order (buildWorkOrderPathPayloads), per line, using the accounts on the line's product Category:
category->revenue← line total minus env fee and core chargecategory->envFee← environmental fee × quantitycategory->corePayable← core charge × quantity- for each stock mutation:
category->inventorycredited andcategory->cogsdebited byquantity × unit_price - the shop's
LABOR_COSTaccount debited andWAGES_PAYABLEcredited by the hours clocked on the work order's lines priced at each technician'shourly_rate(addLaborCostPaths()). Labour consumes no stock, so this is the only cost a service ever carries. Both accounts fall back to their seeded codes (5050,2200), and a shop that resolves neither books no labour cost rather than posting a debit its residual plug would bury in the tax account.TimeEntry::costis the single definition of that amount: the ledger books it here,LaborReportServicereports it, and thesales-by-technicianreport charges it to the technician — so none of the three can drift from the others. - the shop's
RECEIVABLESaccount ← the document total - one path per configured tax (see below)
Lines whose service_status is cancelled — and children of a cancelled parent — are skipped, because they are already excluded from the work order total.
Purchase order (buildPurchaseOrderPathPayloads) mirrors it: cogs and envCost per line, the same inventory/COGS pair per stock mutation, the shop's PAYABLES account for the total, and taxes with a -1 sign because tax paid on a purchase reduces the tax payable balance.
Payment (buildPaymentPathPayloads) has no lines. Each entry in the payments array maps a PaymentMethod to its chart_of_account_id, and the opposite leg lands on the settlement account. Both signs are derived from the settled transaction's type:
| Settled type | Settlement account | Payment-method sign | Settlement sign |
|---|---|---|---|
INVOICE | receivables | +1 | -1 |
CREDIT_NOTE | receivables | -1 | +1 |
PURCHASE | payables | -1 | -1 |
REFUND | payables | +1 | +1 |
Any other type throws. The payment's amount is what was tendered, taken from the payments array rather than re-derived from the paths.
determinePathType() turns a signed amount into debit or credit: an asset/COGS/expense account debits on a positive amount, a liability/equity/revenue account credits, and a negative amount flips it.
The rounding correction
transaction_paths.amount stores whole cents, but the amounts feeding it are not whole: a tax is rate × subtotal. Each path rounds on its own way into the column while the receivable side carries the document's already-rounded total, so the two sides can land a cent apart.
balanceEntries() snaps every entry onto its stored cent, measures the residual in cents, and pushes it onto the shop's TAXES account — the tax split is what produced it. The correction is applied in cents, not by adding a fraction of a dollar, because a float sitting on a half-cent boundary does not shift by the amount added to it.
Where accounts come from
Three layers, all per shop:
shops.ledger_settings— aLedgerSettingsvalue object (JSON column viaLedgerSettingsCast) mapping eachChartOfAccountSettingTypecase (cash,bank,receivables,deposits,payables,labor,labor_cost,wages_payable,parts,purchases,taxes) to achart_of_accounts.id.getLedgerAccount()throwsInvalidArgumentExceptionwhen a mapping is missing — an unmapped receivables account makes billing fail loudly.categories— six account columns per product category, exposed as therevenue,cogs,corePayable,envFee,envCostandinventoryrelations. A line whose product has no category is skipped entirely.payment_methods— each method carries thechart_of_account_idmoney lands on.
Taxes are the exception to (1): addTaxPaths() books each tax on the document to the account carried on the tax itself, so GST and QST report separately. A tax with no account falls back to the shared TAXES mapping.
SeedChartOfAccounts gives a shop the standard independent-garage chart — assets 1xxx, liabilities 2xxx, equity 3xxx, revenue 4xxx, COGS 5xxx, expenses 6xxx — and points all eleven mappings at it. It is idempotent (a shop with any account keeps what it has) and runs both from shop onboarding (SaveShopIdentity) and from ChartOfAccountSeeder.
UpdateAccountingSettings (PUT /settings/accounting) edits the mappings and accounting_settings.due_in_days, which is what AccountingSettings::calculateDueDate() uses to age invoices (default 30).
Periods and closing
A Period is a closed range. from_date is the previous period's to_date and is therefore exclusive; it is null for the first close, which covers everything up to to_date.
ClosePeriod(POST /settings/periods/close) creates the period and stampsperiod_idon every unassigned transaction inside the range.ReopenPeriod(DELETE /settings/periods/{period}) nulls the transactions'period_idand deletes the period. Only the most recent close can be reopened — reopening a mid-range period would leave a gap in the closed history — anything else aborts with 422.Period::closedFrontier($shopId)is the latest closedto_date. Every write path callsguardAgainstClosedPeriod(), which throws aValidationExceptionontransaction_datewhen the date falls on or before the frontier. That covers bothpersistTransaction()andapplyPayment(), so a late payment against a closed month is refused rather than silently reopening it.
Periods reach the front end as the shared periods Inertia prop (SharedPropsServiceProvider::getPeriods(), cached 5 hours under periods.{shop}, busted by ClearCacheOnPeriodSaved). Report actions opt in with ->withSharedProps(['periods']).
Daily closing
End-of-day cash balancing, at /{shop}/reports/daily-closing (ReportType::DAILY_CLOSING). One business day, not a period: it takes ?date=Y-m-d instead of the period filter, through Concerns\InteractsWithBusinessDay.
DailyClosingReportService::report($shop, $date)builds aDailyClosingReportData:- Tenders come from
LedgerReportService::workOrderTenders(): the day'sPAYMENTpaths whose parent transaction belongs to a work order, excluding the receivables leg. They are grouped bypayment_method_id; legacy paths fall back to one "unassigned" row per account. Purchase-order payments never reach the drawer. - Every active method is listed, even at zero. A soft-deleted method still shows when it took money that day.
expected_cashis the tenders on the shop'sCASHledger account; "cards / payments" is everything else.- Sales come from
RevenueMixService(labor, parts, and the rest as other). Taxes arenetBalance()per$shop->taxAccountIds(), labelled fromTaxRate(TPS 5 %). - The invoice list, balances and "on customer account" come from
work_orderswithinvoice_date = $date.
- Tenders come from
CloseDay(POST) stores aDailyClosingwith the snapshot, the counted cash and the notes. It refuses a day that is already closed, in the future, or inside a closedPeriod.ReopenDay(DELETE /{dailyClosing}) deletes the row. Unlike periods, any day can be reopened: days are not contiguous, so reopening one leaves no gap.DownloadDailyClosingrendersresources/views/reports/daily-closing.blade.phpto a one-page Letter PDF throughspatie/laravel-pdf, the same way invoices are printed.- A closed day renders its snapshot, never the live ledger. The sheet filed with the deposit must not move afterwards.
- The lock.
TransactionService::guardAgainstClosedDay()runs next toguardAgainstClosedPeriod()inapplyPayment()andpersistTransaction(), but only when the document is aWorkOrder. A purchase order dated a closed day still books.SyncBilledWorkOrderLaborCostskips a closed day the same way it skips a closed period.
The snapshot shape is a contract. DailyClosingReportData is stored verbatim in daily_closings.snapshot: add fields, never rename or remove one, or older closings stop hydrating. :::
A business day is the shop's calendar day (Shop::resolvedTimezone()), not the server's. In Quebec the UTC date rolls over at 20:00 while the drawer is still open. The default date, the before_or_equal validation and the PDF timestamps all use it. :::
Payments received
GetPaymentsReceived reads workOrderTenders() too, so it shows customer payments only — purchase-order payments are left out. It lists one row per payment method with the same rules as the daily closing: every active method, a deleted one when it took money in the period or its comparison, and one "unassigned — account" row per account for payments recorded before payment_method_id. Comparison rows are matched by key (method-{id} / account-{id}).
Reading the ledger
App\Services\Reports\LedgerReportService is the only place that queries transaction_paths. Everything else composes it. All amounts come back in dollars (cents ÷ 100).
| Method | Returns | Used by |
|---|---|---|
accountBalances($shop, $period, $types, $ids) | AccountBalanceData per account — net movement within the range | income statement, sales, margin |
closingBalances($shop, $asOf, …) | same shape, cumulative from inception | trial balance |
movements($shop, $period, …) | LedgerMovementData per path, ordered by date, number, id | journal, general ledger |
netBalance(…) | one signed float | KPIs, headline totals |
netBalanceByMonth(…) | signed float keyed YYYY-MM | sales trend |
transactionCount($shop, $period, $type) | int | invoices issued, credit notes |
workOrderTenders($shop, $period) | one row per tender path of the period's work-order payments (method, account, cents, invoiced-before-the-period flag) | daily closing, payments received |
Each takes optional ChartOfAccountType[], account-id and TransactionType[] filters, all applied in pathQuery() — a toBase() query joining transactions and chart_of_accounts and always filtering transactions.shop_id.
pathQuery() is a base query builder, so no global scope applies. The shop_id filter in it is the tenancy boundary for every accounting report — a new method on this service that forgets it leaks another shop's ledger. :::
Four services sit on top, each shared by a report page and a KPI so the two can never disagree:
| Service | Does | Shared by |
|---|---|---|
ReceivablesReportService | outstanding invoices aged into current/d1_30/d31_60/d61_90/over_90 | AR report, UNPAID_INVOICES, DAYS_SALES_OUTSTANDING |
LaborReportService | billed hours (invoiced lines) vs worked hours (time entries), plus labour revenue/cost from the ledger | labor efficiency, SHOP_EFFICIENCY, REVENUE_PER_BILLED_HOUR |
RevenueMixService | revenue accounts bucketed into labor / parts / env_fee / diagnostic / towing / sublet / other by account code | sales-by-type, REVENUE_MIX |
CategoryReportService | revenue and COGS split per product category | sales-by-category, gross margin |
RevenueMixService::bucket() matches literal codes (4000, 4020, 4300, 4400, 4500, 4200, 4210, 41xx). A shop that renumbers its revenue accounts, or a new seeded revenue code outside those ranges, falls into other — the total stays correct, the split quietly stops being useful. :::
CategoryReportService only attributes a balance when exactly one category maps to that account; accounts shared by several categories, or claimed by none, land in an other remainder so rows + other always reconciles and nothing is double-counted.
The report surface
Nineteen reports (plus the index) live under /{shop}/reports, all registered in routes/webShop.php under the shop.report. name prefix, each pointing at a single-action class in app/Actions/Report.
The shop.report.* group sits behind module:accounting, as do shop.settings.chart-of-accounts.* and shop.settings.periods.*: a shop whose plan leaves accounting off gets a 403 on all of them. See Plans & modules.
App\Entities\ReportType is the catalogue: one case per report, a category() (STATEMENTS, SALES, RECEIVABLES, PROFITABILITY, OPERATIONAL), a routeName() derived from the value (sales_by_type → shop.report.sales-by-type) and an isAvailable() flag currently always true. GetReportIndex maps the cases into the Shop/Report/Index page.
Every report action follows the same shape:
php
class GetTrialBalance
{
use AsAction;
use InteractsWithReportPeriod;
public function __construct(private readonly LedgerReportService $ledger) {}
public function authorize(ActionRequest $request): bool
{
return $request->user()?->isShop() ?? false;
}
public function rules(): array { return $this->reportPeriodRules(); }
public function handle(Shop $shop, ReportPeriod $period): TrialBalanceData { /* … */ }
public function asController(ActionRequest $request): Response
{
$period = $this->resolveReportPeriod($request);
return Inertia::render('Shop/Report/TrialBalance', [
'report' => fn () => $this->handle(Shop::current(), $period),
])->withSharedProps(['periods']);
}
}'report' => fn () => … is a deferred prop, so ReportFilters.vue can reload with only: ['report'] and leave the rest of the page alone.
Reports are gated on isShop() only — any shop user sees every report. Unlike the dashboard KPIs, which are gated by view-kpi-* permissions, there is no per-report authorization.
The one exception is GetSalesByTechnician, which also requires KpiCategory::OVERVIEW->permission() (view-kpi-overview, held by Manager). Its cost column is clocked hours priced at the technician's hourly_rate, so on a technician whose lines are all services cost / actual_hours gives their exact wage back. Any report that starts carrying payroll needs the same gate. :::
The period filter
InteractsWithReportPeriod resolves the request into a ReportPeriod value object, accepting two mutually exclusive shapes:
from/to— a custom range, defaulting to the current calendar month.period_id(orfrom_period/to_periodfor a span) —current, meaning everything after the closed frontier up to today, or a closed period's id. An unknown id falls back tocurrentrather than erroring.
Both accept compare (ReportComparison: none, previous_period, previous_month, previous_year), which populates $period->comparison. ReportPeriod normalises a reversed range instead of returning nothing, and shifts a full calendar month to the previous whole month rather than subtracting days. The period floor when nothing is closed is 2000-01-01.
Adding a report
- Add a case to
App\Entities\ReportTypeand slot it intocategory(). - Add
enums.report-type.<value>andenums.report-type-description.<value>toresources/js/Locales/en/enums.jsonandfr/enums.json. - Write the DTO(s) in
app/Data— Spatie Data, auto-transformed toresources/js/types/generated.ts. - Write
app/Actions/Report/Get<Name>.phpfollowing the shape above. Derive the figures fromLedgerReportServiceor one of the four services on top of it; if the maths already exists inside another report action, extract it into a service both call rather than copying it. - Register the route in
routes/webShop.php, inside theshop.report.group — the name must matchReportType::routeName(). The group'smodule:accountinggate then covers it; a report route registered outside the group would not be gated. - Add
resources/js/Pages/Shop/Report/<Name>.vue, reusingPartials/ReportFilters,ReportHeader,BreakdownTable,StatementSection,ComparisonCellandShareBar. - Add a test in
tests/Feature/Reports.ReportIndexTestasserts the exact catalogue size, so it needs updating with each new case. - If the report introduces a new filtering pattern, add the covering index and its row to
ReportIndexCoverageTest::reportIndexes()— that test asserts on the schema directly, because a missing index here is a silent performance regression, not a failure.
Adding a KPI
The dashboard band is documented in Dashboard KPIs. The rule that binds it to this page: a KPI never queries the ledger on its own terms. It resolves through the same service the matching report uses, so a tile and the report behind it cannot disagree. Add a KpiType case, a branch in KpiService::resolve(), and — if the maths lives in a report action — extract it into a service first.
Gotchas
- Ledger mappings are not optional. Billing a work order in a shop whose
receivablesmapping is unset throwsInvalidArgumentExceptionfrom deep insideTransactionService.SeedChartOfAccountsnormally prevents this, but a shop created outside onboarding will not have run it. - A product with no category books nothing.
resolveLineCategory()returns null for a non-Productline, and those lines contribute no revenue or COGS path — while the receivable path still carries the full document total, so the residual lands on the tax account throughbalanceEntries(). TransactionType::DEPOSITandSTOCK_MUTATIONare declared but never written. Account 2500 (Customer Deposits) has no entries; the customer deposit KPI reads 0 and derives its count from work orders instead.- A payment is a transaction morphed onto another transaction.
Transaction::payments()is amorphManyback ontoTransactionfiltered toPAYMENT, andUpdateTransactablePaymentStatus::resolveTransactable()has to hop through that extra level to find the work order. Anything walkingtransactablemust handle the two-step. - Payment status is event-driven.
UpdateTransactablePaymentStatusruns onTransactionSaved/TransactionDeleted(after commit), writespayment_totalandis_paid, and moves a fully paid work order toPAID. A ledger write that bypasses the model events leaves the document showing the wrong balance. ClearKpiCacheOnTransactionSavedhangs off the same events; a ledger write outside the model layer leaves the dashboard stale for ten minutes.