Skip to content

Purchase and sale are the same ledger seen from two sides, which is why the shop checkout could also run a finance office

One Laravel platform running both a retail point of sale and a garments finance office: the same purchase-and-sale ledger, the same returns in both directions, and a vocabulary layer that is the only thing that differs between them.

Till · S-10482

Hartwell & Daughters · Till 1 · counter · Tilly Brennan · 11:42

PostedThu 17 Sep 2026TB

Basket

9 units · VAT inclusive
ProductQtyEachLine
Linen tea towelHD-TT-LIN×2£3.60£7.20
Beeswax tealightsHD-TL-BEE×3£2.40£7.20
Jute garden twineHD-TW-JUT×1£4.20£4.20
A5 kraft notebookHD-NB-KRA×2£3.00£6.00
Olive oil soap barHD-SP-OLV×1£3.60£3.60
Net£23.50
VAT at 20%£4.70
Total£28.20
Paid
Cash£10.00
Card · contactless£18.20
New saleReprint receiptRefund items

Ledger lines this sale wrote

S-10482 · 4 lines
AccountWritten byDebitCredit
1210Cash registerTill£10.00
1220Card takingsTill£18.20
4000SalesMovement£23.50
2201VAT on salesMovement£4.70
Balanced · debits equal credits£28.20£28.20

The checkout writes the two tenders; the movement writes income and VAT.

Stock from the same movement

mv_s-10482
direction −1 · stock out−9 units, read from the rows above
ProductBeforeMovementOn hand
Linen tea towel34−232
Beeswax tealights51−348
Jute garden twine12−111
A5 kraft notebook27−225
Olive oil soap bar19−118

No separate stock write: on hand is a reading of the movement rows.

The brief, in specifics

Industry
E-commerce & Retail
Duration
22 weeks
Cooperation model
Dedicated team
Services
Platform engineeringBack officeRetargeting
Integrations
Barcode label printingSMS notificationsAccounting exportScheduled database backupsPDF document generation
Technologies
LaravelMySQLBladejQueryBootstrap
Team
1 Engagement lead3 Backend engineers1 Frontend engineer1 QA engineer

Client name withheld under NDA. Engagement details are shown to the extent our agreement permits.

The hard problem

They were maintaining two products that were quietly the same product. A fix to returns handling had to be made twice and was often made once. The two codebases had drifted far enough that a customer moving between them lost their history, and the sales team had stopped promising the move was possible.

The drift had already cost correctness. A fix to returns handling had to be applied twice and had been applied once, so one vertical carried an arithmetic bug the other didn't, and neither team knew. Underneath, both codebases modeled purchases and sales as separate parallel trees, which is why every fix to one implied a fix to the other and nobody could be sure it had happened.

One ledger. A purchase and a sale are the same movement of stock and money with the sign reversed, and a return is that movement run backwards. Once that was true in the schema, what a garments office calls a 'job' and a shop calls a 'sale' became a label, with no branch in the code.

How the pieces fit

  1. 01

    Modeled purchase and sale as one ledger with direction, replacing two parallel trees

    A purchase and a sale are one movement with the sign reversed, so stock and money are described once, in one model.

  2. 02

    Made returns run the original movement backwards, with no compensating record written by hand

    A return reverses the original movement, so the arithmetic is right by symmetry, with nobody's attention required.

  3. 03

    Put the vertical's vocabulary in configuration, so a new vertical is a labeling job

    Vocabulary moved to configuration first, because until it did, every merge argument turned into an argument about whose word was correct.

  4. 04

    Kept the cash register as the reconciliation point both verticals close against

    Both verticals ran against the merged register in parallel with their old systems for a full month-end before either was retired.

  5. 05

    Shared payroll, expenses and attendance once across both verticals

    Payroll, expenses and attendance are shared across both verticals, because neither trade disagrees about what they are.

Introduction

The system we were asked to build

The company sells business software to two very different customers: shops that need a point of sale, and garments manufacturers that need a finance office. We built one platform that serves both, a shared ledger covering purchases, sales, returns, expenses, payroll and cash, with the document vocabulary configured per vertical.

A software company selling to two very different customers, shops that need a point of sale and garments manufacturers that need a finance office, as two separately maintained products. They were quietly the same product. The engagement began when a customer moving between the two lost their history and the sales team stopped offering the move.

Full-Stack Engineering

One ledger, two directions

Purchase and sale are the same movement with the sign reversed, so stock and money are described once.

A purchase and a sale are the same movement of stock and money with the sign reversed, so both are described once in the schema. No two subsystems to keep in agreement. Every quantity and every amount flows through one set of entries, which is why the stock position and the cash position can't disagree: they're readings of the same rows.

