Skip to main content

backbone_inventory/application/service/
inventory_cancellation.rs

1//! The cancellation/reversal path (hand-authored, user-owned) — council 2026-07-29, finding #3.
2//!
3//! An `impl InventoryWriteService` chunk: `cancel_purchase_receipt` and `cancel_delivery_note`. A
4//! cancellation REVERSES a submitted voucher: per original line it mints ONE REVERSE stock move
5//! through the converged engine (the mirror image of the line's forward move — the moves the
6//! submit door minted), drives it confirm → assign → done, so the quant flips, the Bin reblende
7//! and the compensating Stock Ledger Entry all come from MOVE APPLICATION — the cancel NEVER
8//! writes Bins or SLEs directly and NEVER deletes rows. The header flips submitted→cancelled,
9//! and a balanced `posting_type='reversal'` AccountingPost whose `reverses_post_id` references
10//! the original post is emitted. The original `journal_id`/`accounting_post_id` stay intact; the
11//! reversal's ids land in `reversal_journal_id`/`reversal_accounting_post_id`.
12//!
13//! **Valuation: `forced_value`.** Each reverse move carries `forced_value` = the value the
14//! original line carried (the receipt line's `money(qty·rate)`; the delivery line's stored
15//! `cogs_amount` — the whole remaining value for a drain-to-zero line). The valuation core uses
16//! it instead of the average/price-derived carry, so the reversed estate returns to EXACTLY its
17//! pre-movement state — the same arithmetic the direct-write cancellation produced.
18//!
19//! **GL legs.** The reverse moves post no GL of their own (empty directive + the door-owned-GL
20//! tripwire sink); the voucher's ONE reversal envelope is unchanged — the net GL effect per door
21//! is identical to the direct-write era.
22//!
23//! Idempotent + crash-recoverable: the reverse move names are deterministic
24//! (`{voucher_no}/REV/{seq}`, `origin` = the voucher number), so a crash mid-way leaves the
25//! voucher `submitted` with some reverse moves landed — a re-call RESUMES (a DONE reverse move
26//! is skipped) instead of double-reversing. If the physical reversal committed but the GL
27//! reversal failed (status='cancelled', `reversal_accounting_post_id` NULL), re-calling
28//! re-emits ONLY the GL — it never re-mints moves. An already-fully-reversed voucher
29//! short-circuits with the recorded ids.
30//!
31//! **Receipt cancel** reverses an INFLOW: push the received qty back out (stock location → the
32//! supplier location) and remove the value it added. Requires the stock still hold the qty
33//! (else `InsufficientStockToReverse` — the goods were issued), and that the source quant's
34//! FREE availability cover the whole reversal (stock reserved for someone else is not
35//! returnable stock). GL: swaps the original `Dr Inventory · Cr GR/IR` to `Dr GR/IR · Cr Inventory`.
36//!
37//! **Delivery cancel** reverses an OUTFLOW: push the delivered qty back in (the customer
38//! location → the stock location) and restore the COGS consumed. Always safe (qty only
39//! increases). GL: swaps `Dr COGS · Cr Inventory` to `Dr Inventory · Cr COGS`.
40//!
41//! Per the module's 4-layer rule this file holds no SQL — the statements live on the repositories
42//! and the move engine; every engine verb runs its own guarded transaction.
43//!
44//! Tenancy (ADR-0029): the module is tenant-agnostic. Every write transaction this file opens
45//! re-binds the caller's ambient org scope (`relay_ambient_scope`); the composing decorator owns
46//! isolation. Cross-module wire fields that still carry a company id are legacy twins filled
47//! from the ambient scope's company echo.
48
49use rust_decimal::Decimal;
50use uuid::Uuid;
51
52use crate::infrastructure::persistence::{
53    DeliveryCancelHeaderRow, GlVoucher, MoveRow, ReceiptCancelHeaderRow,
54};
55
56use super::inventory_gl::{AccountingPostEnvelope, GlPostLine, GlPostSink};
57use super::inventory_move_engine::{BackorderPolicy, MoveGlDirective, NewStockMove};
58use super::inventory_write_service::{
59    legacy_company_echo, money, relay_ambient_scope, DoorOwnedGlSink, InventoryError,
60    InventoryWriteService, SubmitOutcome,
61};
62
63impl InventoryWriteService {
64    // ---- cancel: Purchase Receipt (reverse the inflow through the move engine) ------------
65
66    /// Cancel the goods-in voucher: per line, ONE reverse move (stock location → supplier
67    /// location) minted with `forced_value = money(qty·rate)` — the exact value the inflow
68    /// added — confirm → assign (reserves against the stock quants; the door refuses a line
69    /// whose free availability cannot cover the WHOLE reversal) → done, which draws the source
70    /// quant, reblends the Bin back to its pre-receipt state and mints the compensating SLE row.
71    /// The voucher's ONE reversal envelope (`Dr GR/IR · Cr Inventory`) is unchanged.
72    ///
73    /// Not cross-move atomic (each engine verb commits its own transaction): a crash mid-way
74    /// leaves the voucher `submitted` with some reverse moves landed — the deterministic names
75    /// (`{receipt_number}/REV/{seq}`) make a re-cancel RESUME instead of double-reversing.
76    pub async fn cancel_purchase_receipt(&self, id: Uuid, sink: &dyn GlPostSink) -> Result<SubmitOutcome, InventoryError> {
77        let h = self.receipts.fetch_cancel_header(&self.rpool(), id).await?
78            .ok_or(InventoryError::NotFound(id))?;
79
80        // Recovery / idempotency: the physical reversal already committed. Re-emit ONLY the GL leg —
81        // never re-mint reverse moves. An already-recorded reversal short-circuits with its ids.
82        if h.status == "cancelled" {
83            return self.receipt_reversal_gl(id, h, sink).await;
84        }
85        if h.status != "submitted" {
86            return Err(InventoryError::NotSubmitted(id));
87        }
88        // A reversal references the original post; the original must be posted.
89        let orig_post_id = h.gl.accounting_post_id.ok_or(InventoryError::GlNotPosted(id))?;
90
91        let items = self.receipt_items.fetch_items(&self.rpool(), id).await?;
92
93        // The door's move endpoints — the mirror image of the submit door's pair: the warehouse's
94        // stock location is now the SOURCE, the supplier location the destination the received
95        // goods return to.
96        let (supplier_loc, stock_loc) = self.door_move_endpoints(h.warehouse_id, "supplier").await?;
97
98        // The reversal legs already landed in a prior (crashed) attempt: a DONE move under the
99        // line's reverse name means the bin draw already happened — the pre-check below skips it.
100        let prior = self.door_moves_by_origin(&h.receipt_number).await?;
101        let rev_name = |idx: usize| format!("{}/REV/{}", h.receipt_number, idx + 1);
102
103        // ---- all-or-nothing availability pre-check, under the Bin locks -----------------------
104        // Same posture the direct-write cancellation always had: EVERY un-landed line's qty must
105        // still be on the bin before anything mints (the engine's quant draw guard re-checks at
106        // the quant grain). A landed line is skipped — its bin draw already committed.
107        {
108            let mut tx = self.rpool().begin().await?;
109            // Re-bind the caller's ambient org scope before any bin read (ADR-0029) — the scope
110            // is task-local and a fresh pool transaction carries none of it; undecorated
111            // (module tests, jobs) the transaction stays plain.
112            relay_ambient_scope(&mut tx).await?;
113            for (idx, it) in items.iter().enumerate() {
114                if it.quantity.is_zero() { continue; }
115                // A landed-cost service line minted no stock — there is nothing of it on the
116                // bin to check and nothing to reverse.
117                if it.is_landed_costs_line { continue; }
118                let already_reversed = prior.iter()
119                    .any(|m| m.name == rev_name(idx) && m.state == "done");
120                if already_reversed { continue; }
121                let bin = self.bins.lock_or_init(&mut tx, it.item_id, h.warehouse_id).await?;
122                if bin.actual_qty < it.quantity {
123                    return Err(InventoryError::InsufficientStockToReverse {
124                        item_id: it.item_id, warehouse_id: h.warehouse_id,
125                        available: bin.actual_qty, requested: it.quantity,
126                    });
127                }
128                // The quant-surface heal: stock received through the legacy direct-write paths
129                // wrote Bins without quants — seed the quant from the (locked) Bin once, so the
130                // reverse move's reservation has a surface to reserve against.
131                self.pickings.ensure_quant_surface(&mut tx, it.item_id, stock_loc, Some(h.warehouse_id)).await?;
132            }
133            tx.commit().await?;
134        }
135
136        // ---- physical reversal: one reverse move per line, engine-driven -----------------------
137        for (idx, it) in items.iter().enumerate() {
138            if it.quantity.is_zero() { continue; } // a zero line moved nothing and reverses nothing
139            // The landed-cost seam: a flagged line minted no move, so it has no reverse leg
140            // either (its cost is recovered by a negative landed cost, not by un-receiving).
141            if it.is_landed_costs_line { continue; }
142            // The exact value the original inflow added — negating it restores the bin precisely.
143            let reverse_value = money(it.quantity * it.rate);
144            let mid = match self.mint_line_move(NewStockMove {
145                name: rev_name(idx),
146                item_id: it.item_id,
147                demand_qty: it.quantity,
148                price_unit: Decimal::ZERO, // the reversal is valued by its forced_value, never a price
149                procure_method: "make_to_stock".into(),
150                picking_id: None,
151                origin: Some(h.receipt_number.clone()),
152                location_id: stock_loc,
153                location_dest_id: supplier_loc,
154                partner_id: None,
155                warehouse_id: Some(h.warehouse_id),
156                orderpoint_id: None,
157                move_orig_ids: vec![],
158                move_dest_ids: vec![],
159                is_inventory: false,
160                scrapped: false,
161                forced_value: Some(reverse_value),
162            }).await? {
163                Some(mid) => mid,
164                None => continue, // the line already reversed in a prior (crashed) attempt
165            };
166            let state = self.advance_move_to_assigned(mid).await?;
167            if state != "assigned" {
168                // The stock quant's free availability cannot cover the WHOLE reversal — the
169                // received goods are (partly) reserved for someone else. Release whatever the
170                // partial assign took; no stock moved, no reservation held, the voucher stays
171                // submitted (retryable once the reservation clears).
172                self.unreserve_move(mid).await?;
173                let on_hand = self.quants.fetch_on_hand(&self.rpool(), it.item_id, stock_loc).await?;
174                return Err(InventoryError::InsufficientStockToReverse {
175                    item_id: it.item_id, warehouse_id: h.warehouse_id,
176                    available: on_hand.on_hand_qty - on_hand.reserved_qty,
177                    requested: it.quantity,
178                });
179            }
180            self.action_done(mid, BackorderPolicy::Never, &MoveGlDirective::default(), &DoorOwnedGlSink).await?;
181        }
182        {
183            let mut tx = self.rpool().begin().await?;
184            relay_ambient_scope(&mut tx).await?;
185            self.receipts.mark_cancelled(&mut tx, id).await?;
186            tx.commit().await?;
187        }
188
189        // GL reversal: swap the original Dr Inventory · Cr GR/IR. The amount is the voucher's own
190        // arithmetic (Σ money(qty·rate) over the STOCK lines) — identical to the Σ of the reverse
191        // moves' forced values; a landed-cost service line contributed nothing on the way in and
192        // reverses nothing on the way out.
193        // The inventory leg resolves the SAME location valuation-account override the submit
194        // used, so the compensation mirrors the original post exactly (posture symmetry).
195        let inv_acct = self.inventory_leg_account(stock_loc, h.inventory_account_id).await?;
196        let total: Decimal = items.iter()
197            .filter(|l| !l.is_landed_costs_line)
198            .map(|l| money(l.quantity * l.rate))
199            .sum();
200        let env = AccountingPostEnvelope {
201            // Legacy twin (ADR-0029): filled from the ambient org scope's company echo for
202            // consumers that still read a tenant off the wire. No module statement keys on it.
203            idempotency_key: format!("{id}-reversal"), company_id: legacy_company_echo(), branch_id: h.branch_id,
204            source_type: "inventory".into(), source_id: id, source_reference: Some(h.receipt_number.clone()),
205            posting_date: h.posting_date, currency: h.currency.clone(), posting_type: "reversal".into(),
206            reverses_post_id: Some(orig_post_id),
207            description: Some("Goods receipt cancellation".into()),
208            lines: vec![
209                GlPostLine::debit(h.grir_account_id, total).with_description("GR/IR clearing"),
210                GlPostLine::credit(inv_acct, total).with_description("Inventory"),
211            ],
212        };
213        self.emit_reversal_and_reconcile(GlVoucher::PurchaseReceipt, id, &env, sink, total).await
214    }
215
216    // ---- cancel: Delivery Note (reverse the outflow through the move engine) --------------
217
218    /// Cancel the goods-out voucher: per line, ONE reverse move (the customer location → the
219    /// stock location) minted with `forced_value = cogs_amount` — the EXACT value the outflow
220    /// consumed (the whole remaining value for a drain-to-zero line) — confirm → assign (an
221    /// external source's supply is unconditionally available, so the full line is covered) →
222    /// done, which materializes the stock quant, reblends the Bin back to its pre-delivery
223    /// state and mints the compensating SLE row. The voucher's ONE reversal envelope
224    /// (`Dr Inventory · Cr COGS`) is unchanged. Always safe — qty only increases.
225    pub async fn cancel_delivery_note(&self, id: Uuid, sink: &dyn GlPostSink) -> Result<SubmitOutcome, InventoryError> {
226        let h = self.deliveries.fetch_cancel_header(&self.rpool(), id).await?
227            .ok_or(InventoryError::NotFound(id))?;
228
229        if h.status == "cancelled" {
230            return self.delivery_reversal_gl(id, h, sink).await;
231        }
232        if h.status != "submitted" {
233            return Err(InventoryError::NotSubmitted(id));
234        }
235        let orig_post_id = h.gl.accounting_post_id.ok_or(InventoryError::GlNotPosted(id))?;
236
237        let items = self.delivery_items.fetch_cancel_items(&self.rpool(), id).await?;
238
239        // The door's move endpoints — the mirror image of the submit door's pair: the customer
240        // location is now the SOURCE, the warehouse's stock location the destination
241        // the delivered goods return to.
242        let (customer_loc, stock_loc) = self.door_move_endpoints(h.warehouse_id, "customer").await?;
243
244        // ---- physical reversal: one reverse move per line, engine-driven -----------------------
245        for (idx, it) in items.iter().enumerate() {
246            if it.quantity.is_zero() { continue; } // a zero line moved nothing and reverses nothing
247            let name = format!("{}/REV/{}", h.delivery_number, idx + 1);
248            let mid = match self.mint_line_move(NewStockMove {
249                name,
250                item_id: it.item_id,
251                demand_qty: it.quantity,
252                price_unit: Decimal::ZERO, // the reversal is valued by its forced_value, never a price
253                procure_method: "make_to_stock".into(),
254                picking_id: None,
255                origin: Some(h.delivery_number.clone()),
256                location_id: customer_loc,
257                location_dest_id: stock_loc,
258                partner_id: None,
259                warehouse_id: Some(h.warehouse_id),
260                orderpoint_id: None,
261                move_orig_ids: vec![],
262                move_dest_ids: vec![],
263                is_inventory: false,
264                scrapped: false,
265                // The exact COGS the outflow consumed — restoring it reblends the bin exactly
266                // (a drain-to-zero line carried the whole remaining value).
267                forced_value: Some(it.cogs_amount),
268            }).await? {
269                Some(mid) => mid,
270                None => continue, // the line already reversed in a prior (crashed) attempt
271            };
272            let state = self.advance_move_to_assigned(mid).await?;
273            if state != "assigned" {
274                // Unreachable in practice (an external source's supply is unconditionally
275                // available, so assign always covers the full demand) — but a delivery cancel
276                // must restore the WHOLE line, so a partial advance fails loudly instead of
277                // silently short-restoring the estate.
278                return Err(InventoryError::WrongMoveState {
279                    move_id: mid, action: "cancel", current: state,
280                });
281            }
282            self.action_done(mid, BackorderPolicy::Never, &MoveGlDirective::default(), &DoorOwnedGlSink).await?;
283        }
284        {
285            let mut tx = self.rpool().begin().await?;
286            relay_ambient_scope(&mut tx).await?;
287            self.deliveries.mark_cancelled(&mut tx, id).await?;
288            tx.commit().await?;
289        }
290
291        // GL reversal: swap the original Dr COGS · Cr Inventory. The amount is the voucher's own
292        // stored Σ COGS — identical to the Σ of the reverse moves' forced values. The credit leg
293        // resolves the SAME posture the original debit used (the anglo-saxon interim-delivered
294        // account when the posture is ON — a compensation must mirror what was actually posted)
295        // and the inventory leg resolves the same location valuation-account override.
296        let posture = self.posting_posture().await?;
297        let credit_acct = self.delivery_debit_account(&posture, h.cogs_account_id)?;
298        let inv_acct = self.inventory_leg_account(stock_loc, h.inventory_account_id).await?;
299        let total: Decimal = items.iter().map(|l| l.cogs_amount).sum();
300        let env = AccountingPostEnvelope {
301            // Legacy twin (ADR-0029): filled from the ambient org scope's company echo for
302            // consumers that still read a tenant off the wire. No module statement keys on it.
303            idempotency_key: format!("{id}-reversal"), company_id: legacy_company_echo(), branch_id: h.branch_id,
304            source_type: "inventory".into(), source_id: id, source_reference: Some(h.delivery_number.clone()),
305            posting_date: h.posting_date, currency: h.currency.clone(), posting_type: "reversal".into(),
306            reverses_post_id: Some(orig_post_id),
307            description: Some("Delivery cancellation".into()),
308            lines: vec![
309                GlPostLine::debit(inv_acct, total).with_description("Inventory"),
310                GlPostLine::credit(credit_acct, total).with_description("COGS"),
311            ],
312        };
313        self.emit_reversal_and_reconcile(GlVoucher::DeliveryNote, id, &env, sink, total).await
314    }
315
316    // ---- shared: the door-minted moves of one voucher origin ------------------------------
317
318    /// The moves minted under one voucher origin (voucher doors stamp the voucher number in
319    /// `origin`). The cancel doors read them to tell an already-landed reverse leg from a
320    /// fresh one (crash recovery) — the mapping is the deterministic move NAME. The read
321    /// rides the caller's ambient org scope (ADR-0029).
322    async fn door_moves_by_origin(
323        &self,
324        origin: &str,
325    ) -> Result<Vec<MoveRow>, InventoryError> {
326        let mut tx = self.rpool().begin().await?;
327        relay_ambient_scope(&mut tx).await?;
328        let rows = self.moves.fetch_moves_by_origin(&mut tx, origin).await?;
329        tx.commit().await?;
330        Ok(rows)
331    }
332
333    // ---- shared: the GL-only recovery leg for an already-cancelled voucher ----------------
334    //
335    // Reached when status is already 'cancelled': the physical reversal (the reverse moves)
336    // committed in a prior call. If the reversal GL also landed, short-circuit with the recorded
337    // ids; if it didn't (crash window, or a transient accounting outage), rebuild the reversal
338    // envelope from the stored header total and re-emit — never re-minting moves.
339
340    async fn receipt_reversal_gl(
341        &self, id: Uuid, h: ReceiptCancelHeaderRow, sink: &dyn GlPostSink,
342    ) -> Result<SubmitOutcome, InventoryError> {
343        if let Some(rid) = h.reversal_accounting_post_id {
344            return Ok(SubmitOutcome {
345                voucher_id: id, posted: true, journal_id: h.reversal_journal_id, post_id: Some(rid), gl_amount: Decimal::ZERO,
346            });
347        }
348        let orig_post_id = h.gl.accounting_post_id.ok_or(InventoryError::GlNotPosted(id))?;
349        // Same posture + location-override resolution as the forward cancellation, so the
350        // recovered reversal mirrors the original post exactly.
351        let (_, stock_loc) = self.door_move_endpoints(h.warehouse_id, "supplier").await?;
352        let inv_acct = self.inventory_leg_account(stock_loc, h.inventory_account_id).await?;
353        let amt = h.total_value;
354        let env = AccountingPostEnvelope {
355            // Legacy twin (ADR-0029): filled from the ambient org scope's company echo for
356            // consumers that still read a tenant off the wire. No module statement keys on it.
357            idempotency_key: format!("{id}-reversal"), company_id: legacy_company_echo(), branch_id: h.branch_id,
358            source_type: "inventory".into(), source_id: id, source_reference: Some(h.receipt_number.clone()),
359            posting_date: h.posting_date, currency: h.currency.clone(), posting_type: "reversal".into(),
360            reverses_post_id: Some(orig_post_id),
361            description: Some("Goods receipt cancellation (repost)".into()),
362            lines: vec![
363                GlPostLine::debit(h.grir_account_id, amt).with_description("GR/IR clearing"),
364                GlPostLine::credit(inv_acct, amt).with_description("Inventory"),
365            ],
366        };
367        self.emit_reversal_and_reconcile(GlVoucher::PurchaseReceipt, id, &env, sink, amt).await
368    }
369
370    async fn delivery_reversal_gl(
371        &self, id: Uuid, h: DeliveryCancelHeaderRow, sink: &dyn GlPostSink,
372    ) -> Result<SubmitOutcome, InventoryError> {
373        if let Some(rid) = h.reversal_accounting_post_id {
374            return Ok(SubmitOutcome {
375                voucher_id: id, posted: true, journal_id: h.reversal_journal_id, post_id: Some(rid), gl_amount: Decimal::ZERO,
376            });
377        }
378        let orig_post_id = h.gl.accounting_post_id.ok_or(InventoryError::GlNotPosted(id))?;
379        // Same posture + location-override resolution as the forward cancellation (the credit
380        // leg mirrors the original debit — the anglo-saxon interim account when the posture
381        // is ON — so the recovered reversal mirrors the original post exactly).
382        let posture = self.posting_posture().await?;
383        let credit_acct = self.delivery_debit_account(&posture, h.cogs_account_id)?;
384        let (_, stock_loc) = self.door_move_endpoints(h.warehouse_id, "customer").await?;
385        let inv_acct = self.inventory_leg_account(stock_loc, h.inventory_account_id).await?;
386        let amt = h.total_cogs;
387        let env = AccountingPostEnvelope {
388            // Legacy twin (ADR-0029): filled from the ambient org scope's company echo for
389            // consumers that still read a tenant off the wire. No module statement keys on it.
390            idempotency_key: format!("{id}-reversal"), company_id: legacy_company_echo(), branch_id: h.branch_id,
391            source_type: "inventory".into(), source_id: id, source_reference: Some(h.delivery_number.clone()),
392            posting_date: h.posting_date, currency: h.currency.clone(), posting_type: "reversal".into(),
393            reverses_post_id: Some(orig_post_id),
394            description: Some("Delivery cancellation (repost)".into()),
395            lines: vec![
396                GlPostLine::debit(inv_acct, amt).with_description("Inventory"),
397                GlPostLine::credit(credit_acct, amt).with_description("COGS"),
398            ],
399        };
400        self.emit_reversal_and_reconcile(GlVoucher::DeliveryNote, id, &env, sink, amt).await
401    }
402}