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}