What shipped
  • Purchase and sale are one movement with the sign reversed
  • One set of entries behind both stock and cash
  • The two positions can't disagree; they read the same rows
One movement, two directions

A purchase and a sale opened side by side · each ledger line is amount × direction

Open movement tableThu 17 Sep 2026TB
P-3317Brackenfold Wholesale · 15/09/2026direction +1 · stock in£90.00

Items received

+66 units
ProductStockNet eachNet
Linen tea towel+12£1.50£18.00
Beeswax tealights+24£0.90£21.60
Jute garden twine+6£1.40£8.40
A5 kraft notebook+12£1.10£13.20
Olive oil soap bar+12£1.15£13.80

Ledger lines

Purchases and VAT debited
AccountWritten byDebitCredit
5000PurchasesMovement£75.00
2202VAT on purchasesMovement£15.00
2100Trade creditorsMovement£90.00
Balanced · debits equal credits£90.00£90.00
S-10482Walk-in customer · Till 1 · counter · 17/09/2026 11:42direction −1 · stock out£28.20

Items sold

−9 units
ProductStockNet eachNet
Linen tea towel−2£3.00£6.00
Beeswax tealights−3£2.00£6.00
Jute garden twine−1£3.50£3.50
A5 kraft notebook−2£2.50£5.00
Olive oil soap bar−1£3.00£3.00

Ledger lines

Income and VAT credited
AccountWritten byDebitCredit
1210Cash registerTill£10.00
1220Card takingsTill£18.20
4000SalesMovement£23.50
2201VAT on salesMovement£4.70
Balanced · debits equal credits£28.20£28.20
Where they meetThe VAT control accountsSame accounts, opposite sides
2202VAT on purchases£15.00 Debitfrom P-3317 · +1
2201VAT on sales£4.70 Creditfrom S-10482 · −1
These two documents, net£10.30reclaimable: £15.00 in, less £4.70 out
On screen

A purchase and a sale opened side by side: the same movement with the sign reversed, stock in against stock out, meeting at the VAT control accounts on opposite sides.

Refund R-10482-01

Three items from S-10482 · Change of mind · unopened · refunded to the card that paid

PostedThu 17 Sep 2026TB
Runs backwardsS-1048217/09/2026 11:42
Items back3of 9 sold
Refunded to card£9.60net £8.00 · VAT £1.60
Compensating entries0none written by hand

Original sale S-10482

Choose what comes back
ProductSoldBackRefund
Linen tea towel£3.60 each2+1£3.60
Beeswax tealights£2.40 each3+1£2.40
Jute garden twine£4.20 each10
A5 kraft notebook£3.00 each20
Olive oil soap bar£3.60 each1+1£3.60
Net reversed£8.00
VAT reversed at 20%£1.60
Refund to card£9.60

A partial return is the same reversal over fewer items. Nothing to key into the ledger.

The movement, backwards

R-10482-01 reverses S-10482
Original lines · S-10482
AccountWritten byDebitCredit
1210Cash registerTill£10.00
1220Card takingsTill£18.20
4000SalesMovement£23.50
2201VAT on salesMovement£4.70
Balanced · debits equal credits£28.20£28.20
Reversed for three items · sides swapped
AccountWritten byDebitCredit
4000SalesReversal£8.00
2201VAT on salesReversal£1.60
1220Card takingsReversal£9.60
Balanced · debits equal credits£9.60£9.60
direction +1 · stock in+3 back on the shelf
S-10482 now readsSales £15.50 · VAT £3.10 · paid £18.60 · −6 units
On screen

Three returned items running the original sale backwards: the income, VAT and card lines reversed for those items, three units back in stock, and no compensating entry anybody has to keep correct.

Returns are reversals

A return runs the original movement backwards. No compensating entry for somebody to keep correct.

A return runs the original movement backwards against the same entries, so nobody writes a separate compensating document and then has to keep it correct. The arithmetic stays self-evident: the sum is right because the entries are symmetric, with no second process maintaining it. Partial returns and price-adjusted returns fall out of the same mechanism.

What shipped
  • Returns reverse the original movement
  • The arithmetic is right by symmetry alone
  • Partial and price-adjusted returns need no special handling
Vocabulary

One document template, two presets · the words are configuration, not a branch

documents/show.blade.phpThu 17 Sep 2026TB

HDHartwell & Daughters

vocabulary.preset = retail
Sale S-10482direction −1 · stock out
CustomerWalk-in17/09/2026 11:42
ProductQtyNet
Linen tea towel2£6.00
Beeswax tealights3£6.00
3 more lines4£11.50
Net£23.50
VAT at 20%£4.70
Total£28.20

