Sales Return (Back-Office) — plan
| Status | Revision 6 — 6 Oct 2026. New in Revision 6 (owner): New Sales Return searches, it does not list — step 1 shows only a search field; matches appear after Search / Enter with 3+ characters, as a short pick list (§6.3, §4). Build status: B0–B5 and Revision 6 built on branch Redy-20261005-Sales-Return, not committed (§9B). Known gap: the "Within N days" tip uses the shipped 30 days, not the company's own limit. Revision 5: New in Revision 5 (owner answers to the grill review, grill-review.md): card refunds stay keyed in on the Draft before approval (C1, with a warning on Reject / Void / Delete); a void keeps every reason and leaves the original invoice as it was (R7); the refund method list = the store's methods flagged for refund, no cap per method (R13); a refund that changed after approval is explained on screen with Void and Start Again (V13a); loyalty / gift-card take-back moved out — Back-Office invoices give no points and sell no gift cards (§3.6). Revision 4: New in Revision 4 (owner, 5 Oct 2026): the Sales Returns list has a New Sales Return button again; it opens step 1 with a list of completed sales invoices and a search (Ref. No, customer name or phone) — pick one, its items show under it (§6.2, §6.3); the database review of §2.0 was checked again; loyalty points are taken back at Complete Refund (never below zero) and a sold gift card returns only when never used, then is voided (§3.6). Revision 3: New in Revision 3: every returned line says what happens to the item — Back to stock or Write off — stored in the existing column ft_sales_detail.action_for_returned_item, pre-filled from the item's own "When a customer returns this item" value when it has one — that item field is built by the Item module, not in this plan (owner, 5 Oct 2026); when it is empty, the store setting, then the company setting, then Back to stock — and the Invoice step is joined with the Items step (§2.0, §3.5, §6.3). Revision 2: A Back-Office return is raised from a completed sales invoice only — "Return Items" shows on the invoice's row menu and view page when the invoice is Completed (or already partly returned with something left to return). The "without an invoice" path of Revision 1 is removed (owner, 5 Oct 2026): a return without a receipt stays at the till. Print and e-mail of the credit invoice work the same as the sales invoice (invoice plan §3.10, §3.11, §3.11c): one-click Print, Print with options (checklist + live A4 preview, copies, remark, company default), and Send Email with the same choices on the attached PDF. The lifecycle is the invoice's: Draft › Approval pending › Approved / Rejected › Completed, Void once approved, Delete while Draft / Rejected. Review pass (5 Oct 2026): four owner decisions written in (refund method kept on the Draft as its payment row, return time limit LIMIT_SALES_RETURN_IN_DAYS, coupons stay used, credit-invoice date = the Complete Refund day); the refund maths replaced (D7 — pro-rata of the invoice line's stored amounts); line data copied from the invoice (D6); Delete, till refund controls, non-refundable items, tenancy, lock order, calculation token and print plumbing added. Eight open points for the owner (§9A). |
| Scope | Back-Office only. Sales Return (credit invoice, CREDIT_INV / REFUND). Online-type stores, the same as the invoice module. |
| Screen basis | Three mockups, all copied from the invoice set so the layout is exactly the same: mockup/return-bo-step-form-footer.html (list, step form, view page — the invoice's step-form-footer standard, §6), mockup/return-print-options.html (the invoice's Print options page, §3.4b), mockup/return-print-email-formats.html (the credit invoice on A4 and the e-mail, §3.4a). No options to choose between: the owner asked for the invoice layout. |
| Companion | plan.html is generated from this file by build-plan-html.py in the same folder. Edit the .md, then run python build-plan-html.py. Never edit the .html by hand. |
| Builds on | ../invoice-bo/plan.md (Revision 48 + the 2 Oct 2026 print sections) — its lifecycle (§2.1a), status log (§2.1b), validation style (§2.1c), print and e-mail (§3.10, §3.11, §3.11c), status colours (§6.3a) and screen standard (§6.5) apply here unless this plan says otherwise. ../sales-return/spec.md — the read-only list / detail pages that exist today. |
| Depends on | The invoice branch Redy-20260910-BO-Invoice merged to main first. Every code file:line in this plan is read from that branch at commit b01e305cd (re-checked in the review pass, 5 Oct 2026); schema cites (T:, F:) are read from db-postgresql/tenants.schema/ on main. main does not yet have POST /api/sales-returns, sales_doc_semantics.py, print_options.py or SalesPrintOptionsPage, so the build starts after that PR. |
0. Summary
What the user gets. A Back-Office user can take goods back against a completed sales invoice and pay the customer back. A return starts in one of two ways (owner, 5 Oct 2026, Revision 4): New Sales Return on the Sales Returns list opens the form with a list of completed sales invoices and a search — the user picks one; or Return Items on a Completed invoice (row menu or view page) opens the form with that invoice already chosen. Either way the return is always against one invoice. The user picks what comes back and how many (never more than was sold minus what is already returned), picks the serial numbers from the ones that were sold, says for each line what happens to the item (Back to stock or Write off — pre-filled from the item's value when it has one, else the store setting), chooses the reason and the refund method, and sends it for approval. The refund for a line is the invoice line's own stored amounts, in proportion to the qty returned — so discounts, the cart share and tax come back exactly as charged, on tax-inclusive and tax-exclusive invoices alike (§3.2). An invoice older than the return limit (LIMIT_SALES_RETURN_IN_DAYS, 30 days by default) cannot be returned, except by the owner. An item marked non-refundable cannot be returned.
How it moves. Draft › Approval pending › Approved (or Rejected with a reason, back to editable) › Completed by "Complete Refund". The refund method and its reference are saved with the Draft (as the return's payment row), but only Complete Refund pays the money out, dates the credit invoice that day, puts the Back to stock lines back in stock (Write off lines stay out) and marks the original invoice Returned. Before that, nothing counts: no stock move, no money, not in the reports (the payment row is hidden with its document), not on the till. A Draft or Rejected return nobody wants is deleted. An approved return that should not happen is voided with a reason. Every status change goes through change_status() and shows in the Audit timeline.
Print and e-mail. The same as the sales invoice. Print prints the credit invoice at once with the company's saved choices. Print with options opens a full page: a checklist of what goes on the paper, a live A4 preview, copies (1–5, ORIGINAL / COPY stamp), a one-line remark, and "Save as the company default". Send Email sends the credit invoice PDF to the customer's e-mail at once; the PDF uses the same choices as the print. Print and e-mail are offered on a Completed or Voided return only — the invoice's rule.
No return without an invoice. A customer with no receipt is served at the till (exchange_return_without_receipt, role access POPC_NEW_SALES_RETURN). The Back-Office API refuses a return with no original invoice.
What exists today and what changes. The Back-Office already has a one-click return (POST /api/sales-returns) that writes a Completed credit invoice at once. It works from an invoice only and has seven defects (§2.5). This plan replaces its money maths (D7), fixes the other defects, and puts the approval steps in front of it.
1. Scope
1.1 In
| Area | What the user can do |
|---|---|
| Entry from the invoice | "Return Items" on the Sales Invoices row menu and on the invoice view page footer — only on Completed invoices, and on Returned invoices that still have something to return. |
| Entry from the returns list | New Sales Return button (owner, 5 Oct 2026, Revision 4) → step 1 is a search, not a list (owner, 6 Oct 2026, Revision 6): nothing is shown until the user types at least 3 characters of the invoice Ref. No, customer name / code or phone and presses Search (or Enter); the matches then show as a short pick list (at most 8) of invoices that can be returned (Completed, or Returned with qty left, inside the return limit, with a refundable line). Pick → the invoice card and its items on the same step; Change goes back to the search (§6.3). |
| Step form | 1 Invoice & Items › 2 Refund › 3 Review & send, steps in the pinned footer, Save draft, Submit for Approval. |
| What happens to each item | Each returned line has Back to stock or Write off, pre-filled from the item's value when set, else the store / company setting, else Back to stock, changeable per line before submit (§3.5). |
| Item default — read only | The return reads the item's "When a customer returns this item" value when it has one; empty → store setting → company setting → Back to stock (§3.5). The item field itself, its screen and its import are out of scope (Item module). |
| Lifecycle | Submit, Approve, Reject (reason), Void (reason, Approved only), Complete Refund, Delete (Draft / Rejected only). |
| Return time limit | An invoice older than LIMIT_SALES_RETURN_IN_DAYS (store value, else company, shipped 30) cannot be returned; the owner is exempt (owner, 5 Oct 2026). |
| Refund controls | The till's rules: an invoice paid with a non-refundable tender cannot be refunded; the refund method list holds only the store's refund-enabled methods; a non-refundable item cannot be returned (V3, V9, V21). |
| View page | Details · Refund · Customer as footer steps; Print, Print with options, Send Email and Audit in the header. |
| Print and e-mail | The invoice's three ways: one-click Print, Print with options (full page, live preview, copies, remark, company default), Send Email (same choices on the PDF). Company Settings › Advanced › Printing gains a Credit Invoice tab. |
| Serial / batch | Tick the serials sold on that line / pick the sold batch. |
| Original invoice | Turns Returned when the return completes; lists its returns under Linked Documents. |
1.2 Out (this round)
- A return without an invoice (owner, 5 Oct 2026). Served at the till only. The Back-Office API refuses
original_sales_id = null(V10). The Sales Returns list still shows till returns that have no invoice — the Original Invoice cell reads "—". - Exchange (return plus a new sale in one go). The API route exists but is a stub:
sales_commit_service.create_exchangeraisesServerError, so it answers 500. Not touched. - E-invoice for a return. Today nothing sends a credit note to LHDN —
einvoice_query.py:114readsdoc_type = 'SALES_INV'only. LHDN needs the original e-invoice number (UUID) on a credit or refund note. Left for a separate plan (§9A R6). So the credit invoice's print options have no LHDN QR row. - Returning an invoice raised at the till. v1 returns Back-Office invoices only (§9A R3).
- The till using the item default. The till keeps its own store / company setting
RETURNED_ACTION_CONTROL(Restock · Write off · Ask the cashier) this round; reading the new item default at the till needs the item sync and the till's item table (§9A R10). - The item field "When a customer returns this item" (owner, 5 Oct 2026): its column, the Item › Settings row and the item import belong to the Item module. This plan only reads it and handles it being empty (§3.5).
- A damaged-goods stock location. Write off means the item does not come back into stock at all; putting it into a separate "Defective" location is not offered (§3.5).
- Store credit as a new refund type. Refund methods are the store's existing payment methods.
- Coupons (owner, 5 Oct 2026). A coupon used on the invoice stays used after a return, part or full. Nothing in
coupon_masterorft_sales_promo_discis touched. - Notifications. None. Nobody is told by e-mail or bell that a return waits for approval; the approver finds pending returns with the list's Status filter (Approval pending, §6.2).
- The invoice view page's greyed Submit for Approval on an unpaid invoice — raised in the review, but it belongs to the invoice module. Flagged for
../invoice-bo/plan.md; not changed here.
2. Data model
2.0 Database review — every table and column a return touches (checked 5 Oct 2026)
Read from db-postgresql/tenants.schema/ on main (T = tables_pg.sql, F = functions_pg.sql, V = views_pg.sql). Checked again 5 Oct 2026 (Revision 4): about 55 table, column and line cites were right; two were wrong and are fixed in the table; the tables and columns the first pass missed are in "Also touched" below. Backend cites to sales_commit_service.py, sales_commit_repository.py, sales_document_repository.py and sales_lifecycle_service.py are on the branch Redy-20260828-BONewSales, not on main — their line numbers are checked again when that branch is merged (B0).
| Table | Columns a return uses | Notes |
|---|---|---|
ft_sales_header (T:2871, partitioned by doc_trx_date) | doc_ref_no, doc_type = CREDIT_INV, doc_sub_type = REFUND, doc_trx_date, source_type = BO, status, is_voided, is_paid, notes, customer_id / cust_code / customer_name, cust_email, store_id, money totals, voided_or_returned_by_user_id / _by / _reason_code / _notes / _datetime (T:2960-2964), is_emailed / email_status / email_error_msg, sales_rep_* (T:2912-2914), total_amt_paid (T:2936), total_cost / total_profit (T:2940-2941, profit rule in the comment at T:3037), is_paid (T:2952) | status words for CREDIT_INV in the comment at T:3038. Partition key doc_trx_date (T:3007). No table holds a live FK to the header — every FK to ft_sales_header is commented out as [PARTITIONED-NO-FK] (T:8132-8244, and shipment_details at T:8436); ft_einv_header.sales_id (T:1737) and ft_einv_detail.sales_id (T:1657) have no FK at all; so moving a header to another date partition (decision D, §2.1) breaks no constraint |
ft_sales_detail (T:2656, partitioned by created_date, T:2775; no company_id column) | item_id, item_entity_type (ITEM), uom_code, qty, money columns, is_serialized, is_batch_tracked, serial_no / batch_no (newline-separated), is_service_or_non_stock, is_non_refundable (T:2706), item_cost (T:2681), item_barcode (T:2666), item_category_code (T:2669), uom_code (T:2680), is_returned (T:2722), ref_sales_detail_id (T:2723), action_for_returned_item (T:2737), per-line voided_or_returned_* (T:2738-2742), posted_status | action_for_returned_item text DEFAULT NULL — comment T:2816: "Restock (added back to inventory) · Write Off" — already exists, the till fills it, the stock function reads it |
original invoice lines (ft_sales_detail) | qty_returned (T:2730) bumped on Complete Refund; is_returned set on Complete Refund when qty_returned = qty (today's bump_qty_returned never sets it, §2.5 D1) | |
ft_sales_parent_child (T:3183; no company_id column) | parent_sales_id = invoice, child_sales_id = return | the path for "qty on other open returns" (V4): parent › children › their lines by sales_id (indexed, T:2828). ref_sales_detail_id has an index, fk_ft_sales_detail (T:7740, single column, no created_date) — but a search by it alone still opens every monthly partition, so V4 goes parent › children › lines by sales_id and a created_date window (corrected in Revision 4; Revision 3 said "no index") |
ft_sales_payment (T:3200, partitioned by trx_date, T:3264; no company_id column) | one negative local_amt row with the refund method, payment_ref_no (T:3250); written when the return is saved (Draft), replaced on edit, re-checked on Complete Refund — §2.1 | NOT NULL with no default: sales_id, tender_type_id, currency_iso_code — all three supplied |
tender_type (T:7288) / store_tender_type (T:7042) | tender_json.is_refundable (T:7335 — may a sale paid with this tender be refunded; default true, Gift Card seeds false); store_tender_type.is_enable_for_refund (T:7049, comment T:7332 — may this tender be offered as the refund payout at this store) | V3, V9 — the till's two rules |
company.company_settings_json / store.store_settings_json | LIMIT_SALES_RETURN_IN_DAYS — company default 30 (core/data/company_settings_default.json:5; models/company_settings.py:62), store value null = use the company's (store_settings_default.json:6; models/store_settings.py:24) | the till reads the same key (sale_return_exchange_utils.dart:84-93); V3 |
ft_stock_trans (T:4145) | written by fn_api_inventory_set_sale_stock_movement for each restocked line (F:4847-4863): item, serial_no, batch_no, chg_qty, doc_ref_table = 'ft_sales_detail', doc_detail_id, stock_loc_id (NOT NULL, T:4150 — the store's default stock location; none → the function raises "No default stock_location for company % store %", F:3658) | stock on hand and the daily balance follow by trigger / inline call |
workflow_status_log (T:7435) | one row per move, via change_status() | |
general_reasons (T:4620) | reason type SALES_RETURN: DAMAGED_GOODS, DISSATISFIED, INCORRECT_ITEM, INCORRECT_VARIANTS, WRONG_TENDER (seed_general_reasons.sql:66-71) | |
item (T:5190) | is_service_or_non_stock, is_serialized, is_batch_tracked, is_non_refundable (T:5212) — no return default today | the "When a customer returns this item" value comes from the Item module (out of scope); this plan reads it when set (§3.5) |
company.company_settings_json / store.store_settings_json | RETURNED_ACTION_CONTROL — RESTOCK (company default, company_settings_default.json:12; models/company_settings.py:72), store value null = use the company's (store_settings_default.json:8; models/store_settings.py:26) | the till reads it: RESTOCK, WRITE_OFF or PROMPT = ask the cashier per line (exchange_return_controller.dart:127, 348; sale_constants.dart:104-106) |
How the stock function treats each return line (F:4510-4541): on a CREDIT_INV at COMPLETED it loops every line and restocks only when item_entity_type = 'ITEM' AND (action_for_returned_item = 'RESTOCK' OR action_for_returned_item IS NULL) (F:4533), and not for a service / non-stock item (F:4537-4541). A WRITE_OFF line is skipped — no stock row, nothing comes back. So the choice needs no function change. (The Back-Office already writes item_entity_type = 'ITEM', ITEM_ENTITY_TYPE_ITEM in core/constants.py:574.)
Also touched — found in the Revision 4 check:
| Table / column | What the return does | Why |
|---|---|---|
ft_stock_trans serial path (F:4657-4740) and batch path (F:4748-4830); unit_cost, remaining_bal; CREDIT_INV rows are stock-in and FIFO-used (T:4237, T:4246) | Written by the stock function, not by Back-Office code | a serialised or batch line restocks through these paths, with cost; nothing to change, but the V13 tests cover one serial and one batch line |
item_component (read at F:4543+); ft_sales_detail.parent_sales_detail_id (T:2661), item_entity_type = 'COMPONENT' (T:2778) | Read | a bundle restocks its children through the function. On step 1, component lines are not shown — the user returns the mother line only |
ft_sales_header.dayshift_id / offline_dayshift_id (T:2874-2875, "only if from POS", T:3010) | Left NULL | a Back-Office return belongs to no till shift. So it is not in the shift reports (F:2428-2462, F:6589-6592) — the cash leaves outside the till drawer. It is in every report by date (V:400-405, V:609-643, F:2270-2428, F:6562-6592), which already count CREDIT_INV / REFUND; add them to the §2.3 reader list |
ft_sales_header.einv_status / einv_header_id (T:3003-3004); ft_einv_header / ft_einv_detail (einv_type CREDIT_NOTE / REFUND_NOTE, T:1818) | Left NULL / not written | e-invoice for a return is out (§1.2, R6) |
ft_sales_header.total_pts_awarded (T:2942), ft_sales_detail.pts_awarded (T:2736), ft_loyalty_trans (T:2089; trx_type PTS_DEDUCTED = "revert AWARDED points — Return / Void", T:2171), customer_loyalty_card.active_pts / awarded_pts (T:600-601) | Not touched this round | a Back-Office invoice gives no points today — rule kept for later (§3.6) |
ft_giftcard (T:1943; sales_detail_id T:1976, status T:1994, bal_amt T:1958, total_amt_redeemed T:1960), ft_giftcard_trans (T:2005; trx_type Voided, T:2081), ft_sales_detail.giftcard_code (T:2671) | Not touched this round | a Back-Office invoice sells no gift card today — rule kept for later (§3.6) |
ft_giftcard_trans (T:2005), ft_credit_note (T:1191) | Not touched | Gift Card and store credit are not refund methods (V9, §1.2) |
ft_sales_payment.is_psp_payment / is_psp_trans_refunded, tender_category, is_voided (index idx_sales_payment_aggr, T:7771) | Read | a card paid through the terminal is refunded on the terminal first; the Back-Office records the reference only (§6.3 step 2) |
ft_psp_trans (T:2181), customer_rating (T:634), ft_dayshift*, ft_cash_movement | Not touched | no link a return needs |
shipment_details (T:6651, sales_detail_id NOT NULL T:6656) | Read only | a shipped invoice line can still be returned |
stock_on_hand (T:4086) | Not touched | kept by the trigger |
Two existing gaps found, outside this plan:
- On a void of a completed till return the reverse branch (F:4907) undoes only
= 'RESTOCK', notNULL— a till line with no action is restocked on completion but not taken out again on void. Not reachable from the Back-Office (a Back-Office return is voided only at Approved, before any stock moved, and always carries an action). Reported for the till / DB owner. - The field-option labels are capitals with notes: "WRITE OFF (ESPECIALLY FOR FNB)", "RESTOCK (ADDED BACK TO INVENTORY)" (
core/field_options.py:537-541). This plan relabels them Write off and Back to stock in both field-option files (§3.5).
2.1 The return document (no new table, no new column)
A return is one ft_sales_header row, as the till and today's Back-Office write it:
| Column | Value |
|---|---|
doc_type | CREDIT_INV (DOC_TYPE_CREDIT_INV, core/sales_doc_semantics.py:24) |
doc_sub_type | REFUND (DOC_SUB_TYPE_REFUND, sales_doc_semantics.py:45) |
source_type | BO |
status | DRAFT › FOR_APPROVAL › APPROVED / REJECTED › COMPLETED; VOIDED |
doc_ref_no | prefix from the company setting CREDIT_INVOICE_DOC_PREFIX (default CI; company_settings_json, read at sales_commit_service.py:1684; seed company_settings_default.json:67), as create_return builds it today. Given at Draft and kept at Complete Refund. |
doc_trx_date | set when the Draft is saved; reset to the moment Complete Refund is pressed (owner, 5 Oct 2026, decision D) — see "The credit invoice date" below. Until then the screens call the Draft's date "Created", never "Date". |
customer_id, store_id, currency_iso_code | copied from the invoice |
notes | the user's note (printed when the "Document note" print choice is on). Not "Return reason: DAMAGED_GOODS" any more (§2.5 D3). |
| Money columns | negative: total_amt_b4tax, total_tax_amt, total_amt_after_tax on each line, pro-rata of the invoice line (§3.2, D7); header total_amt_after_disc_b4tax_sc_rd, tax_amt, rounding_amt, total_amt_after_disc_tax_sc_rd filled at Draft from the lines |
total_amt_paid, is_paid, total_bal_amt | at Draft: 0.00, false, 0.00. Set only at Complete Refund: total_amt_paid = −refund total, is_paid = true. (So delete_document's "money held" guard, total_amt_paid > 0, sales_lifecycle_service.py:355-360, never blocks deleting a return Draft.) |
total_cost, total_profit | as the till writes a return (sale_return_exchange_utils.dart:488, 497-498, 709-724): line total_cost = −(invoice item_cost × qty returned); line total_profit = −(line refund before tax − invoice item_cost × qty returned), i.e. the original margin taken back; header = the sum of the lines (D5). Today both are 0.00 (sales_commit_service.py:1761-1762). |
sales_rep_user_id, sales_rep_code | left NULL, as today (sales_commit_service.py:1774-1778: "A refund credits no sales rep … would double-count them on the Sales by Rep report"). Rule chosen after reading that comment: the return shows the invoice's sales rep — read through the parent link, on the step form card, the view page and the print (sales_rep print choice) — but the rep is not written on the return header, so a return never changes the rep's sales figures. |
Lines: one ft_sales_detail per returned line (one per batch when a line comes from two batches, the G22 rule of the invoice plan). ref_sales_detail_id points at the invoice line (always set), is_returned = true, qty returned (negative), the serials / batch picked (§2.5 D2), action_for_returned_item = RESTOCK or WRITE_OFF — always set by the Back-Office, never NULL (§3.5). Every other item field is copied from the invoice line (§2.5 D6): item_barcode, item_category_code, uom_code, item_cost, is_service_or_non_stock, is_serialized, is_batch_tracked, is_taxable, tax_class_id.
Link: one ft_sales_parent_child row (parent = invoice, child = return), written on create, removed when the return is deleted.
Refund money (owner, 5 Oct 2026, decision A). The refund method and its reference are kept on the Draft by writing the return's ft_sales_payment row when the return is saved: negative local_amt = the refund total, the chosen tender (tender_type_id, tender_name, tender_code, tender_category), payment_ref_no = the Reference no. typed on step 2, currency_iso_code from the invoice — through the existing sales_commit_repository.insert_sales_payment (:429, which already takes payment_ref_no). On every edit the row is deleted and written again with the new method, reference and total. It counts nowhere until Completed, because the §2.3 rule hides the whole document while it is Draft / Approval pending / Approved / Rejected / Voided — every reader of ft_sales_payment that adds money up must join the header and apply that rule (list in §2.3). At Complete Refund the row is re-checked, not created: exactly one live row, its amount equals the refund total recomputed under lock, its tender still passes V9; any mismatch → 409 REFUND_CHANGED and nothing is written; the screen explains it and offers the way out (V13a).
The credit invoice date (owner, 5 Oct 2026, decision D). The credit invoice is dated the day Complete Refund is pressed. In the V13 transaction, before the stock movement, the return header's doc_trx_date is set to now — doc_trx_date is the partition key (T:3007), so the UPDATE moves the row to that day's partition; no FK points at the header (T:8132-8244), so nothing breaks — together with the payment row's trx_date (its partition key, T:3264). doc_ref_no keeps the number given at Draft. The return's lines are partitioned by created_date and every reader finds them by a window on the header date (created_date >= doc_trx_date::date − 1 day, e.g. sales_commit_repository.get_returnable_lines:601-602, sales_document_repository.delete_document_rows:195); a Draft completed days later would fall outside that window. So in the same step the return's lines get the same new date: UPDATE ft_sales_detail SET created_date = <the new doc_trx_date> WHERE sales_id = <return> — PostgreSQL moves each row to the right partition and keeps its sales_detail_id, so nothing that points at a line (tax rows, stock rows written later) breaks. No delete and re-insert. The return's ft_sales_item_tax rows (partitioned by created_date, T:~3109-3142; written for returns, sales_commit_repository.py:411) get the same created_date update in the same step (a Back-Office return writes no ft_sales_promo_disc row — coupons stay used, §1.2; corrected in Revision 4) — delete_document_rows (sales_document_repository.py:196), the view page and print find them by a window on the header date. Test: a Draft completed 3 days later still shows its tax rows on the view page and print. Reporting: a completed return is reported on the Complete Refund day — sales summary, day totals and month cut-off all follow doc_trx_date (intended, decision D). Until Complete Refund the Draft's date is shown only as "Created".
Reasons (all stored as codes, shown as words). One reason per return line: units of one invoice line that came back for two different reasons are two returns, or the user picks the main reason — the till's split-by-reason is not copied (owner, 5 Oct 2026, R12 closed):
| What | Where it is stored | Values |
|---|---|---|
| Return reason — per line (owner, 5 Oct 2026) | on each return line: ft_sales_detail.voided_or_returned_reason_code (code), _notes (the reason in words, as the till writes it), _by, _by_user_id, _datetime (who keyed it, when) — T:2738-2742, the columns the till already fills (exchange_return_controller.dart:503-510) | The five General Reasons of type Sales Return: Damaged goods · Customer not satisfied · Wrong item · Wrong size or colour · Paid the wrong way (VALID_RETURN_REASON_CODES, sales_commit_service.py:92) |
| Return reason — header | voided_or_returned_reason_code on the return header = the lines' reason when all lines share one; NULL when they differ (the list then shows "Several reasons"). Derived on save, never typed | same codes |
| Reject / Void reason | workflow_status_log.remarks (and the five voided_or_returned_* columns on a void, the invoice-plan rule) | free text, 1–500 characters |
> R7 closed (owner, 6 Oct 2026): a void writes the void into the header's voided_or_returned_* columns as void_document does today; the return reason stays on each line (ft_sales_detail.voided_or_returned_reason_code — no void touches the lines). Both are kept, no new column.
2.2 Status words and colours
The invoice plan's §6.3a table applies. A return uses: Draft (neutral) · Approval pending (waiting) · Approved (accepted) · Rejected (problem) · Completed (done) · Voided (problem). The invoice gains nothing new — it already has Returned (blue, accepted). There is no Return Type tag: every Back-Office return comes from an invoice.
2.3 What counts as a sale
A return that is not Completed must not count anywhere. Today the rule bo_not_a_sale_predicate() (core/sales_doc_semantics.py:95) hides Back-Office rows at Draft / Approval pending / Rejected / Voided, and a SALES_INV at Approved. It must also hide a CREDIT_INV at Approved:
NOT (source_type = 'BO' AND (status IN ('DRAFT','FOR_APPROVAL','REJECTED','VOIDED')
OR (status = 'APPROVED' AND doc_type IN ('SALES_INV','CREDIT_INV'))))
Same change in v2-sellable-api sales_query.bo_not_a_sale_sql() (sales_query.py:53; till sync, sales reports, e-invoice lookup). Sales reports already take CREDIT_INV as the negative side (SALES_REPORT_DOC_TYPES, sales_doc_semantics.py:40).
Payment readers (decision A). A return Draft now holds a negative ft_sales_payment row, so every reader that adds up payment rows must join ft_sales_header (on sales_id, with company_id — the payment table has none) and apply bo_not_a_sale_predicate() / bo_not_a_sale_sql(). Found by git grep "FROM|JOIN ft_sales_payment" on b01e305cd:
| Reader | Today | Change |
|---|---|---|
BO repositories/sales_collection_repository.py — _sales_collection_grouped_sql (:144), query_sales_collection_totals (:395), query_sales_collection_detail (:514, :556), _detail_export_query (:710) | joins the header, filters is_voided and doc_type IN ('SALES_INV','CREDIT_INV') — no status rule; a return Draft's row would count as a refund | add the predicate |
BO repositories/business_day_repository.py — get_sales_collection (:1156), get_register_sales_collection (:1548) | joins the header, is_voided only — no status rule | add the predicate |
BO repositories/exchange_item_report_repository.py — _build_filtered_core (:76, :125) | CREDIT_INV, is_voided only — no status rule | add the predicate |
BO repositories/credit_note_report_repository.py (:154) | sums gc_cn_excess_amt of store-credit payment rows only | none: a Back-Office return never pays out in store credit (V9) |
v2-sellable-api modules/report/report_query.py — query_payment_summary (:217) | joins the header, no status rule | add bo_not_a_sale_sql |
v2-sellable-api modules/sales/sales_query.py — _build_refund_exchange_where_clauses (:426), query_get_store_level_performances (:937), query_count_sales (:5472) | no bo_not_a_sale_sql (it is used only at :5227-5296, the till sync) | add it |
Already safe: BO dashboard_repository (:220), sales_summary_repository (:300, :796), view_sales_repository, sales_invoice_repository (:611, :638) and v2-sellable-api einvoice_query.py apply the predicate; sales_query.query_get_sales_by_stores (:808) and the top-rankings filter (:1094-1274) keep status IN ('COMPLETED','RETURNED'); tangent_mall_ftp.py (:141, :174) keeps status IN ('COMPLETED','REFUNDED') | — | none; covered by a reader test |
Per-document readers — BO sales_payment_repository (:53, :186, :292, :316), sales_document_repository (:77, :205, :813), sales_return_repository (:400, :425), sales_order_repository (:397) | read one document's own rows | none: the return's view page shows its own payment row as "to be refunded" |
2.4 What the till does (checked 25 Sep 2026)
- Return with a receipt:
SaleUtils().refundProcess(...)inv2-pos-client/client/lib/app/utils/module/ext/sale/sale_return_exchange_utils.dart:275. It writesCREDIT_INV/REFUND, statusCOMPLETED. - Without a receipt (till only — not in the Back-Office):
exchange_return_without_receipt_controller.dart:927–928calls it withparentSaleDetails: []— no parent link. Guarded by role accessPOPC_NEW_SALES_RETURN. - With a receipt: the original header turns
RETURNEDon any return, part or full (sale_return_exchange_utils.dart:738); each original line getsqty_returnedandis_returned = 1when nothing is left.
The Back-Office follows the same shape so a return reads the same in every report, whichever app wrote it.
2.5 Today's Back-Office return — defects this plan fixes
Today: POST /api/sales-returns (api/sales_returns.py:161, permission sales.return.manage) → sales_commit_service.create_return (:1603). One transaction: header at COMPLETED, lines, bump_qty_returned, negative payment, parent link, stock movement. Screen: pages/sales/SalesReturnCreatePage.tsx (one page, route /sales/new-return?original=).
| # | Defect | Fix in this plan |
|---|---|---|
| D1 | No status check and loose tenancy on the original. get_original_sale_for_return (sales_commit_repository.py:555) filters by company, id and date only — a Draft or Voided invoice can be "returned" through the API. get_returnable_lines (:579) and bump_qty_returned (:610) filter ft_sales_detail by sales_id / sales_detail_id and a date window with no company_id — and ft_sales_detail, ft_sales_payment and ft_sales_parent_child have no company_id column. The non-replay path of create_return never calls require_record_store on the original (only the replay path checks the store, require_permits at sales_commit_service.py:1644). bump_qty_returned never sets is_returned on the original line. | Original must be a SALES_INV at COMPLETED or RETURNED (V3). Every read and write of a detail, payment or parent-child row joins ft_sales_header on sales_id and filters h.company_id (V1); require_record_store on the original in every path; Complete Refund sets the original line's is_returned = true when qty_returned reaches qty. |
| D2 | Serials cannot be picked. Serial and batch are carried today, but only whole-line: tracking.returned_tracking(src, qty) (sales_commit_service.py:1810; sales_tracking_service.py:226) copies every serial of the source line and refuses a part qty (SERIALS_NOT_CHOSEN), and refuses a line holding more than one batch (BATCH_NOT_CHOSEN). The user cannot say which serials came back, and nothing checks a serial against the invoice. | The user ticks the serials / picks the batch that came back; the server checks them (V7), in the till format of invoice plan §3.13. |
| D3 | Raw code printed. notes = f"Return reason: {body.reason_code}" puts "DAMAGED_GOODS" on the credit invoice. | Reason code to voided_or_returned_reason_code; notes = the user's note only; the credit invoice prints the reason as words (print choice "Return reason", §3.4b). |
| D4 | No audit, no RETURNED. No log_initial_status(), no change_status(); the original invoice never turns Returned (the till does it, §2.4). The workflow guard does not catch it because the header is inserted, not updated. | log_initial_status() on create; every move through change_status(); the original moves to RETURNED through change_status() in the Complete Refund transaction. |
| D5 | No cost on a return line. Today's return writes item_cost = None on each line and total_cost = 0 on the header (sales_commit_service.py:1823, 1761), so a written-off item leaves no trace of what it cost. | Each return line copies item_cost from the invoice line it returns; line and header total_cost = −(item_cost × qty) and total_profit as §2.1, as the till writes a return. get_returnable_lines must select item_cost. |
| D6 | Return lines lose the invoice line's data. The insert forces item_barcode=None, item_category_code=None, uom_code="", item_cost=None, is_service_or_non_stock=False (sales_commit_service.py:1819-1827), and insert_sales_detail (sales_commit_repository.py:308) has no parameter for action_for_returned_item or the five voided_or_returned_* columns. | Copy every field from the invoice line. insert_sales_detail gains action_for_returned_item, voided_or_returned_reason_code, _notes, _by, _by_user_id, _datetime and tax_class_id (all optional, default NULL). get_returnable_lines also selects item_cost, uom_code (already), item_category_code, item_barcode, is_service_or_non_stock, is_non_refundable, total_amt_b4tax, total_amt_after_tax. |
| D7 | Refund maths wrong. create_return always sets grand total = subtotal + tax (sales_commit_service.py:1723-1734) — wrong on a tax-inclusive invoice, where the invoice's own total is subtotal − discounts with the tax already inside (sales_calculation_service.py:588-591). It also treats item_cart_prorata_disc_amt as a line total, but it is already per unit (sales_calculation_service.py:584: prorata * line.qty). | Pro-rata of the invoice line's stored amounts (§3.2), the till's method (sale_return_exchange_utils.dart:429-468). |
Kept as is: the idempotency key, the self-refund guard (S9, sales_commit_service.py:1662 — you may not refund a sale you rang up yourself), the one-transaction rule. Replaced: the refund maths (D7). Widened: the tender check — today only "tender exists and is active for the company" (get_tender_type, sales_commit_repository.py:482); V9 adds the store and stored-value rules.
2.6 Validation and safety net
The invoice plan's rule stands: the screen guides, the server decides. Every check runs on the API even when the screen greys the button.
| # | Rule | Screen | API | What the user reads | Test |
|---|---|---|---|---|---|
| V1 | Tenant and store scope on every read and write | — | company_id on every header query; ft_sales_detail, ft_sales_payment and ft_sales_parent_child have no company_id, so every read and write of them joins ft_sales_header on sales_id and filters h.company_id — including get_returnable_lines and bump_qty_returned (today without it, D1); require_record_store on the return and on the original invoice in every path (today the non-replay create skips the original, D1) | 404 / 403 | another company's invoice id → 404; another company's ref_sales_detail_id → 404; a store outside the user's scope → 403 |
| V2 | One writer moves a status | — | SELECT … FOR UPDATE, then change_status(); status no longer the expected one → 409 | "Someone else changed this return. Reload it." (STATUS_CHANGED) | two approvers at once |
| V3 | Return only a completed, recent, refundable invoice | "Return Items" only on Completed / Returned-with-qty-left, and hidden when the invoice is past the return limit (the owner still sees it) | original must be SALES_INV, COMPLETED or RETURNED, source_type = 'BO' (R3) → 409 ORIGINAL_NOT_RETURNABLE. Return limit (owner, 5 Oct 2026, decision B): invoice doc_trx_date + LIMIT_SALES_RETURN_IN_DAYS < today → 409 RETURN_WINDOW_PASSED; days = the store's value, null → the company's (shipped 30); rbac_service.is_owner() (rbac_service.py:108) is exempt; the till reads the same key (sale_return_exchange_utils.dart:84-93). Non-refundable tender: any live payment on the invoice whose tender has tender_json.is_refundable = false (T:7335) → 409 TENDER_NOT_REFUNDABLE, the till's rule (sale_return_exchange_utils.dart:143-213) | "Only a completed invoice can be returned." · "This invoice is older than N days, the return limit." · "This invoice was paid with Gift Card, which cannot be refunded." | Draft / Approved / Voided original; invoice 31 days old (refused), owner (passes), store value 7 overrides company 30; invoice paid with Gift Card |
| V4 | Never more than is left | Return Qty turns red over "Can return" | Can return = sold − qty_returned − qty on this invoice's other open returns (Draft / Approval pending / Approved / Rejected); checked on save, on submit and again under lock on Complete Refund → 422 QTY_OVER_RETURNABLE | "Only 1 of EQ-STOVE-3B can still be returned." | two drafts that together exceed the sold qty; the second fails on submit |
| V5 | At least one line with qty > 0 | Next marks the Qty boxes | 422 EMPTY_DOCUMENT | "Pick at least one item to return." | submit with all zero |
| V6 | Reason on every returned line | Next marks each returned line with no reason (red border, "Choose a reason") | every returned line needs a valid SALES_RETURN reason code → 422 REASON_REQUIRED with the line | "Choose why Basmati Rice 10 kg is coming back." | one line missing; bad code; header derived right (same / mixed) |
| V7 | Serial / batch complete | chip amber until done; Next marks a chip still to pick in red | every serial must be one sold on that line and not already returned; batch quantities add up → 422 TRACKING_INCOMPLETE / SERIAL_NOT_ON_INVOICE | "Serial SN-0042 was not sold on this invoice." | foreign serial; serial returned twice |
| V8 | Refund price | read-only | the server recomputes the unit refund from the invoice line and ignores any price sent | — | tampered price ignored |
| V9 | Refund method chosen and valid | starts empty, never pre-selected; Next with none chosen marks it red ("Choose how the refund is paid back.") + error toast + focus; select lists only the store's refund methods; a card method shows "Refund the card on the terminal first, then enter its reference." and Reference no. becomes required | tender active for the company; store_tender_type.is_enable_for_refund = true for the return's store (T:7049; the till's payout list, tender_type_dao.dart:169, 178); not stored value (STORED_VALUE_TENDER_CATEGORIES, sales_doc_semantics.py:131) → 422 REFUND_METHOD_NOT_ALLOWED. One refund method per return, for the whole refund total. The list holds only the payment methods flagged for refund at the store (is_enable_for_refund); there is no cap by what each method took on the invoice (owner, 6 Oct 2026, R13 closed). Card (tender_category = 'CARD', field_options.py:1608) needs payment_ref_no → 422 REFERENCE_REQUIRED. | "This payment method cannot be used for a refund." · "Enter the card terminal's reference." | gift card tender; tender not enabled for refund at the store; split Cash 50 + Card 50, refund 100 by Cash allowed; card without reference |
| V10 | Original invoice required | /sales/return/new with ?original= opens on that invoice (from Return Items); without it, step 1 opens on the invoice list (§6.3, Revision 4). Next with no invoice picked marks the search box | original_sales_id / original_doc_trx_date are already required in CreateReturnRequest (models/sales_creation.py:246-247, extra="forbid") → 422; kept | "Choose the invoice the items were sold on." | create with no original |
| V11 | Approve / Reject by an approver, not the author (owner exception) | hidden from the author | sales.return.approve; raiser from the first workflow_status_log row; rbac_service.is_owner() passes → 403 SELF_APPROVAL | "You cannot approve a return you raised." | author; owner; no permission |
| V12 | Wrong status | buttons per status (§6.4) | VALID_TRANSITIONS (§3.1) → 409 STAGE_NOT_ALLOWED | "This return is not waiting for approval." etc. | every wrong-status call |
| V13 | Complete Refund is all-or-nothing | — | one transaction, in this order: (1) lock the original invoice header (get_original_sale_for_return, SELECT … FOR UPDATE, sales_commit_repository.py:555), then the return header — always this order, so two returns of one invoice cannot deadlock; (2) the store has a default stock location (store_has_default_stock_location, sales_commit_repository.py:695; ft_stock_trans.stock_loc_id is NOT NULL, T:4150) else 409 STORE_HAS_NO_STOCK_LOCATION "{store} has no stock location set up, so returned items cannot go back on its shelf. Ask an administrator to set one up." — the past half-written failure (SI-994) is told at sales_commit_service.py:1702-1711, the invoice's own check is :1102-1108; (3) V4 re-sum of open returns under the lock; (4) re-check the payment row (§2.1, decision A); (5) doc_trx_date and the payment trx_date set to now, the lines' created_date updated to the same date (rows move partition, ids kept; decision D); (6) bump_qty_returned, and is_returned = true on each original line whose qty_returned = qty; (7) return → COMPLETED with total_amt_paid / is_paid (change_status()); (8) then the stock movement; (9) original → RETURNED; any failure rolls all back. The order of 7 and 8 matters: fn_api_inventory_set_sale_stock_movement reads the header status live (functions_pg.sql:3616) and restocks a CREDIT_INV only when it is already COMPLETED (:4510) — called earlier it does nothing and raises nothing | 500 with the request id; nothing half-done | fail the stock step → return still Approved, payment row and date unchanged; store with no stock location → 409 before any write |
| V13a | The refund changed after it was sent / approved (owner, 6 Oct 2026) | the view page of an Approval pending or Approved return works the refund out again on open (read only). When it differs from the saved payment row, an amber note sits above the details: "The refund changed after this return was approved. Approved at RM 128.00 · now RM 127.95. Why: another return on the same invoice (SI-…) was completed, so the leftover sen from rounding now falls on this return. The refund cannot be paid at the old amount. What to do: void this return and start it again from the invoice — the new one is worked out at the right amount and goes for approval again." When the card was already refunded: "The card was already refunded on the terminal (ref …) — sort the difference out with the customer." Button in the note: Void and Start Again (Approved) — opens the void reason card filled with "The refund changed after approval. Made again at the new amount."; on confirm the return is voided and a new return opens on the same invoice with the same lines, reasons and choices filled in, at the new amount. On Approval pending the note says to reject it and the button is Reject so It Can Be Fixed; the author's Edit works the refund out again. Complete Refund pressed anyway → error toast "The refund changed after approval — void this return and start it again", and the page scrolls to the note. | GET /{sales_id} adds refund_check: { saved_total, current_total, reason_code, related_ref } (worked out again, no write); Complete Refund keeps 409 REFUND_CHANGED with data.saved_total / data.current_total | the texts in the column to the left | approve, then complete another return of the same invoice → note shows, Complete refused, Void and Start Again opens a filled new return |
| V14 | Void only at Approved | Void Return only on Approved | → 409 STAGE_NOT_ALLOWED; a completed return is not voided in the Back-Office | "A completed return cannot be voided." | void completed |
| V15 | Retry safety | — | Idempotency-Key on create and on Complete Refund | second identical request → the first result | replay |
| V16 | Self-refund | — | kept: a user may not return a sale where they are the cashier (S9, sales_commit_service.py:1662) — the owner is not exempt here (money leaves) | "You cannot refund a sale you made yourself. Ask another team member." | cashier returns own sale |
| V17 | Every move leaves a trace | — | log_initial_status() / change_status() only; check_workflow_registry.py passes | — | one log row per move |
| V19 | What happens to each item | every line shows Back to stock / Write off, pre-filled; with the setting "Ask" the line starts empty and Next marks each one still empty ("Choose one") | action_for_returned_item required on every returned line, RESTOCK or WRITE_OFF (is_valid_option) → 422 RETURN_ACTION_REQUIRED; a service / non-stock item is saved as WRITE_OFF whatever is sent (nothing to restock) | "Choose what happens to Food Container 750 ml." | missing action; bad value; non-stock line |
| V20 | Cost carried | — | each return line's item_cost = the invoice line's; header total_cost = sum of the lines (D5) | — | written-off line shows its cost in the list |
| V18 | Print and e-mail only when finished | Print / Print with options / Send Email only on Completed and Voided (canPrintOrEmail, lib/constants/sales-lifecycle.ts:40) | the receipt, preview, print-options and send-email routes call a new sales_return_service.assert_receipt_access() (company, doc_type = 'CREDIT_INV', require_record_store) — it does not exist for returns today, and today's GET /{sales_id}/receipt (api/sales_returns.py:94-112) has no store check, a gap this closes; print_options validated by parse_print_options (bad JSON or key → 400, not 500); required credit-invoice keys forced on by the renderer | "No customer email address on this return" (toast) | bad print_options; required key sent as false still prints |
| V21 | Non-refundable item | the line shows "Cannot be returned", greyed, no Return Qty box | a line whose invoice line has ft_sales_detail.is_non_refundable = true (T:2706; from item.is_non_refundable, T:5212) → 422 ITEM_NOT_REFUNDABLE with the line | "Food Container 750 ml cannot be returned." | non-refundable line sent with qty 1 |
| V22 | Delete only Draft / Rejected | Delete only on Draft / Rejected (sales.return.delete) | sales_lifecycle_service.delete_document(doc_type='CREDIT_INV') (:332): status not Draft / Rejected → 409 STAGE_NOT_ALLOWED; the payment row, lines and log go with it (sales_document_repository.delete_document_rows, :177) and the ft_sales_parent_child row (added to that function; today it does not delete it). Deleting a stale Draft frees its qty for V4 | "Only a draft or a rejected return can be deleted." | delete Approved (refused); delete Draft → its qty is returnable again, no payment or link row left |
| V23 | Priced by the server, then sent | — | calculation_token is already required on CreateReturnRequest (models/sales_creation.py:251) but create_return never verifies it. POST /calculate issues it (a return variant of issue_calculation_token, sales_commit_service.py:244, hashing original id + lines + qty + refund method) and create and edit verify it as the invoice does (verify_calculation_token, :256; the token lives 90 s, CALCULATION_TOKEN_TTL_SECONDS, :99). Submit, Approve and Complete Refund take no token — they work the refund out again on the server and compare it with the saved payment row (V13a). Create / edit: 400 CALCULATION_TOKEN_MISMATCH / CALCULATION_TOKEN_EXPIRED. Submit / Approve / Complete: 409 REFUND_CHANGED with data.saved_total / data.current_total — on Submit of a Draft / Rejected return the form opens on step 2 with the line "The refund is now RM … (was RM …). Check it and send again." and Save draft writes the new amount | "The return changed after it was priced. Recalculate and try again." | stale token; token from another cart |
3. Business rules
3.1 Lifecycle
| From | Action | To | Who | Side effects |
|---|---|---|---|---|
| (new) | Save draft / Next from step 1, once lines are picked | DRAFT | sales.return.manage | header, lines, parent link, the refund payment row (hidden, §2.1 decision A); log_initial_status(). No money paid, no stock. V3, V21, V23 checked. |
DRAFT, REJECTED | Edit | same | .manage | lines and payment row replaced; version must match. |
DRAFT, REJECTED | Delete | (gone) | sales.return.delete | V22 — header, lines, payment row, parent link and log rows removed; nothing logged. |
DRAFT, REJECTED | Submit for Approval | FOR_APPROVAL | .manage | V4–V9 checked. |
FOR_APPROVAL | Approve | APPROVED | .approve | nothing else. |
FOR_APPROVAL | Reject | REJECTED | .approve | reason → remarks, red banner. |
APPROVED | Complete Refund | COMPLETED | .manage | V13 transaction — original locked first; payment row re-checked; credit invoice dated today (decision D); status to COMPLETED before the stock movement (Back to stock lines only, §3.5); original → RETURNED (remarks = "Returned by CI-…"). |
APPROVED | Void Return | VOIDED | .manage | reason; nothing to undo — no money or stock moved yet. |
Today VALID_TRANSITIONS (sales_lifecycle_service.py:51) is one flat map keyed by status, shared by the order and the invoice, and APPROVED goes only to VOIDED. Adding APPROVED → COMPLETED to that map would also open it for orders and invoices. So the map becomes per document type (VALID_TRANSITIONS[doc_type][status]), with CREDIT_INV = the invoice's moves plus APPROVED → COMPLETED; _NOUN gains CREDIT_INV: "return"; the paid-in-full gate (_NOT_PAID_REASON) does not apply to a return — its money moves at Complete Refund. The original invoice gets one new move: COMPLETED → RETURNED (and RETURNED → RETURNED writes nothing — change_status() skips a same-status call).
Why money moves at Complete Refund and not on the Draft (the invoice takes payments on the Draft): on an invoice the money comes in, so taking it early is safe. On a return the money goes out, so the Back-Office pays nothing itself until Complete Refund. Exception, owner, 6 Oct 2026 (C1): a card refund is done on the card terminal by the cashier before the Draft is saved, and its reference is keyed in — so for a card the money may already be out when the manager approves; Reject / Void / Delete then warn about it (§9A C1). The Draft records how the money goes back (its payment row, decision A) so the approver sees the method and reference; that row counts nowhere until Completed (§2.3).
Delete reuses the invoice's sales_lifecycle_service.delete_document (:332, invoice route api/sales_invoices.py:419) with doc_type = 'CREDIT_INV'; delete_document_rows (sales_document_repository.py:177) gains the ft_sales_parent_child delete (child_sales_id = $1). An abandoned Draft otherwise holds its qty against V4 for ever.
3.2 The numbers
- Lines offered: every line of the invoice with Can return > 0 (V4). A line already fully returned shows greyed with "Returned".
- Refund for a line (D7) = what the customer paid for that line, from the invoice line's stored amounts, pro-rata × qty returned / qty sold. Which stored column holds "what was paid" depends on the invoice's own flag
ft_sales_header.is_tax_included_in_price(T:2994 — the invoice's flag, not today's company setting): - tax-exclusive invoice: paid =
total_amt_after_tax; tax =total_tax_amt; net =total_amt_b4tax. - tax-inclusive invoice: paid =
total_amt_b4tax(price less discounts, tax already inside); tax =total_tax_amt(shown, not added); net = paid − tax. Nevertotal_amt_after_tax: the Back-Office stores it asunit_net × qty + unit_tax × qtywhatever the flag (sales_batch_split_service.py:108-117), so on an inclusive invoice it holds the tax twice — an invoice-side storage fault, reported for the invoice plan (§9A note). Discounts and the cart share (already per unit,sales_calculation_service.py:584) are inside the stored figures, so nothing is recomputed from unit prices. Each figure is rounded half-even to the cent. The last return that empties a line takes what is left exactly: stored amount − what earlier returns of that line took — no cent drift. Same shape as the till (stored amounts pro-rata, last return takes the rest —sale_return_exchange_utils.dart:429-468), not identical: the till takes the per-unit tax fromitemCartProrataTaxAmtand rounds per unit; the Back-Office pro-rates the line totals and rounds once per line. Unit Refund on screen = line refund ÷ qty, display only. The old formulaitem_actual_price − item_disc_amt − item_cart_prorata_disc_amt, grand = subtotal + tax (sales_commit_service.py:1723-1734) is removed. - Never more than the items picked (owner, 6 Oct 2026). The refund total is never more than the total of the returned items' refunds: a Cash refund that would round up rounds down instead (RM 35.33 → RM 35.30, not 35.35), and the last return of an invoice pays back what is left of the payment only when that is not more than the items' value. Nor is it ever more than is left of what was paid on the invoice — then the return is refused, 409
REFUND_OVER_PAID. - Rounding. Today a return writes
rounding_amt = 0(sales_commit_service.py:1764). New rule: the last return that empties the invoice refunds exactly what was paid on it (its live payment rows, net of change) less earlier refunds — the difference from the sum of lines goes intorounding_amt. Otherwise, for a Cash refund only (tender_category = 'CASH',core/constants.py:408), the refund total is rounded with the company rule_rounding_of(company)(sales_commit_service.py:322,snap_to_interval) and the difference goes intorounding_amt; any other method refunds to the cent. - The refund method is never pre-selected (owner, 5 Oct 2026): the field starts empty with "Choose how to pay back", and the user picks it within V9 (one method per return for the whole refund; only the store's methods flagged for refund are listed; no cap per method — owner, 6 Oct 2026). The hint under the field reads "Only payment methods set up for refunds at this store are listed. The invoice was paid with Cash RM 120.00." Next with no method marks the field red with "Choose how the refund is paid back.", an error toast and focus (V9). Editing a saved Draft keeps the method it was saved with.
- Serials: the user ticks from the serials on that invoice line that are not already returned.
3.3 (Removed in Revision 2)
Revision 1 had the numbers for a return without an invoice here. That path is out (§1.2).
3.4 Print and e-mail — the same as the sales invoice
Everything in invoice plan §3.10 (print and e-mail as built), §3.11 (print options) and §3.11c (Last page group, page footer, one font in three sizes) applies to the credit invoice. This section names only what is different for a return. A Completed return is printed and e-mailed exactly the way a Completed invoice is.
3.4a The three ways out, and when
| Action | Where | What it does | Permission |
|---|---|---|---|
Print (Printer) | list row menu; view page header icon | prints the credit invoice PDF at once with the company setting for Credit Invoice (GET /api/sales-returns/{id}/receipt) | sales.return.view |
| Print with options | list row menu, next to Print; view page header, next to the print icon | opens the full page /sales/return/:id/print (§3.4b) | sales.return.view |
Send Email (Mail) | list row menu; view page header icon | sends at once to the customer's e-mail with a progress toast ("Sending credit invoice to {email}…" → "Credit invoice emailed to {email}"); no e-mail on the customer → toast "No customer email address on this return" and nothing is sent. The PDF uses the company setting. | sales.return.manage (the list row action also needs sales.return.email, as today) |
Which statuses: only Completed and Voided (the invoice's canPrintOrEmail rule; a return never becomes Returned). A Draft, Approval pending, Approved or Rejected return offers none of the three. A voided return prints with its VOIDED stamp.
The e-mail (services/sales_return_service.send_return_email, sales_return_service.py:334, moved to the invoice's builder services/sales_document_email.py): subject Credit Invoice {ref} — {company}, then — RM{amount} refunded once the refund is paid (always, on a Completed return); the text "We have refunded RM… for the items you returned on {date}. Your credit invoice is attached."; the amount card reads Refunded RM… (never "Amount due"); the original invoice number under it. The attachment is credit_invoice_{doc_ref_no}.pdf. Outcome written to the header as today (is_emailed, email_status, email_error_msg).
A Voided return (printed or e-mailed): the sheet title reads VOIDED CREDIT INVOICE with the VOIDED stamp, a red band under the identity panel "Voided on {date} by {name}. Reason: “…”. No refund was paid on this credit invoice. It keeps its number and date so the record stays traceable.", and no refund block (the payments choice prints nothing). The e-mail subject is Credit Invoice {ref} voided — {company} (no amount); the text "Please be informed that credit invoice {ref} has been voided. No refund was paid on it and nothing further is owed. A voided copy is attached as a PDF for your records."; no amount card. A return is voided only at Approved, so no money ever moved.
3.4b Print options for the credit invoice
The invoice's page (SalesPrintOptionsPage, kind = "return"), the same rules: the checklist beside a sticky A4 preview, re-fetched 300 ms after the last tick; Print credit invoice (primary), Download PDF, Send e-mail with the same choices; ‹ Back returns to the view page; copies 1–5 and the ORIGINAL / COPY stamp; remark (max 120 characters, print-time only); "Save as the company default (all stores)" only with ADMINISTRATION_COMPANY_MANAGE, everyone else reads "Only an admin with the Company Settings permission can change this." Two layers, as the invoice: this print › company setting › shipped defaults. Nothing is stored per user.
Document type key: CREDIT_INVOICE ↔ doc_type CREDIT_INV (one more entry in DOC_PRINT_OPTIONS and in the report service's type map). Today the renderer ignores print_options for every type other than SALES_ORDER / SALES_INV (invoice plan §3.11, "Report service"); this plan adds CREDIT_INV. Till returns (source_type not BO) print through the same renderer — they pick up the company setting too, the same as the e-invoice mail does for invoices (§9A R8).
What can be turned off — default in brackets. Counter on the page opens at "7 of 12 included".
| Group | Choice (label the user sees) | Key | Default | Notes |
|---|---|---|---|---|
| Header | Company logo | logo | on | |
| Header | Logo shape — Square / Wide / Tall | logo_shape | square | not a checkbox; under Company logo, only when the logo is on |
| Customer | Customer phone & e-mail | customer_contact | on | customer name always prints |
| Customer | Sales rep name | sales_rep | off | "Sales rep · {name}" in the identity panel |
| Items | Item code | item_code | on | |
| Items | Item barcode | item_barcode | off | text on the item's second line |
| Items | Item note | item_note | on | the returned serial numbers print here |
| Items | Tax code & rate | tax_code | on | off hides the Tax column; the tax amount in the totals always prints |
| Refund | Refund method & amount | payments | on | "Refunded by Cash · RM 209.00 · Sep 18, 2026". Off: the refund total still prints. A Voided return has no refund line either way (§3.4a) |
| Refund | Return reason | return_reason | on | the reason as words ("Damaged goods"), never the code (D3): under each item line ("Reason: Damaged goods"); when all lines share one reason it prints once in the identity panel instead |
| Last page | Signature lines (Prepared / Approved / Received by customer) | signature_lines | off | |
| Last page | Document note | doc_note | off | the note typed on the return (ft_sales_header.notes) |
| Last page | Notes & terms | notes_terms | off | the store's disclaimer / terms text |
Required on a credit invoice (not in the checklist, not counted, forced on by forceRequired(opts, "CREDIT_INVOICE") on the page and by the renderer): Company address & phone (company_address), Unit refund (unit_price), Subtotal and tax lines (totals_breakdown) — a credit invoice corrects a tax invoice, so it shows what the invoice had to show (§9A R9).
Not offered on a credit invoice: Deliver-to (deliver_to) — nothing is delivered; Discount (discount) — the unit refund already has the discount taken off; LHDN e-Invoice QR (einvoice_qr) — out of scope (§1.2); Bank details (bank_details) — the money goes to the customer, not to the company.
Always printed (fixed): credit invoice number, date, seller name, customer name, original invoice number and date, refund total. Title on the sheet: CREDIT INVOICE. The page footer (rule + "Page N of M") prints on every page; the Last page block prints once at the foot of the last page (§3.11c). One font, three sizes (§3.11c).
Storage: company_settings_json.DOC_PRINT_OPTIONS.CREDIT_INVOICE, written by the existing PATCH /api/company-settings/print-options (its doc_type list gains CREDIT_INVOICE). Missing key = the defaults above, no migration. Added to company_settings_default.json (new-company seed). The four copies of the contract (invoice plan §3.11c table) each gain the CREDIT_INVOICE entry and the new key return_reason; the existing contract tests keep them equal.
Company Settings › Advanced › Printing gains a third tab, Credit Invoice, beside Sales Order and Sales Invoice — the same checklist, no preview.
3.5 What happens to a returned item — Back to stock or Write off (owner, 5 Oct 2026)
Where it is stored. Per line: the existing ft_sales_detail.action_for_returned_item (RESTOCK · WRITE_OFF). Per item: the item's "When a customer returns this item" value (item.default_return_action, RESTOCK · WRITE_OFF, empty = not set) — added by the Item module, not by this plan.
What the line starts with — handling an empty item value — the first valid value (RESTOCK, WRITE_OFF, or PROMPT at store / company level); null, an empty string or an unknown word counts as not set and falls to the next level:
- the item's value, when it is set — empty,
NULL, or the column not there yet (the Item module has not shipped it) all count as empty and fall to 2; - the store's
RETURNED_ACTION_CONTROL; - the company's
RETURNED_ACTION_CONTROL(shippedRESTOCK); - nothing valid anywhere →
RESTOCK(what the stock function does with an empty action today, F:4533).
PROMPT ("ask the cashier") at level 2 or 3 above means no pre-fill: the line starts empty and the user must choose. A service / non-stock item is always Write off (shown greyed, "Not a stock item").
Who decides. The user changes it per line on step 1 (a small select in the line, plus Set all to: Back to stock · Write off above the grid). It is saved with the draft, shown read-only on Review & send and on the view page, and can be changed until the return is submitted (Edit on Draft / Rejected). An approver sees it before approving.
What Complete Refund does with it.
| Choice | Stock | Money | Where it shows |
|---|---|---|---|
| Back to stock | the units come back at the return's store (ft_stock_trans row, serial / batch kept) | refund as usual | stock movement report, item stock card |
| Write off | nothing comes back — the units left stock when they were sold and stay out (F:4533 skips the line) | refund as usual | the return line ("Write off"); the Sales Returns list and export gain Written-off Cost = item_cost × qty of the Write off lines (D5), gated by sales.return.field.cost. No stock row is written, so stock reports do not show it — the Sales Returns list is the one place a write-off loss appears |
No stock adjustment document is raised for a write-off: the goods never came back into stock, so taking them out again would count the loss twice.
Labels the user reads (no codes on screen): Back to stock · Write off · Use the store setting. The two existing field-option names are relabelled in core/field_options.py and field-options.ts.
Database: nothing new in this plan — ft_sales_detail.action_for_returned_item already exists and fn_api_inventory_set_sale_stock_movement already honours it. /returnable reads the item value only once the Item module's column exists (B0b); until then every item counts as empty.
Not touched: the till (§9A R10) and Stockable — both read item with explicit column lists (v2-sellable-api item_query.py:75, v2-stockable item_table.dart), so a new column is ignored there until they choose to read it.
3.6 Loyalty points and gift cards — not in this round (Revision 5)
Why out. The grill review (6 Oct 2026) found that a Back-Office invoice never gives loyalty points (no PTS_AWARDED / ft_loyalty_trans write anywhere in the Back-Office backend; points are given only by the till sync, v2-sellable-api sales_query.py:4176) and never sells a gift card (insert_sales_detail always writes item_entity_type = 'ITEM', sales_commit_repository.py:367). A Back-Office return is always against a Back-Office invoice (R3), so there are no points to take back and no sold card to void. Building it now would be code that never runs.
The rules stay decided for later (owner, 5 Oct 2026 — R14, R15), to apply when the invoice module starts giving points or selling gift cards:
- Points: take back the points for what came back, with the till's maths, never below zero (
customer_loyalty_card.active_pts,COALESCEas the till does); onePTS_DEDUCTEDrow. - Sold gift card: returnable only when never used (
total_amt_redeemed= 0); at Complete Refund the card is voided (GIFTCARD_STATUS_VOIDED,core/constants.py:45) with one Voidedft_giftcard_transrow.
A gift card used as payment on the invoice is a different thing and is covered: V3 refuses a refund of a tender that is not refundable.
4. API
backend/app/api/sales_returns.py, prefix /api/sales-returns (main.py:353).
| Method | Path | Permission | New / changed |
|---|---|---|---|
| GET | ` · /{sales_id} · /dropdowns` | sales.return.view | list: status filter already exists (sales_return_repository.py:96); detail: parent_invoice already exists (:466) and gains refund_check: { saved_total, current_total, reason_code, related_ref } on Approval pending / Approved (worked out again, no write — V13a) |
| GET | /{sales_id}/receipt | sales.return.view | changed: calls the new sales_return_service.assert_receipt_access() first (today no store check, api/sales_returns.py:94-112); optional query value print_options (JSON, the invoice's parse_print_options dependency, api/_deps/print_options.py) |
| GET | /{sales_id}/receipt-preview | sales.return.view | new — { html } envelope, one copy, no stamp; assert_receipt_access() first (invoice plan §3.11) |
| GET | /{sales_id}/print-options | sales.return.view | new — { options, required, can_save_company_default } for CREDIT_INVOICE |
| GET | /{sales_id}/status-history | sales.return.view | new — same shape as the invoice's (G24) |
| POST | `` (Idempotency-Key) | sales.return.manage | changed: writes a DRAFT (was COMPLETED); original_sales_id / original_doc_trx_date required; new fields lines[].serials, lines[].batches, lines[].return_action (RESTOCK / WRITE_OFF, V19), lines[].reason_code (V6; the header reason is derived), notes, refund_tender_type_id, refund_ref_no (V9); writes the refund payment row (decision A); verifies calculation_token (V23); refuses past the return limit (409 RETURN_WINDOW_PASSED), a non-refundable tender (409 TENDER_NOT_REFUNDABLE) or item (422 ITEM_NOT_REFUNDABLE); any unit price sent is refused (extra="forbid") |
| PUT | /{sales_id} | .manage | new — edit a Draft / Rejected return; carries version and calculation_token; replaces lines and the payment row |
| DELETE | /{sales_id} | sales.return.delete | new — Draft / Rejected only (V22); delete_document(doc_type='CREDIT_INV'); response DeleteDocumentResponse (sales_id, doc_ref_no); 409 STAGE_NOT_ALLOWED otherwise; side effects: header, lines, payment row, parent link, log rows removed |
| POST | /{sales_id}/submit · /approve · /reject · /void | .manage · .approve · .approve · .manage | new — same bodies and errors as the invoice routes |
| POST | /{sales_id}/complete (Idempotency-Key) | .manage | new — Complete Refund (V13) |
| POST | /calculate | .manage | new — refund, tax and rounding for the picked lines and refund method (§3.2); returns calculation_token (V23); no write |
| POST | /{sales_id}/send-email | .manage | changed: SendEmailRequest (models/sales_return.py:273; the class line — :274 is its docstring) gains optional print_options; the e-mail text of §3.4a |
| PUT | /{sales_id}/cashier | as today | changed — today update_sales_rep (sales_return_repository.py:567-592) writes the sales rep onto a return and rewrites cashier_user_id with no status or store check. For a return: Draft / Rejected only (409 STAGE_NOT_ALLOWED), require_record_store, and the sales rep columns are never written (§2.1). |
| GET | /returnable-invoices | sales.return.manage | new (Revision 4) — the invoice list for New Sales Return (§6.3). Query: search — required, at least 3 characters (Ref. No, customer name, customer code, phone); without it → 422 SEARCH_REQUIRED "Type at least 3 characters to search." (owner, 6 Oct 2026: search first, no browse list); page_size default 8. Returns per row: sales_id, doc_ref_no, doc_trx_date, status, store_name, customer_name, cust_code, total, units_left. Rules in SQL: company_id, the user's stores; doc_type = 'SALES_INV', source_type = 'BO' (R3); status IN ('COMPLETED','RETURNED'), not voided; doc_trx_date >= now − LIMIT_SALES_RETURN_IN_DAYS (store, else company; skipped for the owner — this bound also prunes partitions); EXISTS a line with item_entity_type = 'ITEM', not non-refundable, qty − qty_returned − qty on open returns > 0. Newest first. Built as a new spec in sales_invoice_repository.py next to get_sales_invoices_paginated (:177, base WHERE :94), reusing its search / paging; cust_contact_no is added to _SI_SEARCH_COLUMNS (:102-105), which has no phone today. No new index: the date bound keeps it to one month of one company (ft_sales_header_doc_trx_date_IDX, T:7758; no index on name or phone, T:7745-7760) — add one only if EXPLAIN asks for it. |
backend/app/api/sales_invoices.py
| Method | Path | Change |
|---|---|---|
| GET | /{sales_id} (invoice detail, returned items) | changed — get_voided_returned_items (sales_invoice_repository.py:714) reads child CREDIT_INV lines with no status filter, so Draft / Rejected / Voided returns show as returned. Count only status = 'COMPLETED' children as returned; show open ones apart ("On an open return: 2"); voided ones not at all. |
| GET | /{sales_id}/returnable (:574) | Can return also subtracts the qty on open returns (V4, through ft_sales_parent_child); adds the sold serials / batches per line and whether each is still returnable; adds default_return_action per line (item › store › company, §3.5; null when the setting is "Ask") and is_stock_item. |
| GET | /{sales_id} | Linked Documents includes its returns (ref, date, status, refund total). |
backend/app/api/company_settings.py — PATCH /print-options accepts doc_type = "CREDIT_INVOICE".
Item routes (backend/app/api/items.py) — create / update / detail / list carry default_return_action (optional, is_valid_option; same permissions as today).
v2-sellable-api report module — print_options.py gains the CREDIT_INVOICE defaults, required set and return_reason; receipt_renderer.py resolves options for CREDIT_INV; sales_document_receipt.html gains the credit-invoice title, the original-invoice line, the refund block and the return-reason line. POST /report/{report_type}/html already covers receipt types (invoice plan §3.11).
Every write body: ConfigDict(extra="forbid"). Every error uses the ApiResponse envelope with data.reason.
5. Permissions (RBAC v2)
| Layer | Code | Gates | New? |
|---|---|---|---|
| L1 page | sales.return.view | Sales Returns list, return view page, Print, Print with options page (/sales/return/:id/print), every GET route | exists (core/permissions.py:60) |
| L2 button | sales.return.manage | Return Items (on the invoice), Edit, Save draft, Submit, Void, Complete Refund, Change Sales Rep, Send Email; "Return Items" on the invoice also needs sales.invoice.view | exists (:61) |
| L2 button | sales.return.approve | Approve, Reject | new |
| L2 button | sales.return.delete | Delete (Draft / Rejected), DELETE /api/sales-returns/{sales_id} — its own canDelete block, never under .manage | new (the invoice has sales.invoice.delete, core/permissions.py:57) |
| L2 button | sales.return.email | Send Email row action on the list | exists (:62) |
| L2 button | ADMINISTRATION_COMPANY_MANAGE | "Save as the company default" on the Print with options page | exists — the invoice's rule |
| L3 field | sales.return.field.cost | Cost / margin on the returned lines | exists (:63) |
Note: sales.return.approve existed once and was dropped as an unused leaf by 20260505_0700_drop_return_credit_approve.py; the new seed re-adds it (ON CONFLICT DO NOTHING) and says so in its docstring.
Migration: one reversible seed db-postgresql/migrations/tenant/versions/2026MMDD_0100_seed_sales_return_approve_permission.py in the shape of the invoice's 20260925_0100_seed_sales_approve_permissions.py — .approve and .delete: Backoffice Admin and Store Manager true. down_revision = the current tenant head at build time (alembic -n tenant heads; the chain has forked before), not a revision copied from this plan. Frontend: the code in the permission constants; usePermissions().can(...) on each button; route guards unchanged.
Store scope: every read narrows to effective_store_ids; every write calls require_record_store on the return and on the original.
6. Screens — the mockups, page by page
Source: mockup/return-bo-step-form-footer.html, mockup/return-print-options.html, mockup/return-print-email-formats.html. They are copies of the invoice mockups, so everything in ../invoice-bo/plan.md §6.5 "Common to every page" holds: 36 px fields and buttons (40 px on a phone), the pinned footer with the steps on the left and the buttons on the right, status pills from status-badges.ts, lucide icons, Title Case labels, no dialogs, no side panels, plain words.
6.1 Sales Invoice — where "Return Items" appears
- Row menu (Sales Invoices list): "Return Items" (
Undo2) under a separator, only on Completed and on Returned with qty left. - View page footer: on Completed, Back · Return Items. On Returned with qty left, the same.
- Hidden on Draft, Approval pending, Approved, Rejected, Voided, on a Returned invoice with nothing left, and on an invoice older than the return limit (V3) — the owner still sees it. On an invoice paid with a non-refundable tender the form opens with the red line "This invoice was paid with Gift Card, which cannot be refunded." and nothing can be sent.
- Linked Documents on the invoice view page lists its returns (Ref, Date, Status, Refund total); row click opens the return.
- Opening it goes to the return step form with step 1 filled and locked to that invoice (
/sales/return/new?original={sales_id}).
6.2 Sales Returns list (DataTable)
- Breadcrumb Sales › Return — it ends at the section and does not repeat the title "Sales Returns" (owner, 6 Oct 2026), the same rule as the step form (§6.3).
- Title "Sales Returns", subtitle "Items customers brought back, and the money paid back to them.", Export, Settings, New Sales Return (
Plus, primary;sales.return.manage) →/sales/return/newwith no invoice: step 1 opens on the invoice list (§6.3). Owner, 5 Oct 2026, Revision 4 — brings back the button removed in Revision 3 (8). - Columns: Transaction Date · Ref. No · Status · Original Invoice ("—" for a till return without a receipt) · Store · Customer · Return Reason ("Several reasons" when the lines differ; the filter matches a return when any line has that reason) · Refund Method · Refund Total · Written-off Cost (only with the cost permission) · Total Lines · Source Type · Created By · Updated Date · actions (pinned).
- Quick filter: Status — the approver's way to find returns waiting (Approval pending); there are no notifications (§1.2). Every column sorts; the filter row as the invoice list.
- Row menu: View · Edit (Draft / Rejected) · Submit for Approval (Draft / Rejected) · Delete (Draft / Rejected,
sales.return.delete) · Approve / Reject (Approval pending) · Complete Refund (Approved) · Void Return (Approved) · Print · Print with options · Send Email (the last three on Completed / Voided only).
6.3 New / Edit — the step form
Page header (owner, 6 Oct 2026 — the standard's title + subtitle, docs/standards/step-form.md row 12; title kept; the breadcrumb ends at the section — Sales › Return — so it never repeats the title (owner, 6 Oct 2026: "it still redundant"). The invoice and order forms repeat it today; apply the same rule there for one look across the app (AppLayout.tsx, the form sub-routes)): New Sales Return with the subtitle "Pick the invoice, then choose what comes back and how the customer is refunded."; on Edit, Edit Sales Return CI-… with "Change what comes back or how the customer is refunded, then send it for approval."
Footer: steps 1 Invoice & Items › 2 Refund › 3 Review & send · Back · Save draft (once an invoice is picked) · Next / Submit for Approval · RM ….
Why one step (owner, 5 Oct 2026): the Invoice step alone held one search box and, once picked, a small read-only block — it looked empty, and the user could not see what was on the invoice until the next step.
- 1 Invoice & Items — no invoice yet (New Sales Return from the list). Search first, never a browse grid (owner, 6 Oct 2026, Revision 6). One panel Select the Invoice to Return holding only: the field Invoice Ref. No, customer name or phone (required mark, placeholder "e.g. SI-12/BO/2609-0006, Restoran Nasi or 012-345") with a Search button beside it, and the hint "Type at least 3 characters, then press Search or Enter. Only completed invoices with items left to return, inside the return limit, are found." Nothing is listed before a search — no grid, no recent invoices. Typing does not search by itself; Search or Enter does.
- Layout (owner, 6 Oct 2026: "the right side looks empty"): the search and its results take the left two-thirds; the right third holds a Before you search card — Use the receipt (where the Ref. No is printed), No receipt? (search by name or phone), Within N days (the return limit; the owner can return older), Already on the invoice? (open it and press Return Items). On a phone the card moves under the search. Under the field (owner, 6 Oct 2026: "below the search have empty space"): before any search a dashed placeholder fills the space down to the card's height — a search icon, "Search for the invoice to start", "The invoices found show here. Pick the one the items were sold on to see its items." The results, the too-short line and the no-match box take its place.
- Fewer than 3 characters → red line "Type at least 3 characters of the invoice Ref. No, customer name or phone." and no call to the server.
- No match → a dashed box "No invoice found for "…". Check the Ref. No, customer name or phone. Draft, voided, fully returned and too-old invoices are not found."
- Matches → "N invoices found. Pick the one the items were sold on." and a pick list (not a table): one card per invoice — Ref. No (bold, primary) with its status pill, the customer under it, the date · store under that; on the right the total and "n units left to return"; a chevron. The whole card is the button; the first card takes the focus after a search, so Enter on it picks. At most 8 cards; more matches → "N invoices found — showing the first 8. Type more to narrow it down." The list holds only invoices the user may return: Completed, or Returned with qty left; inside
LIMIT_SALES_RETURN_IN_DAYS(the owner sees older ones too, V3); in the user's stores (require_record_store); Back-Office invoices only (R3); at least one refundable line (V21). - Next with no invoice → the field turns red with "Search for the invoice the items were sold on, then pick it.", error toast, focus (step-form §8). Save Draft stays greyed until an invoice is picked. Footer Back reads Back to List. The data comes from
GET /api/sales-returns/returnable-invoices?search=…(§4), which requires a search of 3+ characters. - 1 Invoice & Items — invoice chosen. Top: the invoice summary card: ref (link), date, store, customer, sales rep, paid with, total. Change on the card (new return from the list only) clears the pick and goes back to the search (empty results) — lines, reasons and choices are cleared. Opened from the invoice's Return Items, or editing a saved return: no Change — the invoice is locked; footer Back reads Back to Invoice on a new return from the invoice. Under it, on the same page, the items grid: No · Item Code · Item Name · UOM · Sold · Already Returned · Can Return · Return Qty · Unit Refund (read-only) · Refund · Reason (select of the five Sales Return reasons, empty until chosen) · What happens to it (Back to stock / Write off, §3.5); above the grid Return all, Reason for all lines (one select that fills every returned line — a line can still be changed on its own) and Set all to: Back to stock · Write off; serial line → the Serial pick page (tick boxes of the serials sold); batch line → pick from the sold batches. Under the grid, the invoice's summary layout (invoice plan §6.5 Items): a short note on the left (Unit Refund is what the customer paid per unit; cash refunds round to 5 sen), and on the right the summary box — Total Net · a Tax group with one row per rate ("SST 10.00% · 2 lines", "No tax 0.00% · 1 line") and Total Tax · Rounding (only when not zero) · a rule · Refund Total in bold. Next and Submit are never greyed (step-form standard,
docs/standards/step-form.md§8–9; owner, 5 Oct 2026). Pressing Next checks the step: what is missing gets a red border (aria-invalid) and a short line under it ("Choose a reason", "Choose one", "Choose how the refund is paid back."), an error toast "Some details are missing" names the first problem, and the first missing field gets the focus. A mark goes away as soon as the field is filled. Submit for Approval checks every step and opens the first one with a problem, marked the same way. - Rejected return opened for Edit: the red Rejected banner above step 1 with the manager's reason (the last
workflow_status_log.remarks), who and when — as the invoice form shows it. - Lines that cannot be returned: a non-refundable line shows "Cannot be returned", greyed, no Return Qty box (V21).
- 2 Refund. The reasons in one line ("Damaged goods × 2 lines · Wrong item × 1 line", read-only — changed on step 1), the line "n items go back into stock at <store> · m are written off", Refund method (only the store's refund methods, V9; empty until the user chooses — no pre-selection; hint under it "Only payment methods set up for refunds at this store are listed. The invoice was paid with Cash RM 120.00."; Next with none chosen marks it red), Reference no. (optional; required for a card method, with the line "Refund the card on the terminal first, then enter its reference."), Note (printed on the credit invoice when "Document note" is on). Totals card on the right.
- 3 Review & send. Every step read-only with Edit links (the items with their What happens to it), the totals card, primary Submit for Approval.
- After Submit for Approval (owner, 5 Oct 2026 — the invoice's rule of 1 Oct 2026): no success page and no dialog. The return is saved, the app goes straight to the return's view page (status Approval pending) and shows the standard success toast —
toast.success, the green filled toast ofcomponents/ui/sonner.tsx— "Sent for approval — Sales Return CI-…." with "A manager approves or rejects it." If the save works but the send fails, the view page opens on the Draft with an error toast saying it was saved but not sent (asSalesDocumentPage.tsxdoes for the invoice). Save draft keeps its own rule: back to the list with a success toast.
6.4 Sales Return view page
- Breadcrumb names the page, not the document (owner, 5 Oct 2026, Revision 4): Sales › Return › View Sales Return (Edit: Edit Sales Return). The ref is shown once, in the page title — as the app already does (
AppLayout.tsxsub-route fallback: "View <page>"). The same rule on the invoice and sales order view pages. - Header: ref · status pill; date ("Created" until Completed — decision D) · store. Top-right icon-only buttons: Print (
Printer) · Print with options (SlidersHorizontal) · Send Email (Mail) · Audit (History). Print, Print with options and Send Email show on Completed and Voided only. - Footer section steps: Details · Refund · Customer.
- Details: Return Info grid (original invoice link, return reason — the one reason, or "Several reasons", sales rep, created by) · Returned Lines (with Reason and What happens to it — Back to stock / Write off) · Summary · Linked Documents.
- Refund: method, amount, reference; "Paid out when the return is completed." until then; after, "Refund paid on <date>".
- Footer actions — Draft / Rejected: Edit · Submit for Approval; Approval pending: Reject · Approve; Approved: Void Return · Complete Refund; Completed / Voided: Back only. Delete (Draft / Rejected,
sales.return.delete) on the left of the footer. Reject and Void open the reason card first. On a return whose card was already refunded on the terminal, the Reject / Void / Delete card shows "The card was already refunded on the terminal (ref …). Sort the money out with the customer — this does not undo the card refund." (C1). An Approval pending / Approved return whose refund changed shows the amber note of V13a above the details. Empty reason on the card: pressing Reject / Void Return marks the box red with "Write the reason", shows the error toast "Some details are missing" and puts the focus in the box — the same rule as Next (§6.3); the button is never greyed. - Audit: the invoice's day-grouped timeline — Created · Submitted for approval · Approved · Rejected · Refund completed · Voided.
6.5 Print with options page (/sales/return/:id/print)
> Mockup note: the two print mockups always show one sample return (CI-12/BO/2609-0002), whichever row was clicked; ‹ Back opens that return's view page. The real page shows the return it was opened from.
The invoice's SalesPrintOptionsPage with kind = "return": breadcrumb Sales › Returns › CI-… › Print with options; title "Print credit invoice"; the checklist of §3.4b on the left with the counter "7 of 12 included"; the live A4 credit invoice on the right, sticky; Copies, Stamp each copy, Remark for this print, Save as the company default; footer ‹ Back on the left, Download PDF · Send e-mail · Print credit invoice on the right.
6.6 Mobile (≤ 767 px)
As the invoice: everything stacks, grids scroll sideways with the actions column in view, the step strip collapses to "Step 1 of 3 — Invoice & Items", the view page's section circles stay tappable; on the print page the preview moves under the checklist.
7. Files
Backend — changed: api/sales_returns.py (new routes, §4); api/sales_invoices.py (/returnable, linked returns); api/company_settings.py (CREDIT_INVOICE doc type); services/sales_commit_service.py (create_return → draft, original required, D1–D5); services/sales_lifecycle_service.py (VALID_TRANSITIONS per doc type, _NOUN gains CREDIT_INV, paid-in-full gate skipped for a return, Complete Refund, original → RETURNED); services/sales_return_service.py (receipt / preview / print-options / e-mail with print_options); services/sales_document_email.py (credit-invoice wording); repositories/sales_commit_repository.py (get_original_sale_for_return status filter, open-return qty, sold serials); repositories/sales_return_repository.py (list / detail fields); models/sales_creation.py (CreateReturnRequest), models/sales_return.py (SendEmailRequest.print_options); core/sales_doc_semantics.py (predicate §2.3); core/permissions.py (SALES_RETURN_APPROVE, SALES_RETURN_DELETE); core/print_options.py (PRINT_TYPES gains CREDIT_INVOICE, :45; print_type_for_doc_type, :87) and core/data/company_settings_default.json (CREDIT_INVOICE, return_reason).
Backend — review pass additions: models/print_options.py (PrintType Literal, :17, and its assert against PRINT_TYPES, :20; DocPrintOptions.CREDIT_INVOICE, beside SALES_INVOICE at :89; PrintOptionsStored.return_reason); services/print_options_service.py (get_document_print_options handles SALES_ORDER / SALES_INV only and raises "Unsupported document type" for CREDIT_INV today, :40-48 — add the CREDIT_INV branch); repositories/company_settings_repository.py (update_print_options, :160, accepts CREDIT_INVOICE); services/sales_return_service.py (new assert_receipt_access(), modelled on sales_invoice_service.assert_receipt_access, :492); services/sales_lifecycle_service.py (delete_document for CREDIT_INV); repositories/sales_document_repository.py (delete_document_rows also deletes the ft_sales_parent_child row); repositories/sales_commit_repository.py (get_returnable_lines / bump_qty_returned join the header on company_id; more columns and insert_sales_detail params, D6; is_returned on the original line); services/sales_commit_service.py (D7 maths, rounding, calculation token, time limit, tender rules, decision A payment row, decision D re-date); the BO payment readers of §2.3 (sales_collection_repository.py, business_day_repository.py, exchange_item_report_repository.py).
Backend — the line action: core/field_options.py (relabel ft_sales_detail.action_for_returned_item: Back to stock · Write off); repositories/sales_commit_repository.py (/returnable reads the store / company RETURNED_ACTION_CONTROL, and the item value once it exists). No item files change in this plan.
Backend — new: services/sales_return_write_service.py (edit, calculate) if sales_commit_service.py grows past its size.
Frontend — new: pages/sales/SalesReturnFormPage.tsx (the step form; reuses SalesFooterBar, TrackingEntryPage, ReasonCard, SalesAuditTimeline); pages/sales/components/ReturnInvoiceStep.tsx, ReturnItemsStep.tsx, SerialPickPage.tsx.
Frontend — changed: App.tsx (routes /sales/return/new, /sales/return/:id/edit, /sales/return/:id/print; /sales/new-return redirects), SalesReturnPage.tsx (list columns, row menu with Delete), SalesReturnDetailPage.tsx → the view page (or SalesDocumentViewPage with a return mode), SalesDocumentListPage.tsx / SalesDocumentViewPage.tsx (Return Items on Completed / Returned-with-qty-left), SalesPrintOptionsPage.tsx (kind = "return"), lib/constants/print-options.ts (CREDIT_INVOICE, return_reason), lib/api.ts (return receipt / preview / print-options / send-email with print_options), pages/company/tabs/AdvancedSettingsTab.tsx (Printing › Credit Invoice tab), hooks/use-sales-creation.ts, lib/constants/sales-lifecycle.ts (error words), lib/constants/field-options.ts (the relabel). SalesReturnCreatePage.tsx is removed.
Other apps: v2-sellable-api/src/modules/sales/sales_query.py (bo_not_a_sale_sql, §2.3, and the three payment readers listed there); v2-sellable-api/src/modules/report/report_query.py (query_payment_summary); v2-sellable-api/src/modules/report/print_options.py, renderers/receipt_renderer.py, templates/sales_document_receipt.html (§3.4b). v2-pos-client: none — a Completed Back-Office return reaches the till like any Completed Back-Office document.
Database: the seed migration (§5); the company_settings_json column comment names CREDIT_INVOICE. No new table, no new column; ft_sales_detail and the stock function unchanged.
8. Tests
Backend (backend/tests/unit/):
api/test_sales_return_lifecycle_api.py— V2, V5, V6, V10, V11, V12, V14 on every route.services/test_sales_return_from_invoice.py— V3 (Draft / Approved / Voided original refused), V4 (two open drafts over the sold qty), V8 (tampered price ignored), V10 (no original refused), D3 (notes carry no code).services/test_sales_return_complete.py— V13 all-or-nothing; original →RETURNEDwith one log row;qty_returnedbumped; one negative payment row; stock movement called once.services/test_sales_return_tracking.py— V7 serial not on the invoice, serial returned twice, batch split.services/test_sales_return_reason.py— V6: every returned line needs a valid reason; the line'svoided_or_returned_reason_code/_notes/_by/_datetimeare written; header = the shared reason,NULLwhen mixed; the list filter finds a return by any line's reason.services/test_sales_return_action.py— V19: the pre-fill order item (when set) › store › company ›RESTOCK; an empty item value falls to the store; a store value ofnull,""or an unknown word falls to the company;PROMPTgives no pre-fill and Submit is refused until each line has one; a non-stock line is saved asWRITE_OFF; every saved line has a non-NULL action.services/test_sales_readers.py— aCREDIT_INVat Draft / Approval pending / Approved / Rejected / Voided is not a sale.services/test_sales_return_print.py— V18:print-optionsreturns theCREDIT_INVOICEentry over the defaults and the three required keys;send-emailforwardsprint_options; badprint_options→ 400; the e-mail says "Refunded", never "Amount due".models/test_company_settings_seed.py— the seed, model and frontend defaults agree withCREDIT_INVOICEadded.services/test_sales_return_money.py— D7: a tax-inclusive invoice (3 units, 6% inside the price, a cart discount) returned 1 then 2 units: the refund total equals what the customer paid for those units (the invoice header grand total share, nottotal_amt_after_tax), the two returns add up exactly, no cent drift; the same tax-exclusive; the last return of the invoice refunds exactly what was paid less earlier refunds; a Cash refund rounds with_rounding_of(company), a card refund does not.services/test_sales_return_rules.py— V3 return limit (31 days refused, owner passes, store value overrides company,nullstore falls to company); V3 Gift Card tender refused; V9 tender not enabled for refund at the store, stored-value tender, a split-paid invoice refunded in full by one flagged method, card without reference; V21 non-refundable line; V23 stale / foreign calculation token.services/test_sales_return_draft_payment.py— decision A: the Draft writes one negative payment row with method and reference; edit replaces it; Complete Refund re-checks it (amount changed → 409REFUND_CHANGED) and writes no second row; decision D: Complete Refund movesdoc_trx_dateand the paymenttrx_dateto today, keepsdoc_ref_no, the lines are found through the new date window.services/test_sales_return_delete.py— V22: Draft deleted with its lines, payment row, parent link and log; Approved refused; the deleted Draft's qty is returnable again.services/test_sales_return_lines.py— D6: the return line carries the invoice line's barcode, category, UOM, cost, service flag;total_cost/total_profitsigns as the till; sales rep shown, headersales_rep_user_idNULL.services/test_sales_return_tenancy.py— V1: another company'sref_sales_detail_id/ original refused onget_returnable_lines,bump_qty_returned, create; original in another store → 403 on the non-replay path; return/receiptof another store → 403.services/test_sales_return_complete.pyalso: original locked before the open-return re-sum; store with no default stock location → 409 before any write; original lineis_returnedset when fully returned.services/test_payment_readers.py— a return Draft's payment row is absent from sales collection, business-day collection, the exchange report and thev2-sellable-apipayment summary.python ops/ci/scripts/check_workflow_registry.pyexits 0.
Report service (apps/v2-sellable-api/tests/report/test_receipt_renderer.py): each CREDIT_INVOICE key hides or shows its element; the three required keys cannot be turned off; the return reason prints as words; the original invoice number always prints; a till CREDIT_INV with no print_options uses the company setting.
Frontend (frontend/src/pages/sales/__tests__/): Return Items shows only on Completed / Returned-with-qty-left; Return Qty over Can return stops Next and marks the box; the returns list shows New Sales Return; /sales/return/new without an invoice opens the invoice list; nothing is listed before a search; fewer than 3 characters → red line and no request; Search / Enter runs it, typing alone does not; results are a pick list of at most 8 cards; the search matches Ref. No, customer name / code and phone, and a Voided / Draft / over-limit invoice never shows; Pick fills the card and the items, Change clears them back to the search; Next with no invoice marks the search box; step 1 shows the items under the invoice card; Set all to changes every line; Next stays enabled and marks every returned line with no reason or choice, toast + focus on the first; Next and Submit are never disabled; footer buttons per status; Print / Print with options / Send Email only on Completed / Voided; the print page counter reads "7 of 12"; npm run typecheck, the status-badges drift test.
services/test_sales_return_revision5.py— V13a:refund_checkon the detail of an Approved return; approve, then complete another return of the same invoice → Complete Refund 409REFUND_CHANGEDwith both totals; Submit of a changed Draft → 409 and the new amount saved on edit. R7: void an Approved return → its lines'voided_or_returned_reason_codeunchanged, the held qty released (Can return back to the old figure), the invoice status unchanged. H3:PUT /cashieron an Approved return → 409; on a Draft it never writessales_rep_*. H4: invoice detail counts only Completed returns as returned; Draft / Rejected shown as "On an open return"; Voided not shown. H2: Submit of a Draft saved the day before does not fail on an expired token.- Frontend: the amber note and Void and Start Again (new return filled with the same lines); Complete Refund on a changed return shows the error toast and scrolls to the note; the card warning on Reject / Void / Delete of a return with a card reference; the refund method hint.
8.1 Acceptance
- A Completed invoice shows Return Items in the row menu and on the view page; a Draft / Approved / Voided invoice does not.
- A return cannot take more than sold − returned − already on other open returns — on the screen and on the API.
- The refund price equals what the customer paid per unit, discounts and tax included, whatever the request sends.
- A return with no original invoice is refused by the API; the screen has no way to start one.
- Nothing moves before Complete Refund: no stock, no money, not in reports, not on the till.
- Complete Refund in one transaction: refund paid, Back to stock lines back in stock (Write off lines not), return Completed, invoice Returned, log rows written.
- Every returned line carries Back to stock or Write off, pre-filled from the item, then the store, then the company setting; the user can change it until submit.
- The author cannot approve (the owner can); void only at Approved.
- A Completed return prints, prints with options and e-mails exactly like a Completed invoice: same page, same preview, same copies / remark / company default; the attached PDF uses the same choices.
- The refund of a line equals its stored invoice amounts pro-rata, on tax-inclusive invoices too; the last return leaves no cent behind.
- An invoice past the return limit (owner exempt), paid with a non-refundable tender, or a non-refundable line cannot be returned.
- The refund method and reference are kept on the Draft and count in no report until Completed; the credit invoice is dated the Complete Refund day.
- A Draft or Rejected return can be deleted and frees its qty.
pytest,npm test,npm run typecheck,check_workflow_registry.pypass.- A refund that changed after approval cannot be paid at the old amount; the view page says what changed, why, and offers Void and Start Again, which opens a filled new return at the new amount.
- Voiding or deleting a return leaves the original invoice as it was: same status, its items returnable again; the return keeps its line reasons and the void reason.
- The refund method list shows only the store's methods flagged for refund; one method pays the whole refund, whatever the invoice was paid with.
- Rejecting, voiding or deleting a return whose card was already refunded shows the card warning with the terminal reference.
- New Sales Return opens on a search field only; no invoice is listed until the user searches with at least 3 characters, and the matches are a short pick list.
9. Open points — for the owner
9A. Decisions needed
| # | Question | Option A | Option B | Recommended |
|---|---|---|---|---|
| R3 | Return a till invoice from the Back-Office? | No — Back-Office invoices only; till sales are returned at the till | Yes — any completed invoice | A — a till offline at that moment can return the same unit again |
| R6 | E-invoice for returns | Out of this plan; a separate e-invoice plan (needs the original UUID) | In this plan | A — nothing sends credit invoices today |
| R8 | Till returns and the Credit Invoice print setting | Till credit invoices printed from the Back-Office follow the company's Credit Invoice setting too | Only Back-Office returns follow it; till ones print as today | A — one credit invoice look; the invoice side already works this way for the e-invoice mail |
| R10 | Should the till also use the item value (once the Item module adds it)? | Not this round — the till keeps its store / company setting | Yes — add the column to the item sync and the till's item table, and use it before the store setting | A now, B as a follow-up — the till change needs a POS release |
| R11 | Print Back to stock / Write off on the credit invoice? | No — it is an internal stock decision | Yes, as a small "Written off" note under the item | A — the customer does not need it |
| R9 | Required rows on a credit invoice | Company address, unit refund and tax lines always print (like a tax invoice) | All optional | A — a credit invoice corrects a tax invoice |
Closed during the build (owner, 6 Oct 2026): a refund is never more than the items picked, and never more than is left of what was paid on the invoice (§3.2).
Closed in Revision 5 (owner, 6 Oct 2026, after the grill review — grill-review.md):
- C1 — card refund timing: keep the current flow. The cashier refunds the card on the terminal and keys in everything (method, reference) on the Draft; approval comes after. Accepted risk: a Rejected, Voided or Deleted return may already have its card refund paid. So the Reject / Void / Delete card on a return with a card reference shows "The card was already refunded on the terminal (ref …). Sort the money out with the customer — this does not undo the card refund." The reference stays on the return and in its audit.
- R7 — keep everything. A void keeps what
void_documentwrites today on the header (voided_or_returned_*= the void,sales_lifecycle_service.py:313-319); the return reason stays on each line (ft_sales_detail.voided_or_returned_reason_code, untouched by the void), so both are kept with no new column. The original invoice is put back as it was: a void (Approved only) or a delete (Draft / Rejected) happens before Complete Refund, soqty_returned,is_returned, stock and the original's status were never changed; the qty the return was holding is released at once (V4 counts only open returns), "Already returned" on the invoice counts Completed returns only, and Linked Documents shows the return as Voided. Test: void an Approved return → the invoice's Can return goes back to its old figure and its status is unchanged. - R13 — no cap per method. The refund method list shows only the store's payment methods flagged for refund; one method pays the whole refund (V9).
- Refund changed after approval — say it on screen (V13a).
Closed in Revision 4 (owner, 5 Oct 2026, after an outside check of Square, Lightspeed, Odoo, Shopify) — moved out of this round by Revision 5 (§3.6); kept as the rule for later: R14 — loyalty points given on the invoice are taken back at Complete Refund for what came back, never below zero (§3.6). R15 — a gift card sold on the invoice can be returned only when it was never used; the card is then voided (§3.6).
Closed in Revision 3 (review pass, owner, 5 Oct 2026): A — the refund method and reference are kept on the Draft as its ft_sales_payment row, replaced on edit, re-checked (not created) at Complete Refund, hidden everywhere by the §2.3 rule until Completed. B — return time limit = the existing setting LIMIT_SALES_RETURN_IN_DAYS (store, else company, shipped 30); past it Return Items is hidden and the API answers 409 RETURN_WINDOW_PASSED; the owner is exempt (V3). C — a coupon used on the invoice stays used after a return (§1.2). D — the credit invoice is dated the day Complete Refund is pressed; doc_ref_no keeps the Draft's number (§2.1).
Closed in Revision 3: R12 (owner, 5 Oct 2026) — one reason per return line; units of one invoice line that came back for two reasons go on two returns, or the user picks the main reason; no line split. R4 (damaged goods) — replaced by the per-line Back to stock / Write off choice with an item default (§3.5).
Closed in Revision 2: R1 and R2 (who may raise a return without an invoice, and its refund price) — that path is out (§1.2). R5 (refund methods) — any store payment method except gift card / store credit, no owner question left.
Note for the invoice plan (not this plan): on a tax-inclusive Back-Office invoice ft_sales_detail.total_amt_after_tax is stored as net + tax although the tax is already inside the price (sales_batch_split_service.py:108-117), so line totals do not add up to the header. Fix it in the invoice module; this plan does not read that column for an inclusive invoice.
Also confirm: the Sales Returns list still shows till returns taken without a receipt (Original Invoice "—", Source Type "Till") — they are real returns in the books; the Back-Office only cannot create one.
Also confirm: "Return Items" on a Returned invoice that still has qty left (this plan says yes — otherwise a second part return is impossible).
9B. Build order
Progress (6 Oct 2026): B0 done — Redy-20260910-BO-Invoice merged into Redy-20261005-Sales-Return (merge commit 11de7bae1; the return PR can merge only after the invoice PR). B1 built, not committed: D1–D7, V3 (limit, tender), V9, V21, V23 (POST /api/sales-returns/calculate; the token carries the priced refund) — services/sales_return_rules.py (new), create_return / _price_return / calculate_return, repository reads scoped through the header; tests tests/unit/services/test_sales_return_b1.py. Still Completed in one step until B2. Added in build (owner to confirm): a return that would pay back more than is left of what was paid on the invoice is refused, 409 REFUND_OVER_PAID — an invoice not paid in full cannot be refunded in full. Not in B1: per-line reason and Back to stock / Write off (B2a), batch split across two batches (B3), V4 open-return hold (B2).
B2 built, not committed (6 Oct 2026): a return is saved as a Draft with its refund row (decision A); PUT /{id} edit, DELETE /{id}, submit / approve / reject / void / complete, status-history; _TRANSITIONS_BY_DOC adds Approved → Completed for returns only; Complete Refund locks invoice then return, re-prices (V13a), re-dates (decision D), bumps qty, Completed before stock, invoice → Returned; V4 open-return hold; refund_check on the view data (never raises); predicate hides a CREDIT_INV at Approved in the BO and v2-sellable-api payment readers (readers with an "include voided" toggle use BO_NOT_A_SALE_PREDICATE_H_KEEP_VOIDED); sales.return.approve / .delete + seed 20261006_0100_seed_sales_return_permissions.py; PUT /cashier limited (H3); invoice "returned items" count Completed returns, open ones carry return_status (H4). Tests test_sales_return_b2.py, test_return_phase_b2_readers.py. Ship B2 together with B3 — until the B3 screens exist, a Back-Office return can be saved but not sent, approved or completed from the UI. Known, not changed: returns write no ft_sales_item_tax rows (as before).
B2a + B3 built, not committed (6 Oct 2026): a reason and Back to stock / Write off per line (_line_choices; header reason = the shared one, else none; non-stock always Write off; pre-fill default_return_action: item › store › company RETURNED_ACTION_CONTROL › Back to stock, "ask" = none); field-option labels Back to stock · Write off; GET /api/sales-invoices/{id}/returnable now gated by sales.return.manage and carries holds, free serials, pre-fill, paid with, sales rep; new GET /api/sales-returns/returnable-invoices. Screens: SalesReturnFormPage.tsx (step form, invoice list + search, items, serial pick page, refund, review; edit and "start again" pre-fill), the view page (SalesReturnDetailPage.tsx, footer actions per status, Reject / Void / Delete cards, card warning C1, refund-changed note V13a), the list (New Sales Return, row menu per status), Return Items on Back-Office invoices. SalesReturnCreatePage.tsx removed; /sales/new-return redirects. Not yet: Print with options for returns (B4), list columns Return Reason / Refund Method / Refund Total / Written-off Cost, per-rate tax rows in the form summary. The invoice list is bounded by the company's return limit (a store with a longer own limit is refused on nothing but may miss rows in the picker).
B4 built, not committed (6 Oct 2026): CREDIT_INVOICE print options in all four contract copies (BO core + models + seed JSON, frontend constants, report service), return_reason a key on every type (false on order / invoice); credit invoice forces company_address, unit_price, totals_breakdown on and deliver_to, discount, einvoice_qr, bank_details off. Routes /receipt (now store- and status-checked), /receipt-preview, /print-options, /send-email with print_options, all through assert_receipt_access (Completed / Voided only). Report service: CREDIT INVOICE / VOIDED CREDIT INVOICE sheet, original invoice number and date, Unit Refund, "Refunded by …", reasons as words, serials in the item note, positive amounts; till credit invoices (non-BIR) now print on the same A4 sheet (§9A R8). E-mail: "Credit Invoice {ref} — {company} — RM… refunded", "Refunded" card, credit_invoice_{ref}.pdf, voided wording. Screens: Print with options page for returns (/sales/return/:id/print, "7 of 12 included"), Print with options on the view page and list, Company Settings › Printing › Credit Invoice tab. B5 (predicate in both apps) was done inside B2. All phases B0–B5 built. Note for the owner: till e-receipts now also show serial numbers (the shared receipt query returns them).
| # | What | Size |
|---|---|---|
| B0 | The invoice branch merged to main (print options, sales_doc_semantics.py, the return route) | — |
| B1 | Fix D1–D7 on today's route (status check and tenancy, serials, reason, audit + RETURNED, line cost, line data, refund maths); time limit, tender and item refund rules; calculation token | 2 days |
| B2 | Draft lifecycle: create as Draft with its payment row, edit, delete, submit / approve / reject / void / complete (lock order, re-date), status history; permission seed (.approve, .delete); payment readers (§2.3) | 3–4 days |
| B2a | The line action and reason on create / edit / /returnable (V6, V19) with the empty-item fallback; field-option relabel; the line cost (D5) | ½–1 day |
| B0b | (Item module, separate scope) the item field "When a customer returns this item". When it ships, /returnable adds the item as the first step of the fallback — one query change | — |
| B3 | Step form (Invoice & Items on one step), list, view page, Return Items on the invoice | 3–4 days |
| B4 | Print and e-mail: CREDIT_INVOICE options in the four contract copies, return receipt / preview / print-options routes, the renderer and template, the print page kind = "return", Company Settings tab, e-mail wording | 2 days |
| B5 | Predicate change in both apps, reader tests | ½ day |
10. Decisions that shaped this plan
- Same document type as the till (
CREDIT_INV/REFUND), no new table, no new column — a return reads the same in every report whichever app wrote it. - From an invoice only (owner, 5 Oct 2026) — a return without a receipt stays at the till, where role access
POPC_NEW_SALES_RETURNalready controls it. Two ways in, both to one invoice: New Sales Return on the list (pick from the invoice list) and Return Items on the invoice (Revision 4). - The Back-Office moves money only at Complete Refund. A card refund is the exception: it is done on the terminal first and keyed in on the Draft (owner, 6 Oct 2026, C1). The Draft records the method as its payment row, hidden until Completed (owner, 5 Oct 2026, A).
- Return limit = the till's own setting
LIMIT_SALES_RETURN_IN_DAYS, owner exempt (owner, 5 Oct 2026, B); coupons stay used (C); the credit invoice is dated the Complete Refund day (D). - Refund = what the customer paid for the line, from the stored amounts pro-rata (inclusive invoice:
total_amt_b4tax; exclusive:total_amt_after_tax) — never recomputed from unit prices (D7). - Back to stock / Write off per line (owner, 5 Oct 2026) — reuses the column the till already writes and the stock function already reads. The item's own default is the Item module's; this plan only falls back when it is empty.
- Invoice and Items on one step (owner, 5 Oct 2026) — the Invoice step was too empty.
- Print and e-mail are the invoice's (owner, 5 Oct 2026) — one page, one contract (
DOC_PRINT_OPTIONS), one more document typeCREDIT_INVOICE. - Mockups copied from the invoice mockups — the owner asked for the same layout, not for options.
11. History
- Revision 6 — 6 Oct 2026. Owner: (1) review the New Sales Return page — mockup checked page by page: the leftover per-item "Write off" pre-fill (Revision 3, item field out of scope) removed so lines follow the store setting; "Total Net" → "Refund Before Tax" as in the built app; the duplicate search icon removed. (2) Search first, no browse grid on step 1 — §1.1, §6.3, §4 (
searchrequired, 3+ characters, 8 per page), §8, §8.1. Plan and mockup only; the builtSalesReturnFormPage.tsxandGET /returnable-invoicesfollow in the next build step. - Revision 5 — 6 Oct 2026. Grill review (
grill-review.md: 1 critical, 8 high) answered by the owner: (1) C1 keep the current flow — card refunded and keyed in on the Draft, approval after; warning line on Reject / Void / Delete; (2) R7 keep everything — return reason on the lines, void on the header; the original invoice stays as it was and the held qty is released; (3) R13 no cap per method — only the store's refund-flagged methods; (4) H6 the changed refund is explained on screen (V13a) with Void and Start Again. Plan fixes from the review: V10 rewritten for the invoice list; V23 token on create / edit only;PUT /cashierlimited; invoice "Already returned" counts Completed returns only; §3.6 loyalty / gift cards moved out (code that would never run);/returnable-invoicesrow joined to its table. Mockup: refund-changed note on CI-12/BO/2609-0004 with Void and Start Again; card warning on Reject / Void / Delete; refund method hint; Delete label; "BO" → "Back-Office". - Revision 4 — 5 Oct 2026. Owner: (4) the breadcrumb on a view / edit page no longer repeats the ref shown in the title — it reads View Sales Return / View Sales Invoice / View Sales Order / Edit …, in the return mockup and the invoice + order mockup (§6.4). (3) after an outside check, R14 and R15 closed — loyalty points are taken back at Complete Refund, the till's maths, never below zero; a sold gift card returns only when never used and is then voided — new §3.6, §2.0 rows updated. Correction: Revision 4's first draft said "the till does not take points back" — it does (
sales_query.py:4176). (1) review again every table and column a return touches — §2.0 checked against the DDL:ref_sales_detail_idis indexed (T:7740), FK list extended (T:8436, e-invoice tables), new "Also touched" table (serial / batch stock paths, bundles, shift id left NULL, e-invoice, loyalty, sold gift cards, PSP, shipments), promo rows contradiction removed, backend cites marked as on the unmerged sales branch; R14, R15 added; (2) a New Sales Return button on the Sales Returns list again, and step 1 shows the list of sales invoices with a search as before — §0, §1.1, §4 (GET /api/sales-returns/returnable-invoices), §6.2, §6.3, §8; Change on the invoice card for a return started from the list. Undoes Revision 3 (8). Plan and mockup only. - Revision 3 — 5 Oct 2026. Owner: (14) Refund method is never pre-selected; Next with none chosen marks it red, error toast, focus; (13) Wording: Credit Note → Credit Invoice everywhere (owner, 5 Oct 2026) — screen text, sheet title CREDIT INVOICE, e-mail subject, file name
credit_invoice_….pdf, print-options keyCREDIT_INVOICE; kept: LHDN's own term "credit or refund note" and the existing filecredit_note_report_repository.py; (12) Second check, the same day: the refund for a tax-inclusive invoice is taken fromtotal_amt_b4tax(the storedtotal_amt_after_taxadds the tax twice on an inclusive invoice — invoice-side fault reported); tax and promo rows move date with the lines on Complete Refund; reporting date stated; "same shape as the till"; (10) The summary under the items table uses the invoice's layout and rows (Total Net, Tax by rate, Total Tax, Refund Total); (9) Next / Submit are never greyed — pressing them checks the step, marks the missing fields in red, shows an error toast and focuses the first (step-form standard §8); (8) No "New Sales Return" button on the returns list — a return starts from the invoice's Return Items only; the invoice search and Change link on step 1 removed; (7) The item setting is out of scope — removed the item column, Item › Settings row, item import and §6.7; the plan only handles an empty item value (store › company › Back to stock). The step 1 items table was tidied (one toolbar row, shorter headers, everything in view); (6) A reason per return line (the till already stores it per line) — §2.1, V6, §3.4b, §6.2–6.4, R12; the header reason is derived. (5) After Submit for Approval, go straight to the return's view page with the standard success toast — the "Submitted for approval" page is removed; every success toast in the mockup now uses the green success style; (1) review every table and column a return touches — new §2.0 (found:ft_sales_detail.action_for_returned_itemalready exists and the stock function already skips aWRITE_OFFline;ref_sales_detail_idhas no index; two existing gaps noted). (2) A per-line choice of what happens to the returned item — Back to stock / Write off, §3.5, V19. (3) A default per item — new columnitem.default_return_action, Item › Settings row (§6.7), item import column. (4) The Invoice step looked empty — joined with Items into 1 Invoice & Items (§6.3); three steps now. R4 closed; R10, R11 added; B2a added. Plan and mockup only. (11) Review pass (5 Oct 2026): owner decisions A–D written in and closed in §9A (refund method kept on the Draft as its payment row, return limitLIMIT_SALES_RETURN_IN_DAYSwith owner exemption, coupons stay used, credit invoice dated the Complete Refund day with its lines re-inserted); refund maths replaced (D7: tax-inclusive bug, per-unit cart share) with the last-return rule and Cash-only rounding; D6 line data copied from the invoice; Delete for Draft / Rejected (sales.return.delete, V22); till refund controls (non-refundable tender, store refund methods, per-method cap, card reference; R13 added); non-refundable items (V21); tenancy joins andrequire_record_storeon the original (D1, V1);is_returnedon the original line; header money, cost and profit at Draft vs Complete; sales rep shown but not written; V13 lock order and stock-location check; calculation token (V23); print plumbing and a newassert_receipt_accessfor returns (§7, V18); seeddown_revisionat build time; no notifications; payment readers listed (§2.3); Rejected banner on Edit; reason-card validation; stale cites fixed (create_return :1603,VALID_RETURN_REASON_CODES :92, S9:1662,DOC_SUB_TYPE_REFUND :45, F:3616,RETURNED_ACTION_CONTROLcites, company-level credit-invoice prefix); D2 rewritten. Red-team pass the same day (no critical / high): added D5 (a return line carries no cost today, so a write-off loss was invisible) and V20, the Written-off Cost column, the "first valid value" rule with a finalRESTOCK, and "the till ignores the item default until R10" in the summary. - Revision 2 — 5 Oct 2026. Owner: (1) no return without an invoice — removed the "Without an invoice" card, the
sales.return.unlinkedpermission, the no-invoice reason, the Return Type tag, §1.3 and §3.3, R1, R2 and R5; the API now requires the original invoice (V10). (2) Follow the agreed Back-Office screen standard — the return mockup was brought back in line with the current invoice mockup. (3) Print and e-mail the same as the sales invoice — §3.4 rewritten (three ways out, statuses, e-mail wording, theCREDIT_INVOICEchecklist with defaults and required rows), new routes in §4, V18, B4, R8, R9, two new mockups (return-print-options.html,return-print-email-formats.html). (4) Plan and mockups only; nothing built. Red-team pass the same day: added the Voided credit invoice and e-mail wording (§3.4a), fixed the seed file name and eight line numbers, the print pages' Back now opens the return's view page. - Revision 1 — 25 Sep 2026. First plan. Checked against the code and the schema by two verifiers; fixed from them: the stock step must run after the status is Completed (V13), the currency source, the transition map must become per document type (§3.1), the exchange stub answers 500 not 501, the
sales.return.approvehistory. Research: code map of today's return (Back-Office, till,v2-sellable-api) and an outside check of unreferenced returns (Shopify, Lightspeed X-Series, SAP Business One, Odoo 18, LHDN credit note rules) — kept for reference; that path was removed in Revision 2.