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.
The brief, in specifics
- Industry
- E-commerce & Retail
- Duration
- 22 weeks
- Cooperation model
- Dedicated team
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
- 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.
- 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.
- 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.
- 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.
- 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.
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.
- 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
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.
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.
- Returns reverse the original movement
- The arithmetic is right by symmetry alone
- Partial and price-adjusted returns need no special handling
Vocabulary in configuration
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.
- 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
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.
- 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.
- 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
Five settings differ between the two businesses and the release doesn't: a module is a row in configuration, never a fork.
Phase by phase
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
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
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
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
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
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.
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
- 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.
- 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.
- 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.
- 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.
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.
- Linen tea towel−2
- Beeswax tealights−3
- Jute garden twine−1
- A5 kraft notebook−2
- Olive oil soap bar−1
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;}
- 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.
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.
- 01 · SourceA 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.
- 02 · MovementOne movement with a directionPurchase and sale are the same movement of stock and money with the sign reversed, described once in a single model.
- 03 · EnginePosting and reversalA return runs the original movement backwards, so partial and price-adjusted returns need no compensating record written by hand.
- 04 · StateLedger and stock in MySQLStock and cash positions are readings of the same entries, so the two can't disagree.
- 05 · CloseOne 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 buildNearby engagements
Data & AnalyticsA checkout that stopped losing sales to a 6-second load
Profiling found the real bottleneck behind a slow checkout (an N+1 query and an oversized bundle) and tuned both, with before-and-after metrics locked in as a baseline against future regressions.
E-commerce & Retail · 5 weeks
Web PlatformsA reader people finish, and a library that remembers where they stopped
A reading platform for a comics catalog: a browse surface people can actually navigate, a reader that gets out of the way, and a history that puts everyone back on the page they left.
E-commerce & Retail · 18 weeks
Web PlatformsOne number, a whole handset, and a database that keeps up with 125 brands
A metered IMEI lookup service that turns fifteen digits into a device, its specifications and its status. It's sold three ways to three audiences and backed by a device database that maintains itself.
Consumer Electronics · 22 weeks
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.