CMCalder Mill Garments Ltd

vocabulary.preset = garments
Job J-2291direction −1 · stock out
BuyerNorthgate Workwear17/09/2026
StyleQtyNet
Lined work jacket, navy120£2,220.00
Net£2,220.00
VAT at 20%£444.00
Total£2,664.00

Vocabulary presets

One setting chooses the whole column
Label keyretailgarments
document.saleSaleJob
document.purchasePurchasePurchase order
document.sale_returnRefundCredit note
party.customerCustomerBuyer
item.productProductStyle
account.1210Till drawerPetty cash
register.closeCash upClose day book

A third trade would be a third column here, and no new code.

The template both render

Illustrative excerpt
<div>{{ label('document.sale') }} {{ $doc->number }}</div><dt>{{ label('party.customer') }}</dt><dd>{{ $doc->party->name }}</dd><th>{{ label('item.product') }}</th>@foreach ($doc->movement->items as $item) @include('documents.item-row')@endforeach
Conditionals on which business0
Label keys in each preset7 of 7
Setting that picks the wordsvocabulary.preset

Vocabulary in configuration

On screen

One document template rendered for both businesses, a sale at the shop and a job at the garments office, with every word they differ on read from a vocabulary preset rather than a branch.

What each vertical calls a document is a label. No code path branches on which business is running.

What a garments office calls a job and a shop calls a sale is a label in configuration, and no code path branches on which business is running. That decision is what let one system serve two trades. The moment vocabulary became a branch, every later feature would have had to be written twice, and eventually written differently.

What shipped
  • Vocabulary lives in configuration, never in a branch
  • No code path asks which business is running
  • One feature written once, never twice and then differently
Cash register · close for 17/09/2026

Two businesses, one register concept · both close against account 1210

Both closed · no varianceThu 17 Sep 2026EH

Hartwell & Daughters · Till drawer

Cash up · counted down
Float£100.00Cash sales£612.40Paid out£4.50Expected£707.90Counted£707.90Variance£0.00
DenominationCountValue
£2018£360.00
£1015£150.00
£520£100.00
£220£40.00
£131£31.00
50p24£12.00
20p40£8.00
10p40£4.00
5p36£1.80
2p25£0.50
1p60£0.60
Counted equals expected£707.90

Calder Mill Garments Ltd · Petty cash

Close day book
Opening£250.00Received£85.00Paid out£76.00Closing£259.00Counted£259.00Variance£0.00
RefEntryInOut
—Opening balance£250.00
PC-0418Offcut bale sold, cash£85.00
PC-0419Courier, sample to buyer£18.40
PC-0420Overlock thread, 12 cones£32.60
PC-0421Window cleaning£25.00
—Closing balance£259.00
Counted equals closing£259.00
1210Cash registerTill drawer · petty cash
Hartwell & DaughtersBanked £607.90 to 1200 · float left£100.00
Calder Mill Garments LtdDay book closed · balance£259.00
Group · 1210 at close£359.00a sum, not a translation
On screen

A cash drawer counted down and a day book closed, each with no variance, both reconciling against the same cash account.

Shared cash register

Both verticals close against the same reconciliation point, which is what makes their reports comparable.

Both verticals close against the same reconciliation point with the same register concept, which is what makes their reports genuinely comparable. A day's takings mean the same thing in both, so the owner can read one number across the group, where there used to be two numbers arrived at differently.

What shipped
  • One register and one closing concept across both trades
  • A day's takings means the same thing in each
  • Group reporting is a sum, with nothing to translate

Optional modules, not forks

Gift cards, coupons, quotations and variants switch on per customer, and the codebase stays whole.

Gift cards, coupons, quotations and product variants are modules switched on per customer, never reasons to fork. A shop that needs variants gets them; the garments office never sees the field. The alternative, a branch per customer, is what turns one product into five products with one team, and it was ruled out at the schema.

What shipped
  • Optional modules toggled per customer
  • Unused features never appear in the other trade's interface
  • One product for both, where it could have been five with one team
Settings · two businesses, one release

Every setting compared · a module is a row in configuration, not a fork

4.12.0 on bothThu 17 Sep 2026EH
Release4.12.0the same build for both
Settings compared15
Settings that differ5highlighted below
Code paths branching on business0

Configuration

