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