fix(templates): a user update must not take system-owned fields from the payload - #6689
Merged
Merged
Conversation
…the payload The generated update() persisted the whole incoming row, so a partial payload - the UI header form PUTs only the fields it edits - erased every column the write path owns. Reproduced on production (invoice 0000000181, 2026-08-12): a draft header edit nulled Paid and wrote Balance 0, and the customer-facing PDF printed "amount due: 0" on an 8,556 invoice. Two defects compounded: - nothing preserved the system-owned columns, so the roll-up target Paid (written only by the allocation listeners) took the payload's null; - the expressionUpdate recompute (Balance = Total - Paid) ran BEFORE recalculate(), reading the payload's nulls as zeros - Balance persisted 0 while recalculate() then repaired Net/Vat/Total from the reloaded items, which is exactly why the corruption looked impossible: correct totals next to a nulled Paid and a zero Balance. update() now reloads the stored row once and takes the system-owned values from THERE, never from the payload: isReadOnlyProperty fields (author readOnly, roll-up targets, the document number and uuid - which also covers ProcessId and the audit columns) and aggregate footer fields with no update-time recompute (Net/Vat/Discount/Total/Paid). The expressionUpdate / actionUpdate assigns move AFTER recalculate(), so Balance = Total - Paid reads the resummed Total and the preserved Paid. Deliberately NOT preserved: a calculated create-only field that stays user-editable (a Currency relation defaulted from the company) carries neither flag, so a user's edit of it is respected. Safe against workflow writes by construction: every system writer (number stamp, status transitions, roll-ups) uses the targeted updateProperty / updateProperties / updateDerived primitives, which bypass update() entirely - the #6226/#6306 lost-update family, user-update edition. Verified on a live instance (registry wipe + template reseed + regen of sales-invoices and timesheets): emitted update() preserves Number/Net/Vat/Discount/Total/Paid/Uuid/ProcessId/audit and recomputes Balance after recalculate; runtime reproduction (create invoice + item, PUT a header-only payload) keeps Paid 0.00 / Balance 120.00 / Number intact where the previous build persisted Paid null / Balance 0. Client-Java batch 256 units, 261 class files. audit-dsl-emission.py: 729 OK, 1 pre-existing unrelated FAIL (timesheets personal-group, regen owed).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
A generated
update()persisted the caller's partial payload whole, so a header-only edit erased the system-owned money columns.The UI form PUTs only the fields it edits.
Paid(the allocation roll-up target) took the payload'snull, andBalancewas recomputed beforerecalculate()from those nulls and persisted as0.Observed in production: invoice
0000000181printed a total due of0on an 8,556.00 invoice after exactly one draft header edit. The data was repaired by hand.Fix
In
Repository.java.template:readOnly, roll-up/aggregate targets, the document number anduuid— into$preservedOnUpdate.update(), read the stored row once (findById) and take those values from there, never from the payload.recalculate(entity)before the update-time calculated fields, so an expression likeBalance = Total - Paidreads the freshly resummedTotaland the preservedPaid.This is the
#6226/#6306lost-update family, user-update edition.Deliberately not preserved
An aggregate that is itself recomputed on update (an
expressionUpdatesuch asBalance) is exempt —update()reassigns it afterrecalculate(). A calculated create-only field that stays user-editable (aCurrencyrelation defaulted from the company) carries neither flag, so a user's edit of it is respected.Safe against workflow writes by construction: every system writer (number stamp, status transitions, roll-ups) uses the targeted
updateProperty/updateProperties/updateDerivedprimitives, which bypassupdate()entirely.Verification
On a live instance (registry wipe + template reseed + regen of sales-invoices and timesheets):
update()preservesNumber/Net/Vat/Discount/Total/Paid/Uuid/ProcessId/ audit, and recomputesBalanceafterrecalculate();Paid 0.00/Balance 120.00/Numberintact, where the previous build persistedPaid null/Balance 0;audit-dsl-emission.py: 729 OK, 1 pre-existing unrelated FAIL (timesheets personal-group, regen owed).🤖 Generated with Claude Code