HD and CM · 17/09/2026
SettingGroupHartwellCalder Mill
ledger.vat_standard_rateLedger20%20%
ledger.currencyLedgerGBPGBP
ledger.year_endLedger31 Mar31 Mar
vocabulary.presetVocabularyretailgarments
module.gift_cardsModuleonoff
module.couponsModuleonoff
module.quotationsModuleonoff
module.product_variantsModuleonoff
module.returnsModuleonon
module.cash_registerModuleonon
module.expensesModuleonon
module.payrollModuleonon
module.attendanceModuleonon
module.departmentsModuleonon
register.close_to_accountRegister12101210

5 of 15 differ. Payroll, expenses, attendance and departments are shared.

A module is a row

module.product_variants
businesskeyvalue
Hartwell & Daughtersproduct_variantson
Calder Mill Garments Ltdproduct_variantsoff
Hartwell & Daughters · edit product
ProductLinen tea towelVariants · module onOatSageClay
Calder Mill Garments Ltd · edit style
StyleLined work jacket, navyNo variants field: the module is off

Gift cards, coupons, quotations and variants switch on per business. The code that draws this form is the same file in both.

On screen

Five settings differ between the two businesses and the release doesn't: a module is a row in configuration, never a fork.

Process

Phase by phase

  1. Phase 1: The Shared Ledger

    One Movement, Two Directions

    Replaced the parallel purchase and sale trees with one movement model that carries direction, so stock and money changes are described once and read from either side.

    • Movement Model
    • Direction
    • Stock Derivation
  2. Phase 2: Returns

    The Movement, Backwards

    Built returns in both directions as reversals of the original movement. Hand-written compensating entries were exactly where the two old codebases had diverged most.

    • Sale Returns
    • Purchase Returns
    • Reversal Audit
  3. Phase 3: The Vocabulary Layer

    Labels, Not Branches

    Moved every vertical-specific term into configuration. The garments office and the shop see different words for the same documents, and no code path asks which vertical it is running in.

    • Vocabulary Config
    • Document Naming
    • Vertical Presets
  4. Phase 4: Money And People

    Cash, Expenses, Payroll

    Built the cash register both verticals close against, plus expenses, payroll, attendance and departments, shared once across both products.

    • Cash Register
    • Expenses
    • Payroll & Attendance
  5. Phase 5: Extras That Only One Side Uses

    Optional, Not Forked

    Added gift cards, coupons, quotations and product variants as optional modules. The shop switches them on and the finance office doesn't, and both run the same code.

    • Gift Cards & Coupons
    • Quotations
    • Product Variants
Trial balance · as at 31/08/2026

Hartwell & Daughters · chart of accounts with the writer of each account

ExportThu 17 Sep 2026TB

Chart of accounts

19 accounts · whole pounds
AccountTypeWritten byDebitCredit
0030Shop fittingsFixed assetJournal£38,500
1001StockCurrent assetJournal£46,200
1100Trade debtorsCurrent assetMovements£3,800
1200Bank current accountCurrent assetBank£41,650
1210Cash registerCurrent assetTill£1,350
1220Card takingsCurrent assetTill£2,100
2100Trade creditorsLiabilityMovements£14,300
2201VAT on salesVAT controlMovements£30,000
2202VAT on purchasesVAT controlMovements£18,400
2210PAYE & NILiabilityPayroll£1,900
2300Bank loanLiabilityJournal£20,000
3000CapitalEquityJournal£60,000
3100Retained earningsEquityJournal£7,800
4000SalesIncomeMovements£150,000
5000PurchasesCost of salesMovements£92,000
7000Wages & salariesOverheadPayroll£26,400
7100Rent & ratesOverheadExpenses£9,600
7200UtilitiesOverheadExpenses£2,000
7400Shop expensesOverheadExpenses£2,000
Totals · balanced£284,000£284,000

Either side

Debits£284,000Credits£284,000
Difference £0

Written by the till

2 of 19
1210Cash register£1,350
1220Card takings£2,100

The checkout only records how a sale was paid. Income, VAT and stock come from the movement.

Accounts by writer

Same chart in both businesses
Movements6
Journal5
Expenses3
Payroll2
Till2
Bank1
On screen

The chart of accounts against the trial balance it produces: £284,000 either side, and only two of those accounts are written by the point of sale.

Outcome

What it carries now

1

Codebases maintained

2

Verticals served

0

Code paths branching on vertical

None

Returns written by hand

Codebases maintained and verticals served are counts. Code paths branching on vertical is zero: no conditional anywhere asks which business is running. Returns written by hand is none, because a return runs the original movement backwards.

Client name withheld under NDA. Figures are approximate, drawn from the engagement’s own reporting.

What the architecture settled

