Sales price agreement expired
Fires when a sales-price trade agreement lapses past its To date and no other sales price of the same agreement - account, item, currency, unit, quantity break and inventory dimensions - is in effect on the next day. Nothing is written to the database at that moment, so no table or posting hook can observe it; the event is raised by the Lattice effectivity batch, which on each run reports the sales prices whose last valid day has passed since the batch's last position. Subscribers can use it to remove the price from storefronts or external pricing engines, or to alert pricing teams that a customer or price group has been left without a price.
| Event class | MGCLatticeTradeAgreementPriceExpiredBusinessEvent |
| Contract class | MGCLatticeTradeAgreementPriceBusinessEventContract |
| Category | Sales orders |
| Module | ModuleAxapta::SalesOrder |
| Label IDs | TradeAgrPriceExpiredName / TradeAgrPriceExpiredDesc |
| Trigger | MGCLatticeTradeAgreementPriceEffectivityBatch.run() (the daily Lattice sales price effectivity events background process, in every legal entity; a run from the menu item, in the legal entity it runs in) -> sales-price ToDate after the sweep's position and before the run date, with no successor on the next day |
Trigger / Hook
Which path raises which event
The six sales price agreement events share one payload contract and are told apart by changeType / changeTypeName. All six are limited to sales price agreements - PriceDiscTable rows whose relation is Price (sales) (PriceType::PriceSales); purchase prices and discount agreements never raise them, and journal lines with another relation are ignored. Below, agreement means a row's account code and account relation, item code and item relation, currency, unit, quantity break (from quantity) and inventory dimensions; the dates and the amount are not part of it.
| Event | Raised by | Fires when |
|---|---|---|
| Sales price agreement created | Posting a trade-agreement journal | A posted journal line inserted a sales price for an agreement that had no sales price in effect on the new price's From date |
| Sales price agreement modified | Posting a trade-agreement journal | A posted journal line that references an existing sales price changed at least one of its values |
| Sales price agreement superseded | Posting a trade-agreement journal | A posted journal line inserted a sales price for an agreement that already had a sales price in effect on the new price's From date |
| Sales price agreement deleted | PriceDiscTable.delete(), and posting a journal line marked for deletion | A sales price is deleted |
| Sales price agreement effective | Effectivity batch (background process, every legal entity) | A sales price's From date is after the batch's position and on or before the run date |
| Sales price agreement expired (this event) | Effectivity batch (background process, every legal entity) | A sales price's To date is after the batch's position and before the run date, and no other sales price of the same agreement is in effect on the day after that To date |
Sales-price rows that are inserted or updated outside a journal post - for example through a data entity or by code that calls PriceDiscTable.insert() or update() - are not classified by these events; the record-based Sales price trade agreement changed event (catalog category Accounts receivable) reports them. The journal post itself writes PriceDiscTable with set-based statements; whether those writes also raise the record-based event depends on the Accounts receivable parameters Skip price agreement insert alerts (rows the post inserts) and Skip price agreement update alerts (rows it updates). Set to Yes, a journal post raises only the events in the table above; set to No, the record-based event is also raised for each such row.
Effectivity batch
- Batch class:
MGCLatticeTradeAgreementPriceEffectivityBatch(extendsRunBaseBatch), run from the action menu itemMGCLatticeTradeAgreementPriceEffectivityBatch("Sales price agreement effectivity (effective/expired) events"). The event is raised only when this batch runs; the Lattice sales price effectivity events background process runs it daily (see Activation). The background process sweeps every legal entity that is not a virtual company, each inside that legal entity, so the activation check, the position and the event are that legal entity's. Legal entities where neither the Effective nor the Expired event is active are skipped, and a failure in one legal entity is reported ("The sales price agreement effectivity run failed in legal entity x. The other legal entities were still processed; this one is retried on the next run.") without stopping the others. A run from the menu item sweeps only the legal entity it runs in. In each legal entity, a run does the Effective sweep first, then the Expired sweep. - Position: each legal entity keeps one
MGCLatticeTradeAgreementPriceEffectivityCursorrow per sweep, holding the To date and record ID of the last sales price the Expired sweep processed (CursorDate,CursorRecId; the highest possible record ID means the whole date is done). - First run: the first run in which this event is active records the starting position - the day before the run date - and sends nothing. Prices whose last valid day is before the run date are never reported.
- Firing condition: every sales price that has a To date, where the To date is after the position and before the run date (today in the company's time zone) - that is, its last valid day has passed - in (To date, record ID) order. A run covers every day since the position, so a gap between runs is caught up in one run. When the run has processed them all, the position moves to the day before the run date. Rows without a To date (open-ended prices) never expire.
- Successor check: the event is not sent when another sales price of the same agreement - same account code and relation, item code and relation, currency, unit, quantity break (from quantity) and inventory dimensions - is in effect on the day after the To date (From date on or before that day, and To date empty or on or after it). The price was then replaced rather than left to lapse. Prices of another quantity break or other inventory dimensions do not count, and neither does a price that starts later than the next day. The check is made when the batch processes the price, so a successor added or removed later does not change what was reported.
- A price whose To date is changed to a later date after it was reported is reported again when that date has passed; a price saved with a To date the position has already passed (for example a back-dated end date) is not reported.
- One price at a time: for each price, the event is sent (unless a successor exists) and the position moves past the price in the same transaction. So an interrupted run resumes after the last price it committed and sends nothing twice; a send that fails is rolled back together with its position move, the sweep stops at that price (the run shows "The Sales price agreement expired event for sales price record n could not be sent. The sweep stopped at this price and will retry it on its next run.") and the next run retries it; and two runs at the same time in the same company never both report a price, because each price's transaction locks the position row and a price another run has already processed is skipped.
- Inactive event: while this event is not activated in the company, the Expired sweep does nothing and keeps its position (the run shows "Sales price agreement expired is not active in this company. Its sweep kept its position and will catch up once the event is activated."). Once the event is activated again, the next run reports the prices that expired in the meantime. If the event is deactivated during a run, the sweep stops and keeps its position.
- Throttling: the batch pauses 250 ms after every 500 events it sends (Effective and Expired together), outside any transaction.
- Enablement gate: the sweep checks
BusinessEventsConfigurationReader::isBusinessEventEnabled(classStr(MGCLatticeTradeAgreementPriceExpiredBusinessEvent))at the start and before each price. Effective and Expired are activated, and keep their positions, independently. - Each run ends with the message "Sales price agreement effectivity run completed. Effective events sent: n. Expired events sent: n."
The Expired sweep in sweep():
while select row
order by ToDate, RecId
where row.Relation == PriceType::PriceSales
&& row.ToDate != dateNull()
&& row.ToDate <= lastDate
&& (row.ToDate > cursorDate || (row.ToDate == cursorDate && row.RecId > cursorRecId))
{
if (!this.processRow(_changeType, row, row.ToDate))
{
break;
}
}
The successor check, from hasSuccessor():
PriceDiscTable successor;
TransDate dayAfter = _expiring.ToDate + 1;
select firstonly RecId from successor
where successor.Relation == PriceType::PriceSales
&& successor.ItemCode == _expiring.ItemCode
&& successor.ItemRelation == _expiring.ItemRelation
&& successor.AccountCode == _expiring.AccountCode
&& successor.AccountRelation == _expiring.AccountRelation
&& successor.Currency == _expiring.Currency
&& successor.InventDimId == _expiring.InventDimId
&& successor.UnitId == _expiring.UnitId
&& successor.QuantityAmountFrom == _expiring.QuantityAmountFrom
&& successor.RecId != _expiring.RecId
&& successor.FromDate <= dayAfter
&& (successor.ToDate == dateNull() || successor.ToDate >= dayAfter);
Payload Contract
One row per [DataMember], in declaration order. All six sales price agreement events use this contract; the sources are fields on the expired PriceDiscTable row as it is when the batch runs, mapped in initialize().
| JSON field | Display label | Type / EDT | Source | Notes |
|---|---|---|---|---|
legalEntity | Legal Entity | DataAreaId | PriceDiscTable.DataAreaId | Company of the trade agreement row |
changeType | Change Type | int | enum2int(MGCLatticeTradeAgreementPriceChangeType) | Integer change type: Created 0, Modified 1, Superseded 2, Deleted 3, Effective 4, Expired 5. Always 5 for this event |
changeTypeName | Change Type Name | str | enum2Symbol(enumNum(MGCLatticeTradeAgreementPriceChangeType), …) | Change type symbol; always "Expired" for this event |
priceType | Price Type | int | enum2int(PriceDiscTable.Relation) | Integer value of the PriceType enum; always 4 (PriceSales) |
priceTypeName | Price Type Name | str | enum2Symbol(enumNum(PriceType), enum2int(PriceDiscTable.Relation)) | Always "PriceSales" |
accountCode | Account Code | int | enum2int(PriceDiscTable.AccountCode) | PriceDiscPartyCodeType: 0 = Table (one customer), 1 = GroupId (customer price group), 2 = All (all customers) |
accountCodeName | Account Code Name | str | enum2Symbol(enumNum(PriceDiscPartyCodeType), …) | Symbol of accountCode: Table, GroupId or All |
accountRelation | Account Relation | PriceDiscAccountRelation | PriceDiscTable.AccountRelation | Customer account or price group; empty when accountCode is 2 |
itemCode | Item Code | int | enum2int(PriceDiscTable.ItemCode) | PriceDiscProductCodeType: 0 = Table (one item), 1 = GroupId, 2 = All |
itemCodeName | Item Code Name | str | enum2Symbol(enumNum(PriceDiscProductCodeType), …) | Symbol of itemCode: Table, GroupId or All |
itemRelation | Item Relation | PriceDiscItemRelation | PriceDiscTable.ItemRelation | Item number (or item group) |
currencyCode | Currency Code | CurrencyCode | PriceDiscTable.Currency | Currency of the price |
unitId | Unit | UnitOfMeasureSymbol | PriceDiscTable.UnitId | Unit of measure the price applies to |
quantityAmountFrom | Quantity From | AmountQty | PriceDiscTable.QuantityAmountFrom | From quantity of the row's quantity break |
quantityAmountTo | Quantity To | AmountQty | PriceDiscTable.QuantityAmountTo | To quantity of the row's quantity break; 0 when the break has no upper limit |
inventDimId | Inventory Dimensions | InventDimId | PriceDiscTable.InventDimId | Inventory dimension combination the price applies to; AllBlank when the price is not dimension-specific |
priceUnit | Price Unit | PriceUnit | PriceDiscTable.PriceUnit | Number of units the amount is quoted for |
fromDate | From Date | FromDate | PriceDiscTable.FromDate | Start of the validity period, in the \/Date(milliseconds)\/ form. A row without a start date carries 1900-01-01 (\/Date(-2208988800000)\/) |
toDate | To Date | ToDate | PriceDiscTable.ToDate | The price's last valid day, in the same form |
amount | Amount | PriceDiscAmount | PriceDiscTable.Amount | The price that expired |
oldAmount | Old Amount | PriceDiscAmount | Not used by this event | Always 0 |
discountPercent | Discount Percent | DiscPct | PriceDiscTable.Percent1 | Discount percentage stored on the row (Percent1); normally 0 on a sales price row |
priceDiscTableRecId | Price Disc Table RecId | RecId | PriceDiscTable.RecId | Record ID of the expired row |
previousPriceDiscTableRecId | Previous Price Disc Table RecId | RecId | Not used by this event | Always 0 |
changedFields | Changed Fields | list of str | Not used by this event | Always an empty array |
Every Lattice event also carries the Lattice ordering and correlation envelope, inherited from MGCLatticeBusinessEventContractBase: eventSequence (Int64 - increasing across the whole catalog and all companies, allocated when the event is sent inside the business transaction; it orders the events of one record, but two transactions can commit in the opposite order of their values, and gaps are normal and permanent - see the events overview), correlationId (String - always Scope:Key: Event: followed by a GUID unique to this emission, unless the event joins a run) and schemaVersion (Int - the payload shape version; 1 for this contract). D365 F&O also adds its standard envelope fields to every business event payload: BusinessEventId, BusinessEventLegalEntity, ContextRecordSubject, ControlNumber, EventId, EventTime, EventTimeIso8601, InitiatingUserAADObjectId, MajorVersion, MinorVersion, and ParentContextRecordSubjects. Do not rely on the order of members in the delivered JSON.
Example Payload
{
"BusinessEventId": "MGCLatticeTradeAgreementPriceExpiredBusinessEvent",
"ControlNumber": 5637149321,
"EventId": "B267C0E2-054E-4EAD-BB4C-C5301EA39596",
"EventTime": "\/Date(1790816409000)\/",
"EventTimeIso8601": "2026-10-01T01:00:09.6264163Z",
"MajorVersion": 0,
"MinorVersion": 0,
"BusinessEventLegalEntity": "USMF",
"ContextRecordSubject": "",
"InitiatingUserAADObjectId": "{048AC514-DCC1-B54A-85A2-7DFA10A6ADEE}",
"ParentContextRecordSubjects": [],
"eventSequence": 48518,
"correlationId": "Event:8653c1c1-56f9-40c2-8011-8db6c3657fea",
"schemaVersion": 1,
"legalEntity": "usmf",
"changeType": 5,
"changeTypeName": "Expired",
"priceType": 4,
"priceTypeName": "PriceSales",
"accountCode": 0,
"accountCodeName": "Table",
"accountRelation": "US-001",
"itemCode": 0,
"itemCodeName": "Table",
"itemRelation": "D0111",
"currencyCode": "USD",
"unitId": "ea",
"quantityAmountFrom": 0,
"quantityAmountTo": 0,
"inventDimId": "AllBlank",
"priceUnit": 1,
"fromDate": "\/Date(1780272000000)\/",
"toDate": "\/Date(1790726400000)\/",
"amount": 1299.00,
"oldAmount": 0,
"discountPercent": 0,
"priceDiscTableRecId": 22565424069,
"previousPriceDiscTableRecId": 0,
"changedFields": []
}
Activation
- In the D365FO client, go to System administration > Setup > Business events > Business events catalog. Find this event by name ("Sales price agreement expired") or by filtering the Category column to Sales orders. Select it and use Activate to bind it to a configured endpoint (for example, an Azure Service Bus queue, Event Grid topic, or HTTPS webhook).
- The Lattice sales price effectivity events background process (System administration > Setup > Process automations, Background processes tab) sends this event once a day in every legal entity where it is active; there is nothing to schedule per legal entity. To send a legal entity's events at once, open System administration > Periodic tasks > Lattice business events > Sales price agreement effectivity (effective/expired) events in that legal entity (available to the Lattice Integration administrator role) and select OK. Such a run covers only that legal entity, and because each legal entity keeps its own position, nothing is sent twice. The batch has no parameters.
- The first run after the event is activated only records its starting position and sends nothing; events start with the next run.
Related Events
- Sales price agreement effective - raised by the same batch
- Sales price agreement superseded - a replacing price, which suppresses this event when it is in effect on the day after the To date
- Sales price agreement created
- Sales price agreement modified
- Sales price agreement deleted
- Sales price trade agreement changed - record-based complement for sales-price rows inserted or updated outside a journal post