Add VAT rate resolution and VAT amount difference for E-Document purchases#8251
Add VAT rate resolution and VAT amount difference for E-Document purchases#8251ventselartur wants to merge 62 commits into
Conversation
…te/ventselartur/PayablesAgentVATSpike
…Line Prefer TaxRate (string, unambiguous percentage) over Tax (currency, ambiguous). Fall back to computing Tax/Amount*100 only when Tax contains a monetary amount (no % in value_text). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds enum value 2 ("VAT Rate Mismatch") to E-Document Notification Type,
with parallel Add/Dismiss/Disable procedures in the notification codeunit
and an updated SendPurchaseDocumentDraftNotifications that fans out to both
notification types via SetFilter.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…Prepare Draft Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…nvoice line Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…repare Draft Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replaces the notification banner approach with a persisted boolean field and an inline warning column on the draft subform, following the PO matching warning pattern. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…racted rate Instead of blindly clearing the mismatch flag when any posting group is selected, look up the VAT Posting Setup and compare its VAT % against the line's extracted VAT Rate. Only clear the warning when the rates actually match. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…date Remove rounding tolerance — compare VAT % from setup against extracted rate with exact equality. Zero-rate lines also go through the comparison instead of being skipped. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The lookup opens the VAT Posting Setup page filtered by the vendor's VAT Bus. Posting Group so the user only sees relevant combinations. Selection runs through Validate to trigger mismatch re-evaluation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…te/ventselartur/PayablesAgentVATSpike
Full VAT and Sales Tax do not use VAT % for rate-based matching. Filter FindVATProductPostingGroup, OnValidate, and OnLookup to exclude those calculation types. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…alidate Tests cover: Full VAT and Sales Tax ignored during Prepare Draft, Reverse Charge VAT resolved, OnValidate clears/keeps mismatch based on rate comparison, mismatch set when clearing posting group, Full VAT skips comparison, and zero-rate matching. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
7 tasks covering: table field + triggers, Prepare Draft refactor, subform warning column, notification removal, and 9 tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…. Posting Group Field 111 [BC] VAT Rate Mismatch persists whether VAT resolution failed. OnValidate re-evaluates mismatch by comparing VAT Posting Setup rate against extracted rate, filtering to Normal/Reverse Charge VAT only. OnLookup opens VAT Posting Setup filtered by vendor's bus posting group.
…re Draft Replace HasUnresolvedVATLines + notification call with direct [BC] VAT Rate Mismatch field assignment. Filter FindVATProductPostingGroup to Normal VAT and Reverse Charge VAT calculation types only.
Per-line warning with Ambiguous styling, conditional visibility when any line has a mismatch, and drill-down showing the extracted VAT rate. Follows the PO matching warning pattern.
Notification replaced by persisted [BC] VAT Rate Mismatch field and inline warning column on draft subform.
…notification Rename and rewrite the mismatch test to assert [BC] VAT Rate Mismatch boolean. Add mismatch=false assertion to the successful resolution test.
Full VAT and Sales Tax setups are excluded from resolution. Reverse Charge VAT setups are matched successfully.
Covers: rate match clears mismatch, rate mismatch persists, clearing group sets mismatch, Full VAT skips comparison, zero-rate matching works.
Extract 10 VAT-related test procedures into new codeunit 139896 for better organization. No test logic changes. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…te/ventselartur/PayablesAgentVATSpike
…te/ventselartur/PayablesAgentVATSpike
| LastReceiptNo := EDocLineByReceipt.ReceiptNo; | ||
| end; | ||
| EDocLineByReceipt.Close(); | ||
| EDocPurchaseDocumentHelper.ApplyVATDifferenceToLines(PurchaseHeader, EDocumentPurchaseHeader); |
There was a problem hiding this comment.
ApplyVATDifference called before invoice discount
ApplyVATDifferenceToLines is invoked before ApplyInvDiscBasedOnAmt (line 181), so every PurchaseLine."Inv. Discount Amount" is still 0 at distribution time. The subtraction PurchaseLine."Line Amount" - PurchaseLine."Inv. Discount Amount" in the helper is therefore dead code and the proportional basis always equals the gross line amount, ignoring document-level discounts.
Recommendation:
- Move
ApplyVATDifferenceToLinesto afterPurchCalcDiscByType.ApplyInvDiscBasedOnAmtso invoice-discount amounts are already populated on the purchase lines when the VAT difference is distributed.
| EDocPurchaseDocumentHelper.ApplyVATDifferenceToLines(PurchaseHeader, EDocumentPurchaseHeader); | |
| PurchaseHeader.Modify(); | |
| PurchCalcDiscByType.ApplyInvDiscBasedOnAmt(EDocumentPurchaseHeader."Total Discount", PurchaseHeader); | |
| EDocPurchaseDocumentHelper.ApplyVATDifferenceToLines(PurchaseHeader, EDocumentPurchaseHeader); | |
| exit(PurchaseHeader); |
👍 useful · ❤️ especially valuable · 👎 wrong - reply with why
| LineAmount := PurchaseLine."Line Amount" - PurchaseLine."Inv. Discount Amount"; | ||
| if LineAmount <> 0 then begin | ||
| VATDiffForLine := VATDiffRemainder + EDocumentPurchaseHeader."Applied VAT Amount Diff." * LineAmount / TotalLineAmount; | ||
| PurchaseLine."VAT Difference" := Round(VATDiffForLine, Currency."Amount Rounding Precision"); |
There was a problem hiding this comment.
VAT Difference set without Validate, skips BC checks
PurchaseLine."VAT Difference" := Round(...) is a direct field assignment that bypasses the OnValidate trigger and BC's internal purchase-line recalculation logic. This can leave VAT-related totals on the header inconsistent and will not respect any line-level VAT difference constraints.
Recommendation:
- Use
PurchaseLine.Validate("VAT Difference", Round(VATDiffForLine, Currency."Amount Rounding Precision"))and then callPurchaseLine.Modify(true)to ensure all dependent fields are recalculated.
| PurchaseLine."VAT Difference" := Round(VATDiffForLine, Currency."Amount Rounding Precision"); | |
| PurchaseLine.Validate("VAT Difference", Round(VATDiffForLine, Currency."Amount Rounding Precision")); | |
| VATDiffRemainder := VATDiffForLine - PurchaseLine."VAT Difference"; | |
| PurchaseLine.Modify(true); |
👍 useful · ❤️ especially valuable · 👎 wrong - reply with why
| EDocPurchLine.SetRange("E-Document Entry No.", EDocEntryNo); | ||
| if EDocPurchLine.FindSet() then | ||
| repeat | ||
| TotalLineAmount += Round(EDocPurchLine.Quantity * EDocPurchLine."Unit Price" - EDocPurchLine."Total Discount"); |
There was a problem hiding this comment.
ComputeTotalLineAmount uses default Round precision
Round(EDocPurchLine.Quantity * EDocPurchLine."Unit Price" - EDocPurchLine."Total Discount") uses BC's default 2-decimal rounding, while ApplyVATDifferenceToLines distributes using Currency."Amount Rounding Precision". For currencies with non-standard precision (e.g. JPY=0, BHD=3), the denominator will differ from the actual purchase line amounts, causing the sum of distributed VAT differences to diverge from Applied VAT Amount Diff..
Recommendation:
- Pass the currency's amount rounding precision into
ComputeTotalLineAmount(or read it from the purchase header) and use it in theRound()call to stay consistent withApplyVATDifferenceToLines.
| TotalLineAmount += Round(EDocPurchLine.Quantity * EDocPurchLine."Unit Price" - EDocPurchLine."Total Discount"); | |
| TotalLineAmount += Round( | |
| EDocPurchLine.Quantity * EDocPurchLine."Unit Price" - EDocPurchLine."Total Discount", | |
| Currency."Amount Rounding Precision"); |
👍 useful · ❤️ especially valuable · 👎 wrong - reply with why
| if EDocumentPurchaseLine.FindSet() then | ||
| repeat | ||
| LineAmount := Round(EDocumentPurchaseLine.Quantity * EDocumentPurchaseLine."Unit Price" - EDocumentPurchaseLine."Total Discount"); | ||
| LineVATAmount := Round(LineAmount * EDocumentPurchaseLine."VAT Rate" / 100); |
There was a problem hiding this comment.
TotalLineVATAmount uses default Round, ignores currency precision
LineVATAmount := Round(LineAmount * EDocumentPurchaseLine."VAT Rate" / 100) applies the default 2-decimal rounding, independent of the document's currency. For currencies with fewer decimals (JPY) or more (BHD), the computed TotalLineVATAmount will diverge from the actual purchase line VAT amounts, generating a spurious or inflated Applied VAT Amount Diff..
Recommendation:
- Retrieve the currency record for the e-document (or use the default amount rounding precision) and pass it as the second argument to
Round().
| LineVATAmount := Round(LineAmount * EDocumentPurchaseLine."VAT Rate" / 100); | |
| LineVATAmount := Round(LineAmount * EDocumentPurchaseLine."VAT Rate" / 100, Currency."Amount Rounding Precision"); |
👍 useful · ❤️ especially valuable · 👎 wrong - reply with why
| if (VATRate = 0) and (LineCount = 1) and | ||
| (EDocumentPurchaseHeader."Total VAT" > 0) and (EDocumentPurchaseHeader."Sub Total" > 0) | ||
| then | ||
| VATRate := Round((EDocumentPurchaseHeader."Total VAT" / EDocumentPurchaseHeader."Sub Total") * 100, 0.01); |
There was a problem hiding this comment.
Single-line VAT rate fallback uses header-level Sub Total
For a single-line document where VAT Rate = 0, the fallback computes VATRate = ("Total VAT" / "Sub Total") * 100 from header fields. If the header Sub Total includes charges or differs from the individual line's base (e.g. due to header discounts, rounding, or fees), the computed rate will be wrong and an incorrect VAT Prod. Posting Group will be assigned.
Recommendation:
- Prefer using the line's own
Quantity * "Unit Price" - "Total Discount"as the denominator, falling back to the headerSub Totalonly when the line amount is zero.
| VATRate := Round((EDocumentPurchaseHeader."Total VAT" / EDocumentPurchaseHeader."Sub Total") * 100, 0.01); | |
| var LineBase: Decimal; | |
| LineBase := EDocumentPurchaseLine.Quantity * EDocumentPurchaseLine."Unit Price" - EDocumentPurchaseLine."Total Discount"; | |
| if LineBase = 0 then | |
| LineBase := EDocumentPurchaseHeader."Sub Total"; | |
| if LineBase > 0 then | |
| VATRate := Round((EDocumentPurchaseHeader."Total VAT" / LineBase) * 100, 0.01); |
👍 useful · ❤️ especially valuable · 👎 wrong - reply with why
| if not Rec."[BC] VAT Rate Mismatch" then | ||
| exit; | ||
| EDocPurchDocHelper.SetNormalReverseChargeFilter(VATPostingSetup, VendVATBusPostingGroupCode); | ||
| VATPostingSetupRef.GetTable(VATPostingSetup); |
There was a problem hiding this comment.
RecordRef.GetTable() called on unpositioned record
In LogVATRateMismatch, VATPostingSetupRef.GetTable(VATPostingSetup) is called after filters are applied but without FindFirst(). The RecordRef captures the table metadata but holds no actual row, so .SetReferenceSource() produces an invalid or empty drilldown link in the activity log.
Recommendation:
- Either position the record with
VATPostingSetup.FindFirst()before callingGetTable, or remove the reference source if no specific record is to be linked.
| VATPostingSetupRef.GetTable(VATPostingSetup); | |
| EDocPurchDocHelper.SetNormalReverseChargeFilter(VATPostingSetup, VendVATBusPostingGroupCode); | |
| if VATPostingSetup.FindFirst() then begin | |
| VATPostingSetupRef.GetTable(VATPostingSetup); | |
| ActivityLog.SetReferenceSource(Page::"VAT Posting Setup", VATPostingSetupRef); | |
| end; |
👍 useful · ❤️ especially valuable · 👎 wrong - reply with why
| if Rec."[BC] VAT Rate Mismatch" then | ||
| exit; | ||
| EDocPurchDocHelper.SetNormalReverseChargeFilter(VATPostingSetup, VendVATBusPostingGroupCode); | ||
| VATPostingSetupRef.GetTable(VATPostingSetup); |
There was a problem hiding this comment.
LogVATRateResolved: same unpositioned RecordRef issue
Identical to the LogVATRateMismatch problem: VATPostingSetupRef.GetTable(VATPostingSetup) is called after setting filters but before any FindFirst(), yielding a RecordRef with no current record and an invalid activity-log reference link.
Recommendation:
- Call
VATPostingSetup.FindFirst()beforeVATPostingSetupRef.GetTable(VATPostingSetup)and guard with anifblock, or pass the specific resolved record as a parameter.
| VATPostingSetupRef.GetTable(VATPostingSetup); | |
| EDocPurchDocHelper.SetNormalReverseChargeFilter(VATPostingSetup, VendVATBusPostingGroupCode); | |
| if VATPostingSetup.FindFirst() then begin | |
| VATPostingSetupRef.GetTable(VATPostingSetup); | |
| ActivityLog.SetReferenceSource(Page::"VAT Posting Setup", VATPostingSetupRef); | |
| end; |
👍 useful · ❤️ especially valuable · 👎 wrong - reply with why
| // Strip common non-numeric prefixes/suffixes: "VAT 20%", "20%", "20.0%", "20" | ||
| CleanedText := TaxRateText.Replace('%', '').Trim(); | ||
| // Remove common prefixes like "VAT ", "Tax ", etc. | ||
| if CleanedText.StartsWith('VAT ') then |
There was a problem hiding this comment.
ParsePercentageFromText prefix stripping is case-sensitive
CleanedText.StartsWith('VAT ') and StartsWith('Tax ') are case-sensitive in AL. Common real-world patterns like "vat 20%", "Vat 20%", or "tax 20%" will not match, causing Evaluate() to fail and returning -1, which silently falls through to the ambiguous tax monetary-amount field instead of the unambiguous taxRate field.
Recommendation:
- Convert the cleaned text to uppercase before the prefix checks, or use
LowerCase()comparisons.
| if CleanedText.StartsWith('VAT ') then | |
| CleanedText := UpperCase(TaxRateText.Replace('%', '').Trim()); | |
| if CleanedText.StartsWith('VAT ') then | |
| CleanedText := CopyStr(CleanedText, 5).Trim(); | |
| if CleanedText.StartsWith('TAX ') then | |
| CleanedText := CopyStr(CleanedText, 5).Trim(); | |
| if Evaluate(ParsedValue, CleanedText) then | |
| exit(ParsedValue); | |
| exit(-1); |
👍 useful · ❤️ especially valuable · 👎 wrong - reply with why
| DataClassification = CustomerContent; | ||
| InitValue = true; | ||
| } | ||
| field(6104; "Resolve VAT Group Purch EDoc"; Boolean) |
There was a problem hiding this comment.
Resolve VAT Group defaults false, Apply VAT Diff defaults true
After upgrade, existing tenants will have "Apply VAT Diff. For Purch EDoc" = true (explicit InitValue = true) but "Resolve VAT Group Purch EDoc" = false (no InitValue). VAT differences will be redistributed across lines that still carry the BC-default VAT Prod. Posting Group rather than the e-document-matched one, potentially resulting in incorrect VAT postings.
Recommendation:
- Either add
InitValue = trueto"Resolve VAT Group Purch EDoc"to match the paired field's default, or setInitValue = falseon"Apply VAT Diff. For Purch EDoc"so both features are opt-in together.
| field(6104; "Resolve VAT Group Purch EDoc"; Boolean) | |
| field(6104; "Resolve VAT Group Purch EDoc"; Boolean) | |
| { | |
| Caption = 'Resolve VAT Product Group for Purch. E-Doc.'; | |
| DataClassification = CustomerContent; | |
| InitValue = true; | |
| } |
👍 useful · ❤️ especially valuable · 👎 wrong - reply with why
sorenfriisalexandersen
left a comment
There was a problem hiding this comment.
Added a suggestion for tooltip
|
@ventselartur please read the following Contributor License Agreement(CLA). If you agree with the CLA, please reply with the following information.
Contributor License AgreementContribution License AgreementThis Contribution License Agreement (“Agreement”) is agreed to by the party signing below (“You”),
|
Groenbech96
left a comment
There was a problem hiding this comment.
I would consider not putting this on the table.
You can compute this easily, and arguably it will be more correct to not store a value, but always compute it (Stale values....)
VATAmountDiff := EDocumentPurchaseHeader."Total VAT" - TotalLineVATAmount;
My suggestion would be to have a set of functions that compute this diff, and then use those where needed.
Why
When importing e-documents for purchase processing, the system did not automatically resolve VAT Product Posting Groups from extracted VAT rates. Users had to manually identify and assign the correct VAT posting group for each line, which was error-prone and time-consuming. Additionally, rounding differences between the document's total VAT and the computed line-level VAT amounts were not reconciled, leading to posting discrepancies.
Summary
[BC] VAT Prod. Posting Groupand[BC] VAT Rate Mismatchfields on E-Document Purchase Line with OnValidate/OnLookup supporttaxRatefield and properly disambiguate thetaxfield between percentage and monetary valuesE-Doc Purch. VAT Testscovering resolution, mismatch detection, OnValidate behavior, and VAT calculation type filteringFixes AB#619564