04
  1. 01

    Two products that share a spine are one product with a labelling problem, and maintaining them separately costs correctness first.

    Two products sharing a spine are one product with a labeling problem, and the first thing separate maintenance costs is correctness. Time comes second.

  2. 02

    Model returns as reversals of the original movement; compensating entries drift the moment two people write them differently.

    Compensating entries drift the moment two people write them differently. A reversal can't drift, because it's the same movement read backwards.

  3. 03

    Put vertical vocabulary in configuration and no code path has to ask which business it is serving.

    Vocabulary in configuration means no code path asks which business it is serving, which is the only version of this that survives a third vertical.

  4. 04

    Optional modules beat forks. The moment a vertical needs its own branch, the shared spine has already stopped being shared.

    The moment a vertical needs its own branch, the spine has already stopped being shared, so optional modules are the boundary worth defending.

Ways of working

How the work was run

Their support leads kept a register of every bug that had been fixed in one product and not the other. That list, more than any requirements document, defined the merge: the shared ledger had to make each of those defects impossible to reintroduce on one side only.

A dedicated team for twenty-two weeks, starting with the ledger model before any interface, because vocabulary is only a labeling question once the schema is genuinely shared. Both verticals ran against the merged ledger in parallel with their existing systems for a full month-end before either was retired.

Four documents, one routine

A sale, its return, a purchase and another business’s job, posted by the same code.

Each tab writes its ledger lines with VAT at 20% and moves stock from the same rows. Watch what changes between the shop and the garments office: five configuration values, and not one line of the routine. Switch tabs, or use the arrow keys once one is focused.

A £28.20 sale at the shop's checkout. The checkout records how it was paid; the movement writes income net of VAT and the 20% VAT on top, and the nine units leave stock from the same rows.

Sale S-10482Hartwell & Daughters · Walk-in customer · cash and carddirection −1 · stock out
AccountDebitCredit
1210Cash register£10.00
1220Card takings£18.20
4000Sales£23.50
2201VAT on sales£4.70
Balanced£28.20£28.20
Stock, from the same movement−9 units
  • Linen tea towel−2
  • Beeswax tealights−3
  • Jute garden twine−1
  • A5 kraft notebook−2
  • Olive oil soap bar−1
Posting.php · same on every tab 0 branches on business
public function post(Document $doc): Movement{    $movement = $doc->reverses  // null        ? $doc->reverses->movement->reversed($doc->items)        : Movement::from($doc);  // direction −1     foreach ($movement->lines() as $line) {        $this->ledger->write($line);  // 4 lines    }    $this->stock->apply($movement);  // −9 units    return $movement;}
Settings that differHartwell & Daughters
  • vocabulary.presetretail
  • module.gift_cardson
  • module.couponson
  • module.quotationson
  • module.product_variantson

The garments office has other values for all five.

Why there is no if (vertical): the direction is data on the movement, a return is its original read backwards, and the words and modules are rows in configuration. Nothing left for a branch to decide. Line-by-line timing is illustrative.

Architecture

From a shop checkout or a finance office to one set of entries

One Laravel codebase on MySQL serves both trades. The shop and the garments office differ in configuration rows, never in the path a document takes.

  1. 01 · Source
    A document in either vocabularyA sale, a job, a purchase or a return. What each trade calls it is a label in configuration; no code path asks which business is running.
  2. 02 · Movement
    One movement with a directionPurchase and sale are the same movement of stock and money with the sign reversed, described once in a single model.
  3. 03 · Engine
    Posting and reversalA return runs the original movement backwards, so partial and price-adjusted returns need no compensating record written by hand.
  4. 04 · State
    Ledger and stock in MySQLStock and cash positions are readings of the same entries, so the two can't disagree.
  5. 05 · Close
    One cash registerBoth verticals close against the same register concept, so a day's takings mean the same thing in each and group reporting is a sum.

What can drift, and why it can't

Balanced books, clean returns, one release

Stock and cash read the same rows

A purchase and a sale are one movement with the sign reversed, so every quantity and amount flows through one set of entries. Both trades ran against the merged ledger alongside their old systems for a full month-end before either was retired.

Returns are reversals, not hand-written entries

A return runs the original movement backwards against the same entries. The sum is right because the entries are symmetric, with no second process keeping it right, and partial returns use the same mechanism.

A fix lands in both businesses at once

One codebase, and no code path that branches on vertical. Vocabulary and optional modules are configuration, so a fix to returns is made once for both businesses, and never only once by accident.

Maintaining two products that are quietly the same ledger? Scope your build in 3 minutes.

Scope your build
Have a project?

Let's talk

Running a large platform, shaping a first MVP, or getting a product ready for a funding round? Tell us where you are. We'll shape the process around it, and stay with you after launch.