How I keep a 322,000-line accounting system correct on my own: one save gate, append-only money, a hash-chained audit trail, real-database tests, and the audit that caught what they missed.
Offline-first billing for electricity stations: one id from the phone to the receipt, why bill totals must never be added, an outbox that knows what to retry, and the 500 that looped.
Why ESC/POS printers mangle Arabic, how to print it as a 1-bit image at the head's exact width, and the geometry bug that turned a 12 cm receipt into a roll on the floor.
Six layers in production, each built for the day the one above it fails: forced row-level security, logins that cannot bypass it, sealed containers, phone certificates, weekly restores.
On 24 September 2026 I audited my own accounting system with one rule: reading code is not a test run. The suite passed 372 of its 373 tests. The first finding was the worst kind of bug, the kind where nothing crashes. With the default settings, no sales invoice could reach the ledger.
Customer balances were right. Stock was right. The cash drawer was right. Only the trial balance knew. It had no sales at all, while every purchase posted at once. On paper, the business bought and never sold.
FinyX-ERP is the Arabic ERP and point-of-sale system I design, build and sell alone. This article is the machinery that keeps it correct without a team, and the exact reason 372 passing tests could not see that bug. By the end you will have a save gate you can copy into any EF Core app, an audit trail that notices edits made straight on the database file, and a checklist for the bugs your tests are blind to by design.
322k
lines of code, generated migrations excluded
100
database tables
531
automated tests today
1
developer
#A rule in a screen is a rule the next screen forgets
FinyX began as accounting software for Windows. Today the same repository holds a WPF till and back office, an ASP.NET Core server for each restaurant, a Next.js web store and three phone apps from one Expo codebase. One person cannot remember every rule in every one of those places, so the design must not depend on anyone remembering.
The desktop app and the restaurant server compile the same Domain, Application and Infrastructure source files. Every business service is registered in one method, AddFinyXBusinessServices(), and both hosts call it. A rule written once runs in both.
That settles where a rule is written. The harder question is where it is enforced. My answer: at the one place every change to the data has to pass through.
FinyX treats each business event as one database transaction. An invoice, its stock movements and its journal entry commit together or not at all. Every save goes through the unit of work, and the unit of work refuses anything that would rewrite history:
csharp
public async Task<int> SaveChangesAsync(CancellationToken ct = default){ EnsureAppendOnlyAccountingAndAuditRecords(); // journal entries and lines, payments, cash vouchers, audit: insert only EnsurePostedDocumentsAreImmutable(); // a posted invoice is corrected by a new document await EnsureNoClosedFinancialPeriodWritesAsync(ct); var audits = BusinessAuditCapture.Capture(_context, _operationId, _user.CurrentUserKey); await using var tx = await _context.Database.BeginTransactionAsync(ct); var changed = await _context.SaveChangesAsync(ct); var records = audits.Select(audit => audit.Materialize()).ToArray(); AuditChain.Link(records, await AuditChain.ReadTailHashAsync(_context, ct)); _context.BusinessAuditRecords.AddRange(records); await _context.SaveChangesAsync(ct); await tx.CommitAsync(ct); return changed;}
This is shortened. The real method joins an outer transaction when one exists, and it tells open screens to refresh only after the commit, so no screen ever reads a write that might still roll back.
Three checks, three promises.
Append-only. Audit records, journal entries, journal lines, payments and cash vouchers are never modified or deleted through the gate. A mistake is corrected by a reversing entry, and both stay in the history.
Posted means fixed. A posted invoice, return or expense cannot change. You correct it with a new document.
Closed periods stay closed. The comment above that check explains why it lives here: "correctness must not depend on every current or future service remembering to call one." It reads both the original and the new date of a changed row, so a historical entry cannot be moved out of a closed period, or into one. It also locks everything dated before the end of a closed period, including a date before the first period or in a gap between two. The next opening balance is the sum of everything before it, so a row slipped into a gap changes a number someone has already signed.
Refuserewrites of money, posted documents, closed periods
Save the changeinside one transaction
Link the auditeach record carries the previous record's hash
Committhe change and its evidence together, or neither
One gate for every save. Any failure rolls back the change and its audit record together.
A gate can fail in the other direction, just as quietly. Archiving a sales invoice required it to be posted, and the posted-document check then refused the save because the invoice was posted. Archiving never succeeded once. The print counter failed the same way after every print of a posted invoice, so it stayed at zero and the invoice read "never printed".
The fix is a closed allow-list. Four filing fields may change on a posted invoice:
If a single field outside that list changes in the same save, the whole save is refused. Anyone hoping to edit a posted amount finds no door here.
#An audit trail that notices edits behind its back
My audit log used to promise one thing: it could not be edited from the app. That promise is weak. Anyone who opens the SQLite file with a database tool can delete a sale and its audit row together, and nothing is left to show it.
So every audit record now carries a SHA-256 hash of its own content plus the hash of the record before it. Change one character and its hash stops matching. Delete a record and the next one points at a hash that no longer exists.
csharp
public static string ComputeEntryHash(BusinessAuditRecord record, string previousHash){ var payload = string.Join("\u001F", // a separator no value contains record.OperationId.ToString("N"), record.OccurredAtUtc.ToString("yyyy-MM-ddTHH:mm:ss.fff", CultureInfo.InvariantCulture), record.UserKey, record.Action, record.EntityType, record.EntityKey, record.ChangedProperties, record.BeforeJson, record.AfterJson, record.ApplicationVersion, record.MachineName, previousHash); return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(payload))).ToLowerInvariant();}
Twelve lines. Four traps hid inside them.
Timestamps that come back different. FinyX runs on SQLite, SQL Server or PostgreSQL. SQLite hands a DateTime back with no Kind, and PostgreSQL rounds it to microseconds. Hash the full value and a record written on SQLite fails verification once the data moves to PostgreSQL. The hash takes milliseconds with no kind suffix, the one precision every provider returns intact.
The payload is a storage format. Field order and separator are frozen. Change either and every hash ever written becomes unverifiable, so treat any change there as a data migration.
Two writers, one tail. On a shared server, two tills can save at the same instant and both link to the same previous record. That is honest work. A verifier that demands one straight line would accuse the customer every day, and an alarm that rings every day gets switched off. So verification ignores order and checks three things: every hash matches its content, every previous hash points to a record that exists, and the chain has exactly one start.
The tail has no witness. Delete the last record and no later record points at it. The chain alone cannot see that, so the record count and the last hash are also kept outside the database. A log that got shorter shows up as a smaller count.
The order inside the gate matters too. The "before" values are captured before the first save, while EF Core still holds the original values. The "after" values and any new primary keys are read after it. The tail hash is read inside the same transaction, so the tail I link to is the tail that commits. Because both saves share that transaction, a business change never commits without its evidence.
The test does exactly what a tamperer would do. It writes to the database file with raw SQL, from outside the app:
csharp
// edit a record outside the app: detectedExecuteOutsideTheSystem(db, "UPDATE BusinessAuditRecords SET UserKey = 'someone.else' WHERE Id = (SELECT MIN(Id) FROM BusinessAuditRecords);");Require(!Verify(db).IsIntact, "An edited audit record went unnoticed.");// put the value back: intact again, the check holds no grudgeExecuteOutsideTheSystem(db, "UPDATE BusinessAuditRecords SET UserKey = 'audit.chain.tests' WHERE Id = (SELECT MIN(Id) FROM BusinessAuditRecords);");Require(Verify(db).IsIntact, "Restoring the original value left the trail broken.");// delete one from the middle: detectedExecuteOutsideTheSystem(db, "DELETE FROM BusinessAuditRecords WHERE Id = (SELECT MIN(Id) + 1 FROM BusinessAuditRecords);");Require(!Verify(db).IsIntact, "A deleted audit record went unnoticed.");
On PostgreSQL, triggers guard 11 tables as well, so a posted entry or an audit record stays fixed even for someone holding an admin tool. A test compares that list with the SQL Server list, so moving a customer to a server never weakens the protection.
FinyX does not use xUnit. Its tests are one executable harness. Each test is a method named as an English sentence that states the promise it keeps, and the ones that touch data run against a real SQLite database in their own temporary folder. Each test prints a METRIC line with the numbers it proved, then PASS, FAIL or SKIP:
A METRIC line turns a green test into evidence. You can read what it proved without opening the code. SKIP exists for one reason: a test the environment cannot run is announced and counted, never reported as a failure, because a red that means nothing trains you to ignore red. The release script runs the whole desktop suite before it publishes anything, and stops if any test fails.
With all of that in place, the suite said 372 of 373. Here is what it could not see.
The sales screen shows how many invoices are waiting to be posted, with a button that posts them. In the view model, that list required every invoice to be approved. Approval is a policy, SalesRequireApprovalBeforePosting, and it ships switched off. So with the default settings, no invoice ever reached that list, and the button had nothing to post.
Why did nobody notice? Because everything a cashier or an owner checks every day was right. Customer balances, stock and the cash drawer all include unposted documents on purpose, so they stay correct before the accountant posts. Only the reports that read the ledger itself, the trial balance and the income statement, were missing every sale.
Why did 372 tests not notice? This is the part worth stealing. The service already had the right function, GetUnpostedSaleInvoicesAsync. It was tested. And nothing called it. The screen built its own list from the rows it happened to be showing, with its own copy of the rule. The tests proved a function the product never ran.
The same mistake had a second symptom. Because the list came from the visible rows, it changed with the screen's date filter. With the range set to "today", yesterday's invoice could not be posted at all.
The sales screen (what ran)The service (what was tested)
Approval always requiredApproval only when the policy asks for it
Built from the rows on displayRead from the database
Changed with the date filterEvery unposted invoice
Empty with default settingsCorrect, with no caller
One rule in two places. The tests covered the right-hand column, and nothing called it.
The rule moved into the service, which now reads the same policy as the posting itself, and the screen takes its list from the service:
The same audit found that pressing Edit on a posted invoice removed its journal entry first and opened the editor second. Press Cancel and the invoice left the books: revenue, tax, cost and the receivable gone, the goods still out of stock, the invoice still in the list. No message.
Now the editor opens first. If it closes without confirmation, the ledger is never touched. The invoice is unposted only after confirmation, right before the change is applied, and any failure after that restores the posting. If even the restore fails, a window names the invoice and says its entry is missing from the ledger.
How do you test a decision that lives behind a modal dialog, in a suite with no screen? You test the order in the source:
csharp
var body = MethodBody(File.ReadAllText(salesViewModelPath), "private async Task EditSelectedInvoiceAsync()");var dialogGuard = body.IndexOf("editor.ShowDialog() != true", StringComparison.Ordinal);var unpost = body.IndexOf("UnpostSaleInvoiceAsync", StringComparison.Ordinal);Require(dialogGuard >= 0, "The editor is no longer opened before the invoice is amended.");Require(unpost > dialogGuard, "The ledger is stripped before the editor is confirmed.");Require(body.Contains("RestorePostingAfterAbandonedEditAsync"), "Nothing restores the entry when the amend fails after the invoice was unposted.");
It is crude, and it measures the exact thing that broke.
Unposting an invoice has to remove its journal entry, which the gate forbids. So unposting and administrative deletion had a path of their own: raw SQL, with the database triggers suspended. That path never passed through the unit of work, so it never met the closed-period check. An entry from a month already closed could be erased.
The fix runs the period check before the connection opens and before any trigger is suspended. A request against a closed period is refused before a single row is touched.
Every path around a gate has to carry the gate's checks with it. The fastest way to find yours is to search the codebase for raw SQL.
The one failing test of the 373 failed on every normal run. It needed a database prepared beforehand by a separate script, so for anyone else it was always red. A permanent failure in an otherwise green list teaches its reader to skip it, and the first real failure gets skipped with it. The test now builds its own database in a fresh temporary folder and removes it afterwards. After the fixes the suite ran 376 of 376.
Less than a month later, the desktop suite has 475 tests.
Desktop (WPF)475
Server (ASP.NET Core)30
Web16
Integrations library10
531 tests in four suites today. On the day of the audit, the desktop suite had 373.
Put every invariant that protects money in the save path. Screens may warn early. The save path decides.
A screen offers only what the service accepts. Read the list from the service, never from the rows on display.
Make money records append-only, and correct them with reversing documents.
In a period check, test the original date and the new one, and lock the gaps before a closed period's end.
Write exceptions to an invariant as closed allow-lists of fields.
Chain your audit records: fixed field order, a separator no value contains, a timestamp precision every database returns, verification that tolerates concurrent writers, and a witness for the tail outside the database.
In tests, write to the database file from outside the app, and prove you notice.
Search for raw SQL. Every path around the gate must repeat the gate's checks.
Search for tested service methods that nothing calls.
Allow no permanently red test. Skip loudly, and count the skips.
Audit with one rule: reading code is not a test run, and a successful build proves nothing about the accounting.
If I started FinyX again tomorrow, I would write the save gate before the first screen. The audit's first findings were two rules that lived in a screen and one raw SQL path around the gate. Put the rule where the data changes, then write the test that proves the screen uses it.
It is the same discipline I bring to the business systems I build for clients.
Comments
Join the conversation
Sign in with Google to like articles and comment. It takes one tap.