From 885c5896da8fff3f6edeabc331a2d73ff9fa05fb Mon Sep 17 00:00:00 2001 From: Michael Dieringer <65093775+MichaelDieringer@users.noreply.github.com> Date: Wed, 7 Oct 2026 20:37:01 +0200 Subject: [PATCH] knowledge(data-modeling): code that calculates a unit price rounds it to the currency's Unit-Amount Rounding Precision Only BaseApp's own price computations round a unit price; Validate/assignment and DecimalPlaces do not. A subscriber that handles OnUpdateUnitPriceOnBeforeFindPrice / OnUpdateDirectUnitCostOnBeforeFindPrice skips RoundPrice, so Quantity x displayed price no longer equals Line Amount. Adds the article, a good/bad sample pair, a worklist cue in al-data-modeling-review, and the review-fixtures registration. Co-Authored-By: Claude Opus 5.5 (1M context) --- evaluation/review-fixtures.json | 1 + ...nit-prices-to-unit-amount-precision.bad.al | 30 ++++++++++ ...it-prices-to-unit-amount-precision.good.al | 34 +++++++++++ ...ed-unit-prices-to-unit-amount-precision.md | 56 +++++++++++++++++++ .../skills/review/al-data-modeling-review.md | 3 +- 5 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 microsoft/knowledge/data-modeling/round-calculated-unit-prices-to-unit-amount-precision.bad.al create mode 100644 microsoft/knowledge/data-modeling/round-calculated-unit-prices-to-unit-amount-precision.good.al create mode 100644 microsoft/knowledge/data-modeling/round-calculated-unit-prices-to-unit-amount-precision.md diff --git a/evaluation/review-fixtures.json b/evaluation/review-fixtures.json index 68904529..4d551aa0 100644 --- a/evaluation/review-fixtures.json +++ b/evaluation/review-fixtures.json @@ -27,6 +27,7 @@ "new-price-source-must-add-candidate-and-trigger-recalculation", "pictures-must-use-media-not-blob", "report-barcodes-must-use-barcode-module-and-production-font-name", + "round-calculated-unit-prices-to-unit-amount-precision", "table-design-must-match-bc-table-type-conventions", "tablerelation-field-length-must-match-related-field", "transferfields-mirrored-fields-must-match-type-and-length" diff --git a/microsoft/knowledge/data-modeling/round-calculated-unit-prices-to-unit-amount-precision.bad.al b/microsoft/knowledge/data-modeling/round-calculated-unit-prices-to-unit-amount-precision.bad.al new file mode 100644 index 00000000..06bffef3 --- /dev/null +++ b/microsoft/knowledge/data-modeling/round-calculated-unit-prices-to-unit-amount-precision.bad.al @@ -0,0 +1,30 @@ +// Demonstration only; independently authored, not copied from BaseApp. +codeunit 50650 "Sample Own Sales Price Bad" +{ + [EventSubscriber(ObjectType::Table, Database::"Sales Line", 'OnUpdateUnitPriceOnBeforeFindPrice', '', false, false)] + local procedure SetOwnUnitPrice(SalesHeader: Record "Sales Header"; var SalesLine: Record "Sales Line"; CalledByFieldNo: Integer; CallingFieldNo: Integer; var IsHandled: Boolean; xSalesLine: Record "Sales Line") + var + Item: Record Item; + CurrencyExchangeRate: Record "Currency Exchange Rate"; + PriceLCY: Decimal; + begin + if SalesLine.Type <> SalesLine.Type::Item then + exit; + Item.Get(SalesLine."No."); + PriceLCY := Item."Unit Cost" * SalesLine."Qty. per Unit of Measure" * GetMarkupFactor(SalesHeader."Sell-to Customer No."); + + // Markup and currency conversion leave more decimals than the currency's unit-amount + // precision. IsHandled skips the standard calculation and its RoundPrice, so the price + // is stored unrounded and Quantity x the displayed price no longer equals Line Amount. + SalesLine."Unit Price" := + CurrencyExchangeRate.ExchangeAmtLCYToFCY( + SalesHeader."Posting Date", SalesHeader."Currency Code", PriceLCY, SalesHeader."Currency Factor"); + IsHandled := true; + end; + + local procedure GetMarkupFactor(CustomerNo: Code[20]): Decimal + begin + // Stand-in for a customer-specific markup lookup. + exit(1.35); + end; +} diff --git a/microsoft/knowledge/data-modeling/round-calculated-unit-prices-to-unit-amount-precision.good.al b/microsoft/knowledge/data-modeling/round-calculated-unit-prices-to-unit-amount-precision.good.al new file mode 100644 index 00000000..fe015f81 --- /dev/null +++ b/microsoft/knowledge/data-modeling/round-calculated-unit-prices-to-unit-amount-precision.good.al @@ -0,0 +1,34 @@ +// Demonstration only; independently authored, not copied from BaseApp. +codeunit 50651 "Sample Own Sales Price Good" +{ + [EventSubscriber(ObjectType::Table, Database::"Sales Line", 'OnUpdateUnitPriceOnBeforeFindPrice', '', false, false)] + local procedure SetOwnUnitPrice(SalesHeader: Record "Sales Header"; var SalesLine: Record "Sales Line"; CalledByFieldNo: Integer; CallingFieldNo: Integer; var IsHandled: Boolean; xSalesLine: Record "Sales Line") + var + Item: Record Item; + Currency: Record Currency; + CurrencyExchangeRate: Record "Currency Exchange Rate"; + PriceLCY: Decimal; + begin + if SalesLine.Type <> SalesLine.Type::Item then + exit; + Item.Get(SalesLine."No."); + PriceLCY := Item."Unit Cost" * SalesLine."Qty. per Unit of Measure" * GetMarkupFactor(SalesHeader."Sell-to Customer No."); + + // IsHandled skips the standard calculation and its RoundPrice, so this subscriber rounds + // the final price itself: once, after every factor, to the unit-amount precision of the + // document's currency (Initialize takes General Ledger Setup's precision for a blank code). + Currency.Initialize(SalesHeader."Currency Code"); + SalesLine."Unit Price" := + Round( + CurrencyExchangeRate.ExchangeAmtLCYToFCY( + SalesHeader."Posting Date", SalesHeader."Currency Code", PriceLCY, SalesHeader."Currency Factor"), + Currency."Unit-Amount Rounding Precision"); + IsHandled := true; + end; + + local procedure GetMarkupFactor(CustomerNo: Code[20]): Decimal + begin + // Stand-in for a customer-specific markup lookup. + exit(1.35); + end; +} diff --git a/microsoft/knowledge/data-modeling/round-calculated-unit-prices-to-unit-amount-precision.md b/microsoft/knowledge/data-modeling/round-calculated-unit-prices-to-unit-amount-precision.md new file mode 100644 index 00000000..a87b2e17 --- /dev/null +++ b/microsoft/knowledge/data-modeling/round-calculated-unit-prices-to-unit-amount-precision.md @@ -0,0 +1,56 @@ +--- +bc-version: [all] +domain: data-modeling +keywords: [unit-price, direct-unit-cost, rounding, unit-amount-rounding-precision, currency, price-calculation, ishandled, onupdateunitpriceonbeforefindprice, line-amount, e-invoicing] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Code that calculates a unit price rounds it to the currency's Unit-Amount Rounding Precision + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +Business Central rounds a unit price only where its own code computes one, above all in the standard price calculation. `Price Calculation Buffer Mgt.` converts a price list amount by tax, unit of measure, and currency, then calls `RoundPrice`, which rounds to the currency's `Unit-Amount Rounding Precision`, or to the one in `General Ledger Setup` when the currency code is blank. Assigning or validating the field does not round it. The `OnValidate` of `Sales Line`."Unit Price" and `Purchase Line`."Direct Unit Cost" only re-validates `Line Discount %`. `DecimalPlaces` and `AutoFormatType` format the value for display but do not round a value that code assigns or validates. A computed price is therefore stored with every decimal it has. + +The line amount is rounded: `UpdateAmounts` computes `Round(Quantity * "Unit Price", Currency."Amount Rounding Precision")`. Say a stored price of 49.7536 is displayed as 49.75. Four units then give a line amount of 199.01, while the printed document implies 4 × 49.75 = 199.00. Business Central reports no error. The difference shows up at the receiver, in an e-invoice validator, a customs declaration, or an EDI partner that recomputes quantity × price. + +The widest gap is a subscriber that takes over the price lookup: `Sales Line`'s `OnUpdateUnitPriceOnBeforeFindPrice` or `Purchase Line`'s `OnUpdateDirectUnitCostOnBeforeFindPrice` with `IsHandled := true`. This skips the price calculation and its rounding together. The subscriber takes over the guarantees of the calculation it replaces (compare `events/do-not-bypass-critical-operations-with-ishandled`, which looks at the same pattern from the publisher's side). Microsoft Learn says a currency's unit-amount precision rounds "all unit amounts in the currency". That describes the standard calculation and does not cover a price that code sets. + +**Not affected:** +- A value copied unchanged from a field that is already rounded, for example another document line. BaseApp copies a blanket order line's price this way. +- A price taken unchanged from an authoritative external document, such as an EDI order or a vendor invoice with four decimals. Rounding it would change the agreed price, so keep it and make the document layout show the full precision. An exact change of representation, such as minor currency units divided by 100, stays in this category. +- BaseApp's `CalcUnitPriceUsingUOMCoef`. For a credit memo copied from a posted invoice, it rescales the invoiced price to another unit of measure without rounding, to reproduce the posted price. +- A price returned by the standard price calculation. + +## Best Practice + +Round once, at the end, after every factor: markup, customer factor, unit of measure, and currency conversion. Use the precision of the document's currency. `Currency.Initialize(SalesHeader."Currency Code")` loads the currency, and for a blank code it takes the precisions from `General Ledger Setup`. Then assign `Round(Price, Currency."Unit-Amount Rounding Precision")`. Rounding intermediate results adds rounding error. BaseApp follows the same rule when it computes a price itself: the `Prices Including VAT` conversion on `Sales Header` and the `Unit Cost` conversion on `Sales Line` both round to `Unit-Amount Rounding Precision`. + +Prefer adjusting the standard result to replacing it. Let the price calculation run, change its price in `OnUpdateUnitPriceByFieldOnAfterFindPrice`, and round the adjusted value. BaseApp validates `Unit Price` after that event. A subscriber that must set `IsHandled := true` rounds the price before it assigns it. + +See sample: [`round-calculated-unit-prices-to-unit-amount-precision.good.al`](round-calculated-unit-prices-to-unit-amount-precision.good.al). + +## Anti Pattern + +These shapes are the anti-pattern: +- Assigning or validating `Unit Price` or `Direct Unit Cost` with a value computed by `*`, `/`, `ExchangeAmtLCYToFCY`, or `ExchangeAmtFCYToLCY`, without a `Round` to the unit-amount precision of the line's currency. +- An `OnUpdateUnitPriceOnBeforeFindPrice` or `OnUpdateDirectUnitCostOnBeforeFindPrice` subscriber that sets `IsHandled := true` and assigns a price that is not rounded. This includes a price copied from a custom price table whose stored values are not rounded. + +A hardcoded precision such as `Round(Price, 0.01)` is wrong for a currency whose unit-amount precision differs. + +Do not report the cases listed under **Not affected**. Do not report code that already rounds the final price to `Unit-Amount Rounding Precision`. + +See sample: [`round-calculated-unit-prices-to-unit-amount-precision.bad.al`](round-calculated-unit-prices-to-unit-amount-precision.bad.al). + +## References + +- [BCApps: `Price Calculation Buffer Mgt.` `CalcUnitAmountRoundingPrecision`, `RoundPrice`, `ConvertAmount`](https://github.com/microsoft/BCApps/blob/837ef802485ee457e52310d2ecaa08b93d0122fd/src/Layers/W1/BaseApp/Pricing/Calculation/PriceCalculationBufferMgt.Codeunit.al#L109-L169). +- [BCApps: `Sales Line` field 22 `Unit Price` `OnValidate`](https://github.com/microsoft/BCApps/blob/837ef802485ee457e52310d2ecaa08b93d0122fd/src/Layers/W1/BaseApp/Sales/Document/SalesLine.Table.al#L933-L950), [`UpdateUnitPriceByField`](https://github.com/microsoft/BCApps/blob/837ef802485ee457e52310d2ecaa08b93d0122fd/src/Layers/W1/BaseApp/Sales/Document/SalesLine.Table.al#L5328-L5380), [`UpdateAmounts` line amount](https://github.com/microsoft/BCApps/blob/837ef802485ee457e52310d2ecaa08b93d0122fd/src/Layers/W1/BaseApp/Sales/Document/SalesLine.Table.al#L5884), [`Unit Cost` rounding](https://github.com/microsoft/BCApps/blob/837ef802485ee457e52310d2ecaa08b93d0122fd/src/Layers/W1/BaseApp/Sales/Document/SalesLine.Table.al#L993-L1003), [`CalcUnitPriceUsingUOMCoef`](https://github.com/microsoft/BCApps/blob/837ef802485ee457e52310d2ecaa08b93d0122fd/src/Layers/W1/BaseApp/Sales/Document/SalesLine.Table.al#L11043-L11055). +- [BCApps: `Purchase Line` field 22 `Direct Unit Cost` `OnValidate`](https://github.com/microsoft/BCApps/blob/837ef802485ee457e52310d2ecaa08b93d0122fd/src/Layers/W1/BaseApp/Purchases/Document/PurchaseLine.Table.al#L785-L801), [`UpdateDirectUnitCostByField`](https://github.com/microsoft/BCApps/blob/837ef802485ee457e52310d2ecaa08b93d0122fd/src/Layers/W1/BaseApp/Purchases/Document/PurchaseLine.Table.al#L5242-L5304). +- [BCApps: `Sales Header` `Prices Including VAT` conversion rounds the price](https://github.com/microsoft/BCApps/blob/837ef802485ee457e52310d2ecaa08b93d0122fd/src/Layers/W1/BaseApp/Sales/Document/SalesHeader.Table.al#L1054-L1055). +- [BCApps: `Currency.Initialize` and `InitRoundingPrecision`](https://github.com/microsoft/BCApps/blob/837ef802485ee457e52310d2ecaa08b93d0122fd/src/Layers/W1/BaseApp/Finance/Currency/Currency.Table.al#L1237-L1257). +- [Set up currencies: unit-amount rounding](https://learn.microsoft.com/dynamics365/business-central/finance-set-up-currencies#unit-amount-rounding): "The unit-amount rounding feature is used automatically every time you enter an item or resource number on a sales line." +- [DecimalPlaces property](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/properties/devenv-decimalplaces-property): "This setting is evaluated on text boxes and fields during validation." diff --git a/microsoft/skills/review/al-data-modeling-review.md b/microsoft/skills/review/al-data-modeling-review.md index fa76847f..26fb8010 100644 --- a/microsoft/skills/review/al-data-modeling-review.md +++ b/microsoft/skills/review/al-data-modeling-review.md @@ -39,7 +39,7 @@ Narrow the relevant files to the subset that applies to the changes under review - The changed AL object names and types — especially `* Setup` singleton tables and Card pages, custom master tables, tableextensions that add master-data fields, document or journal lines that reference a master, document pages/codeunits exposing print/email/Post-and-Send actions, codeunits subscribing to `Navigate`, enumextensions to `"Report Selection Usage"`/`"Price Calculation Handler"`/`"Price Source Type"`, and report objects that render barcodes. - The changed fields, keys, triggers, and procedures, weighted toward `Primary Key`, `No.`, `No. Series`, `Blocked`, `Last Date Modified`, `OnInsert`, `OnModify`, `OnRename`, reference-field `OnValidate`, posting validation, and posting-cascade `TransferFields` calls. -- Tokens extracted from the diff that relate to data modeling (`setup`, `master`, `Primary Key`, `Code[10]`, `Code[20]`, `AutoIncrement`, `SystemId`, `No.`, `No. Series`, `NoSeriesManagement`, `Codeunit "No. Series"`, `GetNextNo`, `IsManual`, `TestManual`, `Blocked`, `TestField`, `Last Date Modified`, `Today`, `WorkDate`, `InsertAllowed`, `DeleteAllowed`, `PageType = Card`, `OnOpenPage`, `GetRecordOnce`, `OnInsert`, `OnModify`, `OnRename`, `InitRecord`, `Round`, `Precision`, `Direction`, `TableRelation`, `ValidateTableRelation`, `TestTableRelation`, `Text[`, `tableextension`, `enumextension`, `Media`, `MediaSet`, `Item`, `Count`, `TransferFields`, `Navigate`, `OnAfterFindRecords`, `OnBeforeShowRecords`, `Report Selections`, `Report Selection Usage`, `InsertRecord`, `Document Sending Profile`, `PrintForCust`, `PrintWithDialogForCust`, `PrintWithDialogForVend`, `SendEmailToCust`, `SendEmailToVendor`, `Report.RunModal`, `Report.Run`, `Price Calculation Handler`, `Price Calculation`, `OnFindSupportedSetup`, `Price Calculation Setup`, `Price Source Type`, `PriceSourceList`, `OnAfterAddSources`, `UpdateUnitPrice`, `PlanPriceCalcByField`, `UpdateUnitPriceByField`, `Prices Including VAT`, `Unit Price`, `Direct Unit Cost`, `Line Amount`, `Prepmt. Line Amount`, `Amount Including VAT`, `CalculateOutstandingAmountExclTax`, `Barcode Font Provider`, `Barcode Font Provider 2D`, `EncodeFont`, `ValidateInput`, `Insert`, `Delete`, `DeleteAll`). +- Tokens extracted from the diff that relate to data modeling (`setup`, `master`, `Primary Key`, `Code[10]`, `Code[20]`, `AutoIncrement`, `SystemId`, `No.`, `No. Series`, `NoSeriesManagement`, `Codeunit "No. Series"`, `GetNextNo`, `IsManual`, `TestManual`, `Blocked`, `TestField`, `Last Date Modified`, `Today`, `WorkDate`, `InsertAllowed`, `DeleteAllowed`, `PageType = Card`, `OnOpenPage`, `GetRecordOnce`, `OnInsert`, `OnModify`, `OnRename`, `InitRecord`, `Round`, `Precision`, `Direction`, `TableRelation`, `ValidateTableRelation`, `TestTableRelation`, `Text[`, `tableextension`, `enumextension`, `Media`, `MediaSet`, `Item`, `Count`, `TransferFields`, `Navigate`, `OnAfterFindRecords`, `OnBeforeShowRecords`, `Report Selections`, `Report Selection Usage`, `InsertRecord`, `Document Sending Profile`, `PrintForCust`, `PrintWithDialogForCust`, `PrintWithDialogForVend`, `SendEmailToCust`, `SendEmailToVendor`, `Report.RunModal`, `Report.Run`, `Price Calculation Handler`, `Price Calculation`, `OnFindSupportedSetup`, `Price Calculation Setup`, `Price Source Type`, `PriceSourceList`, `OnAfterAddSources`, `UpdateUnitPrice`, `PlanPriceCalcByField`, `UpdateUnitPriceByField`, `Prices Including VAT`, `Unit Price`, `Direct Unit Cost`, `Line Amount`, `Unit-Amount Rounding Precision`, `OnUpdateUnitPriceOnBeforeFindPrice`, `OnUpdateDirectUnitCostOnBeforeFindPrice`, `OnUpdateUnitPriceByFieldOnAfterFindPrice`, `ExchangeAmtLCYToFCY`, `ExchangeAmtFCYToLCY`, `Prepmt. Line Amount`, `Amount Including VAT`, `CalculateOutstandingAmountExclTax`, `Barcode Font Provider`, `Barcode Font Provider 2D`, `EncodeFont`, `ValidateInput`, `Insert`, `Delete`, `DeleteAll`). A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file — its `## Best Practice` / `## Anti Pattern` bodies — only after it makes the worklist; candidate selection uses the index alone. When the diff contains no data-modeling changes by any of the above signals, return `outcome: "not-applicable"` without evaluating files. @@ -70,6 +70,7 @@ The following targeted checks cover every current `data-modeling` article. Treat - An `enumextension` extends `"Price Source Type"` with a new value intended for a sales, purchase, or job price list, without extending the matching document subset enum (`"Sales Price Source Type"`, `"Purchase Price Source Type"`, `"Job Price Source Type"`) with a value at the same numeric ID — `extend-price-source-type-must-sync-document-subset-enum`. - A codeunit subscribes to `"Sales Line - Price"`'s `OnAfterAddSources` to register a custom field as a price source via `PriceSourceList.Add`, but that field has no `OnValidate` (or matching `OnAfterValidate`) that triggers recalculation — either `SalesLine.UpdateUnitPrice()`, or the explicit `SalesLine.PlanPriceCalcByField()` followed by `SalesLine.UpdateUnitPriceByField()`. A bare `UpdateUnitPriceByField` without a preceding `PlanPriceCalcByField` for the same field number does not count as recalculation (it exits without recalculating) — `new-price-source-must-add-candidate-and-trigger-recalculation`. - Code reads `Unit Price`, `Direct Unit Cost`, `Line Amount`, `Line Discount Amount`, `Inv. Discount Amount`, `Prepmt. Line Amount`, `Prepmt. Amt. Inv.`, `Prepmt Amt to Deduct`, `Prepmt Amt Deducted`, or the result of `CalculateOutstandingAmountExclTax` of a `Sales Line`/`Purchase Line`/`Service Line` as a known net or gross value (a net/gross total, a VAT computation, a comparison with `Amount` or `Item."Unit Price"`/`"Last Direct Cost"`, an export), or writes a source price of known basis into `Unit Price`/`Direct Unit Cost`, without reading the document header's `Prices Including VAT` — `document-line-prices-follow-prices-including-vat`. Reads of fixed-basis fields (`Amount`, `Amount Including VAT`, `Prepayment Amount`, `Prepmt. Amt. Incl. VAT`), combinations of header-dependent fields with each other, prices returned by the standard price calculation, and copies between lines of the same document are not this anti-pattern. +- Code assigns or validates `Unit Price` (`Sales Line`) or `Direct Unit Cost` (`Purchase Line`) with a value computed by `*`, `/`, `ExchangeAmtLCYToFCY`, or `ExchangeAmtFCYToLCY` without a `Round` to the line currency's `Unit-Amount Rounding Precision`; or an `OnUpdateUnitPriceOnBeforeFindPrice`/`OnUpdateDirectUnitCostOnBeforeFindPrice` subscriber sets `IsHandled := true` and assigns a price that is not rounded to that precision — `round-calculated-unit-prices-to-unit-amount-precision`. Values copied unchanged from an already-rounded field or from an authoritative external document, and prices returned by the standard price calculation, are not this anti-pattern. - A report hand-constructs a barcode string only where a concrete, independently provable defect is visible: the source value can contain characters outside the symbology's character set and is never validated, a checksum the symbology/setup requires is never applied, or there is concrete evidence of an incompatible font binding. Do not flag manual start/stop delimiters by themselves — `*value*` is a documented, valid Code 39 form for IDAutomation fonts (IDAutomation also accepts parentheses), so delimiter choice alone is never a finding. Also flag module use that does not match the interface: a 1D `"Barcode Font Provider"` path must call both `ValidateInput` and `EncodeFont`; a 2D `"Barcode Font Provider 2D"` path calls `EncodeFont` only (the 2D interface has no `ValidateInput`, so its absence there is not a finding). Separately, flag an otherwise correctly encoded barcode whose report layout names an evaluation/demo font instead of the purchased production font name — `report-barcodes-must-use-barcode-module-and-production-font-name`. - A new field is typed `Code`/`Text` and its `OnValidate` calls `DimensionManagement`/`DimMgt`, or a table adds Shortcut Dimension fields, a `Dimension Set ID` field, or `AddDimSource`/`GetDefaultDimID` — `dimension-management-wiring`. A master table calling `SaveDefaultDim` and a document/journal table computing its own `Dimension Set ID` are two different valid shapes; do not flag a master table for lacking a `Dimension Set ID` field or a document for lacking `SaveDefaultDim`. - A journal-based posting codeunit is added or changed and validation, Journal-table access, ledger writes, and user-interaction (`Confirm`/dialogs) all occur in one procedure or one codeunit, rather than split across `Check Line`/`Post Line`/`Post Batch`-shaped companions — `check-post-line-batch-pattern`. A document posting routine calling `Post Line` directly without a `Post Batch` companion is not this anti-pattern.