Document order invoice lifecycle

This commit is contained in:
2026-09-16 16:25:56 +05:30
parent 0cd4508d50
commit db24c66d77
@@ -1,12 +1,89 @@
# [Refactor] Order/Invoice Lifecycle — Unified PO → Invoice Pipeline
**Status**: Proposed (design only — no code changes yet)
**Status**: Partially implemented — schema, API, and DB migration live on branch
`feat/order-invoice-lifecycle`. Frontend, `LoadingSheet` wiring, and the cancellation cascade
(Open Question 5) are not done. See [Implementation Status](#implementation-status) below for the
as-built schema, which differs in a few small ways from the original design further down this
document.
**Refactors**: [../05_delivery_ride.md](../05_delivery_ride.md) and
[../06_sale_ride.md](../06_sale_ride.md) (both kept as-is, this is the target design)
**Related but independent**: [ride_lifecycle.md](./ride_lifecycle.md), [dispatch_pipeline.md](./dispatch_pipeline.md)
---
## Implementation Status
Everything below reflects what is actually in the database and codebase right now, not just the
plan. The rest of this document (Data Model Changes onward) is kept as the original design record —
where the two disagree, this section wins.
### `Orders` (existing table, altered)
| Field | Type | Notes |
|---|---|---|
| `FK_SaleId` | `char(36)`, nullable | Changed from required to nullable, as designed. |
| `CreatedByUserId` | `char(36)`, required | **Added — not in the original diagram below.** The
original design had no way to know who created a PO or to answer the `agentId` filter Mode B's own
endpoints require once a PO can exist without a Sale. Backfilled for every pre-existing row from its
linked Sale's `CreatedByUserId`. FK → `Users`. |
| `ConfirmedAt` | `datetime`, nullable | **Added — not in the original diagram below.** The design
never named a field for "is this PO confirmed" independent of an Invoice existing (Mode B confirms
with no Invoice at all), so this was added to actually gate item-editing per Business Rule 1. Null =
Shop PO, set = Confirmed PO. |
| `ConfirmedByUserId` | `char(36)`, nullable | **Added**, alongside `ConfirmedAt`. FK → `Users`. |
| `InvoiceId` | `char(36)`, nullable | As designed — plain column, no DB-level FK constraint. |
### `OrderItems` (existing table, altered)
| Change | Notes |
|---|---|
| Primary key | Changed from the composite `(FK_OrderId, FK_ItemId)` to the single inherited `Id`
column. **Required for versioning to work at all** — the original composite key made it impossible
for two rows to exist for the same PO+item (one soft-deleted, one active), which item-editing needs.
Not called out explicitly in the original design below. |
| `IsActive` | Already existed via the `Template` base class; now actually used to mark old versions
inactive on every edit. |
| New index | `IX_OrderItems_FK_OrderId` (standalone — previously implied by the composite key). |
### `Invoices` (new table)
| Field | Type | Notes |
|---|---|---|
| `Id` | `char(36)` | PK |
| `FK_OrderId` | `char(36)`, required | FK → `Orders` |
| `Status` | `int` (`InvoiceStatus`) | `Draft` / `Printed` / `Finalized` |
| `CreatedByUserId` | `char(36)`, required | FK → `Users`. Named `CreatedByUserId` in code, not
`CreatedBy` as in the diagram below — matches this codebase's existing naming convention
(`Sale.CreatedByUserId`, etc.). |
| `PrintedAt` | `datetime`, nullable | |
| `PrintedByUserId` | `char(36)`, nullable | FK → `Users`. Same naming note as above. |
| `FinalizedAt` | `datetime`, nullable | |
| `FinalizedByUserId` | `char(36)`, nullable | FK → `Users`. Same naming note as above. |
| `TotalAmount` | `decimal` | |
| `IsActive`, `CreatedAt` | from `Template` | |
### API endpoints implemented
`POST /api/orders` (independent PO creation, replacing the old "created from Sale" behaviour),
`PUT /api/orders/{id}/items` (versioned rewrite), `POST /api/orders/{id}/confirm` (Mode B),
`POST /api/orders/{id}/confirm-and-create-invoice` (Mode A), `GET /api/orders/confirmed`
(filtered by `agentId`/`routeId`/`shopId`), `POST /api/orders/{id}/cancel`; `POST /api/invoices`
(Mode B), `POST /api/invoices/{id}/print`, `POST /api/invoices/{id}/finalize` (using the resolved
payload from Open Question 6 below). Both modes exist side by side, gated at runtime by the
`OrderInvoice:InvoiceCreationMode` config flag (`appsettings.json`, defaulted to `"B"`).
### Not implemented yet
- Frontend (no UI changes made).
- `LoadingSheet` population still needs its own wiring per [loading_event.md](./loading_event.md) —
untouched by this pass.
- Cancellation cascade to `Invoice`/`LoadingSheet` (Open Question 5) — still unresolved; cancelling a
Confirmed PO with a linked Invoice today leaves the Invoice untouched.
- `Payment`/`Invoice` wiring from [invoice_collection.md](./invoice_collection.md) — separate doc,
not started.
---
## Goal
Replace the Delivery-vs-Sale ride-type split with a single unified pipeline. The PO and Invoice are
@@ -201,9 +278,38 @@ Draft → Printed → Finalized
already has a linked Invoice, what happens to that Invoice (voided? stays Draft?) is unresolved.
If the PO's items were pulled into a loading sheet, what happens to those loading sheet items is
also unresolved. Both cases need explicit cascade rules before implementation.
6. **Exact adjustment payload for `POST /finalize`** — quantity adjustments, returns, damage, and
discounts are assumed but the payload shape is not fully specified. "Free issues" handling (items
given to the shop at no charge) is a known case that needs its own field(s) in the payload.
6. ~~**Exact adjustment payload for `POST /finalize`**~~ — **Resolved:**
```
POST /api/invoices/{id}/finalize
{
"items": [
{ "orderItemId": Guid, "deliveredQuantity": decimal, "freeQuantity": decimal }
],
"returns": [
{ "itemId": Guid, "quantity": decimal, "reasonCode": ReturnReasonCode }
],
"discount": { "type": "Amount" | "Percentage", "value": decimal, "reason": string? } | null
}
```
- `items[].deliveredQuantity` — what the shop actually received per line, replacing the ordered
`OrderItem.Quantity` for billing purposes. Captures shortages/quantity edits the agent
handwrote on the printed copy.
- `items[].freeQuantity` — **the free-issue field this document previously flagged as missing.**
Units given to the shop at no charge on top of `deliveredQuantity`. Contributes `0` to
`Invoice.TotalAmount` but is still recorded per line for stock/audit — it moves stock the same
as a sold unit, it just isn't billed.
- `returns` — **reuses the existing `SaleReturn`/`SaleReturnItem`/`CreditNote` shape from
[18_returns_claims.md](../18_returns_claims.md) instead of inventing a parallel mechanism.**
Finalize creates a `SaleReturn` (`Mode = CreditDeduction`) keyed to the PO's `Order` with one
`SaleReturnItem` per entry, which produces a `CreditNote` the normal way. "Damage" is not a
separate field — it's `ReasonCode = Damaged` on a return line, since `ReturnReasonCode` already
covers it (`Expired, Damaged, OrderError, Other`).
- `discount` — a single amount-or-percentage adjustment applied to the invoice subtotal, with an
optional free-text reason for audit. Not per-line; one discount per invoice for v1.
- `Invoice.TotalAmount` at Finalize = `Σ(items[].deliveredQuantity × OrderItem.UnitPrice)`
− discount − `Σ(CreditNote.Amount from returns)`. `freeQuantity` never enters this sum.
---