Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// BAD: only the changed row is validated (From < To), and there is NO OnDelete check.
// Deleting an interior band opens a gap that nothing rejects; amounts in that range
// then fall through to the wrong tier at calculation time.
table 50100 "Bad Tier Band"
{
fields
{
field(1; "Plan Code"; Code[20]) { }
field(2; "From Amount"; Decimal) { }
field(3; "To Amount"; Decimal) { }
field(4; "Rate %"; Decimal) { }
}
keys { key(PK; "Plan Code", "From Amount") { } }

trigger OnInsert()
begin
if (Rec."To Amount" <> 0) and (Rec."To Amount" <= Rec."From Amount") then
Error('To must exceed From.'); // per-row only: ignores gaps/overlaps across the set
end;

// No OnModify / OnDelete contiguity check -> a deleted interior band silently opens a gap.
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// GOOD: every insert/modify/delete re-validates the WHOLE ordered band set for
// contiguity: first band at 0, each From = the prior To (no gap, no overlap), only the
// last band open-ended. (Build the post-change set with the stage-siblings/overlay-Rec
// pattern so the in-flight or being-deleted row is reflected.)
table 50101 "Good Tier Band"
{
fields
{
field(1; "Plan Code"; Code[20]) { }
field(2; "From Amount"; Decimal) { }
field(3; "To Amount"; Decimal) { }
field(4; "Rate %"; Decimal) { }
}
keys { key(PK; "Plan Code", "From Amount") { } }

trigger OnInsert() begin ValidateContiguity(Rec."Plan Code"); end;

trigger OnModify() begin ValidateContiguity(Rec."Plan Code"); end;

trigger OnDelete() begin ValidateContiguityAfterDelete(Rec); end;

local procedure ValidateContiguity(PlanCode: Code[20])
var
Band: Record "Good Tier Band"; // in practice: the staged post-change set (siblings + overlaid Rec)
ExpectedFrom: Decimal;
IsFirst: Boolean;
begin
Band.SetRange("Plan Code", PlanCode);
Band.SetCurrentKey("Plan Code", "From Amount");
IsFirst := true;
if Band.FindSet() then
repeat
if IsFirst then begin
if Band."From Amount" <> 0 then
Error('The first band must start at 0.');
IsFirst := false;
end else
if Band."From Amount" <> ExpectedFrom then
Error('Bands must be contiguous: no gaps or overlaps.');
ExpectedFrom := Band."To Amount"; // next band must start where this one ends
until Band.Next() = 0;
end;

local procedure ValidateContiguityAfterDelete(var DeletedBand: Record "Good Tier Band")
begin
// Re-validate the set with DeletedBand removed so an interior delete that opens a gap is rejected.
ValidateContiguity(DeletedBand."Plan Code");
end;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
bc-version: [all]
domain: reliability
keywords: [tier, bands, breakpoints, intervals, contiguous, gaps, overlaps, from-to, validation]
technologies: [al]
countries: [w1]
application-area: [all]
---

# Interval/breakpoint bands must be validated for contiguity (no gaps, no overlaps) across the whole set

## Description

When a feature models an ordered set of **breakpoint bands** — tier rate bands with a From/To amount, aging buckets, quantity breaks — the bands must form a contiguous cover: the first starts at the floor (0), each band's From equals the previous band's To, no two bands overlap, and only the highest band may be open-ended. A per-row check (`From < To`) is **not enough**: a gap or overlap is a property of the *set*, and the most dangerous case is a **delete** that removes an interior band and silently opens a gap, so amounts in that range fall through to the wrong band (or no band) at calculation time. The integrity must be re-validated on every insert, modify, AND delete, against the whole ordered set.

## Best Practice

On insert/modify/delete, validate the full ordered band set in one pass: sort by From; require the first From = the floor; require each subsequent From to equal the prior band's To (no gap, no overlap); allow only the last band to be open-ended (blank/max To). Build the post-change set with the stage-committed-siblings-and-overlay-Rec pattern so the in-flight row (or the row being deleted, staged out) is reflected — see `stage-committed-siblings-and-overlay-rec-for-set-validation-in-triggers`. Reject the change with a clear error rather than letting a gap/overlap reach the calculation.

See sample: `interval-bands-must-be-contiguous-no-gaps-or-overlaps.good.al`.

## Anti Pattern

A band table that validates only the changed row (e.g. `TestField`/`From < To`) and has no whole-set contiguity check, or that has an OnInsert/OnModify check but **no OnDelete** check. Detection signal: From/To band rows where calculation assumes a contiguous cover but nothing rejects a gap (deleted interior band) or an overlap (a new band straddling two existing ones). The defect surfaces only at calc time for amounts landing in the gap/overlap.

See sample: `interval-bands-must-be-contiguous-no-gaps-or-overlaps.bad.al`.

## See also

Originates from commissions-management Plan Builder (PB-3) tier breakpoint bands: From/To rate bands validated for no gaps/overlaps, first band at 0, highest open-ended, and a deleted interior band rejected for opening a gap. Pairs with `stage-committed-siblings-and-overlay-rec-for-set-validation-in-triggers` (how to see the in-flight set in the trigger).
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// BAD: matches the credit memo to its shipment via "Order No.", which is BLANK on a
// return-order-sourced credit memo. The loop finds no lines, so the source shipment's
// commission is never reversed.
codeunit 50100 "Bad Return Match Example"
{
procedure ReverseShipmentCommission(var SalesCrMemoHeader: Record "Sales Cr.Memo Header")
var
SalesCrMemoLine: Record "Sales Cr.Memo Line";
SalesShipmentLine: Record "Sales Shipment Line";
begin
SalesCrMemoLine.SetRange("Document No.", SalesCrMemoHeader."No.");
SalesCrMemoLine.SetRange(Type, SalesCrMemoLine.Type::Item);
SalesCrMemoLine.SetFilter("Order No.", '<>%1', ''); // empty on a return-order credit memo -> no rows
if SalesCrMemoLine.FindSet() then
repeat
SalesShipmentLine.SetRange("Order No.", SalesCrMemoLine."Order No.");
SalesShipmentLine.SetRange("Order Line No.", SalesCrMemoLine."Order Line No.");
// ... reverse the shipment commission (never reached)
until SalesCrMemoLine.Next() = 0;
end;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// GOOD: resolves the source shipment through the item application that survives a
// return-of-goods flow: the credit-memo line's "Appl.-from Item Entry" is the shipment's
// outbound Item Ledger Entry, whose "Document No." is the posted shipment.
codeunit 50101 "Good Return Match Example"
{
procedure ReverseShipmentCommission(var SalesCrMemoHeader: Record "Sales Cr.Memo Header")
var
SalesCrMemoLine: Record "Sales Cr.Memo Line";
ItemLedgerEntry: Record "Item Ledger Entry";
begin
SalesCrMemoLine.SetRange("Document No.", SalesCrMemoHeader."No.");
SalesCrMemoLine.SetRange(Type, SalesCrMemoLine.Type::Item);
SalesCrMemoLine.SetFilter("Appl.-from Item Entry", '<>%1', 0);
if SalesCrMemoLine.FindSet() then
repeat
if ItemLedgerEntry.Get(SalesCrMemoLine."Appl.-from Item Entry") then
if ItemLedgerEntry."Document Type" = ItemLedgerEntry."Document Type"::"Sales Shipment" then
; // reverse the commission sourced to ItemLedgerEntry."Document No." (the posted shipment)
until SalesCrMemoLine.Next() = 0;
end;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
bc-version: [all]
domain: reliability
keywords: [credit-memo, return-order, appl-from-item-entry, item-ledger-entry, order-no, sales-shipment, document-link]
technologies: [al]
countries: [w1]
application-area: [all]
---

# Match a return/credit memo to its source document via item application, not the order link

## Description

To reverse or relate a posted **return / credit memo** back to the original document it returns (a posted shipment, the original sale), do **not** key on the posted line's `"Order No."`/`"Order Line No."`. Those fields are **blank** on a return-order-sourced credit memo: the unposted `Sales Line` has no `"Order No."` field to carry, and `Sales-Post` stamps a posted line's `"Order No."` only when posting an **Order** document — a Return Order populates `"Return Order No."`/`"Return Receipt No."` instead. A join on `Sales Cr.Memo Line."Order No."` therefore matches nothing and the link silently never resolves (e.g. the original commission/cost is never reversed). The link that **does** survive a return-of-goods flow is the **item application**: the line's `"Appl.-from Item Entry"` points at the originating outbound `Item Ledger Entry`, whose `"Document No."`/`"Document Type"` identify the posted shipment/invoice.

## Best Practice

Resolve a return/credit-memo line to its source document through `"Appl.-from Item Entry"` → `Item Ledger Entry.Get(...)` → `ItemLedgerEntry."Document No."` (gated on the expected `"Document Type"`, e.g. `"Sales Shipment"`). This is the same chain BC uses for exact-cost reversing, so it is present whenever the return is applied to the goods. In tests, establish the application explicitly (copy from the posted shipment, or set `"Appl.-from Item Entry"` to the shipment line's `"Item Shpt. Entry No."`) — a return order copied from a posted shipment does not always carry it for free.

See sample: `match-return-to-source-via-item-application-not-order-link.good.al`.

## Anti Pattern

`SalesCrMemoLine.SetFilter("Order No.", '<>%1', '')` (or `SetRange("Order No.", SomeOrderNo)`) used to find the order/shipment a credit memo reverses. Detection signal: a posted `Sales Cr.Memo Line` (or `Return Receipt Line`) filtered/joined on `"Order No."`/`"Order Line No."` to reach a source Order or Shipment. Those fields are empty for the return-order flow, so the match resolves to nothing and the dependent action (reversal, cost link, traceability) is silently skipped.

See sample: `match-return-to-source-via-item-application-not-order-link.bad.al`.

## See also

Originates from commissions-management #59 (ENG-8): the on-shipment commission reversal matched credit-memo lines on `"Order No."` (blank on a return-order credit memo), so the original shipment commission was never flagged Reversed. Fixed by matching via `"Appl.-from Item Entry"` → the shipment's Item Ledger Entry `"Document No."`. Confirmed against the base-app symbols: the unposted `Sales Line` has no `"Order No."` field.
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// BAD: Net Payable is derived from Commission Amount, THEN the event fires letting a
// subscriber change Commission Amount, THEN the entry is recorded with no recompute.
// A subscriber that halves the gross leaves Net Payable computed from the old gross.
codeunit 50100 "Bad Mutation Hook Example"
{
procedure RecordEntry(var CommissionEntry: Record "Commission Ledger Entry")
var
IsHandled: Boolean;
begin
ApplyNetPayable(CommissionEntry); // Net Payable := f(Commission Amount)

OnBeforeRecordCommissionEntry(CommissionEntry, IsHandled); // subscriber may change Commission Amount
if IsHandled then
exit;

CommissionEntry.Insert(true); // Net Payable is now stale vs the modified Commission Amount
end;

[IntegrationEvent(false, false)]
local procedure OnBeforeRecordCommissionEntry(var CommissionEntry: Record "Commission Ledger Entry"; var IsHandled: Boolean)
begin
end;

local procedure ApplyNetPayable(var CommissionEntry: Record "Commission Ledger Entry")
begin
CommissionEntry."Net Payable Amount" := CommissionEntry."Commission Amount"; // (cap/draw omitted)
end;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// GOOD: after the (un-suppressed) mutation event, the derivation is re-run so Net Payable
// always follows the FINAL Commission Amount, whether or not a subscriber changed it.
codeunit 50101 "Good Mutation Hook Example"
{
procedure RecordEntry(var CommissionEntry: Record "Commission Ledger Entry")
var
IsHandled: Boolean;
begin
ApplyNetPayable(CommissionEntry);

OnBeforeRecordCommissionEntry(CommissionEntry, IsHandled);
if IsHandled then
exit;

// The subscriber may have changed the gross; re-derive so the entry is internally consistent.
ApplyNetPayable(CommissionEntry);

CommissionEntry.Insert(true);
end;

[IntegrationEvent(false, false)]
local procedure OnBeforeRecordCommissionEntry(var CommissionEntry: Record "Commission Ledger Entry"; var IsHandled: Boolean)
begin
end;

local procedure ApplyNetPayable(var CommissionEntry: Record "Commission Ledger Entry")
begin
CommissionEntry."Net Payable Amount" := CommissionEntry."Commission Amount"; // (cap/draw omitted)
end;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
bc-version: [all]
domain: reliability
keywords: [integration-event, onbefore, mutation-hook, derived-field, stale, ishandled, extensibility]
technologies: [al]
countries: [w1]
application-area: [all]
---

# Re-derive dependent fields after a record-mutation extension event

## Description

When code computes a **derived** field from a source field and also raises an `OnBefore…` integration event that lets a subscriber **modify the record** before it is recorded, the order matters. If the derivation runs first and the event fires after it, a subscriber that changes the source field leaves every dependent field computed from the **pre-modification** value — they are silently stale. The record is then inserted (and any side effect, e.g. a balance decrement, applied) with an internally inconsistent source-vs-derived state. This is a BC-specific extensibility trap: the event contract advertises "you may modify amount/status/dimensions," but the platform cannot know that a downstream field was derived from what the subscriber just changed.

## Best Practice

Establish a single invariant — *the dependent field is always re-derived from the final source value* — and enforce it relative to the event. Either (a) fire the mutation event **before** the derivation, so the derivation naturally consumes the modified value, or (b) **re-run the derivation after** the (un-suppressed) event returns, just before recording. Option (b) is cheap when the derivation is a pure in-memory recompute. Document that the dependent field is engine-derived (not a value the subscriber sets directly), so the contract is unambiguous. Add a test with a subscriber that modifies only the source field and asserts the dependent field is consistent.

See sample: `re-derive-dependent-fields-after-a-record-mutation-event.good.al`.

## Anti Pattern

`DeriveDependent(Entry)` → `OnBeforeRecord(Entry, IsHandled)` → `Entry.Insert()` with no re-derivation between the event and the insert. Detection signal: an `OnBefore…`/`OnBeforeInsert…` event passing the record `var` (modifiable) that fires **after** a field on that record was computed from another field on the same record, with no recompute before the write. A pure-suppression (`IsHandled`) subscriber is unaffected; only an amount/source-modifying subscriber exposes the stale derived field.

See sample: `re-derive-dependent-fields-after-a-record-mutation-event.bad.al`.

## See also

Originates from commissions-management #51 (Commission Engine close-out): the ENG-2 posting gateway raised `OnBeforeRecordCommissionEntry` after the ENG-9 net-payable derivation, so a subscriber halving `Commission Amount` left `Net Payable`/`Cap Deduction`/`Draw Offset` stale and decremented the draw by a stale offset. Fixed by re-running the net-payable derivation after the event.
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// BAD: OnAfterApplyCustLedgEntry fires during the apply CALC, before the application
// detailed entries post, so the invoice's Remaining Amount is still pre-payment.
// The cash % reads as 0 and the payment-triggered promotion never fires.
codeunit 50100 "Bad Apply Timing Example"
{
[EventSubscriber(ObjectType::Codeunit, Codeunit::"Gen. Jnl.-Post Line", 'OnAfterApplyCustLedgEntry', '', false, false)]
local procedure OnAfterApply(var CustLedgerEntry: Record "Cust. Ledger Entry")
begin
CustLedgerEntry.CalcFields("Remaining Amount"); // still the PRE-payment remaining here
if CustLedgerEntry."Remaining Amount" = 0 then
PromoteCommission(CustLedgerEntry."Entry No."); // never reached on a paying application
end;

local procedure PromoteCommission(InvoiceCustLedgerEntryNo: Integer)
begin
end;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// GOOD: react after the application is committed — subscribe to the Detailed Cust. Ledg.
// Entry insert for Entry Type = Application, where the applied invoice's Remaining Amount
// reflects the payment.
codeunit 50101 "Good Apply Timing Example"
{
[EventSubscriber(ObjectType::Table, Database::"Detailed Cust. Ledg. Entry", 'OnAfterInsertEvent', '', false, false)]
local procedure OnAfterInsertDtldCLE(var Rec: Record "Detailed Cust. Ledg. Entry")
var
InvoiceCustLedgerEntry: Record "Cust. Ledger Entry";
begin
if Rec."Entry Type" <> Rec."Entry Type"::Application then
exit;
if not InvoiceCustLedgerEntry.Get(Rec."Cust. Ledger Entry No.") then
exit;
InvoiceCustLedgerEntry.CalcFields("Remaining Amount"); // now reflects the applied payment
if InvoiceCustLedgerEntry."Remaining Amount" = 0 then
PromoteCommission(InvoiceCustLedgerEntry."Entry No.");
end;

local procedure PromoteCommission(InvoiceCustLedgerEntryNo: Integer)
begin
end;
}
Loading