Sales Return (Back-Office) — plan

StatusRevision 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).
ScopeBack-Office only. Sales Return (credit invoice, CREDIT_INV / REFUND). Online-type stores, the same as the invoice module.
Screen basisThree 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.
Companionplan.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 onThe 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

AreaWhat 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 listNew 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 form1 Invoice & Items › 2 Refund › 3 Review & send, steps in the pinned footer, Save draft, Submit for Approval.
What happens to each itemEach 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 onlyThe 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).
LifecycleSubmit, Approve, Reject (reason), Void (reason, Approved only), Complete Refund, Delete (Draft / Rejected only).
Return time limitAn 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 controlsThe 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 pageDetails · Refund · Customer as footer steps; Print, Print with options, Send Email and Audit in the header.
Print and e-mailThe 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 / batchTick the serials sold on that line / pick the sold batch.
Original invoiceTurns Returned when the return completes; lists its returns under Linked Documents.

1.2 Out (this round)

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).

TableColumns a return usesNotes
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_statusaction_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 = returnthe 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.1NOT 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_jsonLIMIT_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 todaythe "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_jsonRETURNED_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 / columnWhat the return doesWhy
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 codea 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)Reada 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 NULLa 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 writtene-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 rounda 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 rounda 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 touchedGift 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)Reada 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_movementNot touchedno link a return needs
shipment_details (T:6651, sales_detail_id NOT NULL T:6656)Read onlya shipped invoice line can still be returned
stock_on_hand (T:4086)Not touchedkept by the trigger

Two existing gaps found, outside this plan:

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:

ColumnValue
doc_typeCREDIT_INV (DOC_TYPE_CREDIT_INV, core/sales_doc_semantics.py:24)
doc_sub_typeREFUND (DOC_SUB_TYPE_REFUND, sales_doc_semantics.py:45)
source_typeBO
statusDRAFT › FOR_APPROVAL › APPROVED / REJECTED › COMPLETED; VOIDED
doc_ref_noprefix 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_dateset 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_codecopied from the invoice
notesthe user's note (printed when the "Document note" print choice is on). Not "Return reason: DAMAGED_GOODS" any more (§2.5 D3).
Money columnsnegative: 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_amtat 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_profitas 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_codeleft 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):

WhatWhere it is storedValues
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 — headervoided_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 typedsame codes
Reject / Void reasonworkflow_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:

ReaderTodayChange
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 refundadd 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 ruleadd the predicate
BO repositories/exchange_item_report_repository.py — _build_filtered_core (:76, :125)CREDIT_INV, is_voided only — no status ruleadd the predicate
BO repositories/credit_note_report_repository.py (:154)sums gc_cn_excess_amt of store-credit payment rows onlynone: 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 ruleadd 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 rowsnone: the return's view page shows its own payment row as "to be refunded"

2.4 What the till does (checked 25 Sep 2026)

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=).

#DefectFix in this plan
D1No 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.
D2Serials 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.
D3Raw 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).
D4No 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.
D5No 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.
D6Return 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.
D7Refund 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.

#RuleScreenAPIWhat the user readsTest
V1Tenant 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 / 403another company's invoice id → 404; another company's ref_sales_detail_id → 404; a store outside the user's scope → 403
V2One 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
V3Return 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
V4Never more than is leftReturn 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
V5At least one line with qty > 0Next marks the Qty boxes422 EMPTY_DOCUMENT"Pick at least one item to return."submit with all zero
V6Reason on every returned lineNext 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)
V7Serial / batch completechip amber until done; Next marks a chip still to pick in redevery 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
V8Refund priceread-onlythe server recomputes the unit refund from the invoice line and ignores any price sent—tampered price ignored
V9Refund method chosen and validstarts 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 requiredtender 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
V10Original 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 boxoriginal_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
V11Approve / Reject by an approver, not the author (owner exception)hidden from the authorsales.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
V12Wrong statusbuttons per status (§6.4)VALID_TRANSITIONS (§3.1) → 409 STAGE_NOT_ALLOWED"This return is not waiting for approval." etc.every wrong-status call
V13Complete 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 nothing500 with the request id; nothing half-donefail the stock step → return still Approved, payment row and date unchanged; store with no stock location → 409 before any write
V13aThe 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_totalthe texts in the column to the leftapprove, then complete another return of the same invoice → note shows, Complete refused, Void and Start Again opens a filled new return
V14Void only at ApprovedVoid 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
V15Retry safety—Idempotency-Key on create and on Complete Refundsecond identical request → the first resultreplay
V16Self-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
V17Every move leaves a trace—log_initial_status() / change_status() only; check_workflow_registry.py passes—one log row per move
V19What happens to each itemevery 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
V20Cost 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
V18Print and e-mail only when finishedPrint / 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
V21Non-refundable itemthe line shows "Cannot be returned", greyed, no Return Qty boxa 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
V22Delete only Draft / RejectedDelete 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
V23Priced 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

FromActionToWhoSide effects
(new)Save draft / Next from step 1, once lines are pickedDRAFTsales.return.manageheader, 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, REJECTEDEditsame.managelines and payment row replaced; version must match.
DRAFT, REJECTEDDelete(gone)sales.return.deleteV22 — header, lines, payment row, parent link and log rows removed; nothing logged.
DRAFT, REJECTEDSubmit for ApprovalFOR_APPROVAL.manageV4–V9 checked.
FOR_APPROVALApproveAPPROVED.approvenothing else.
FOR_APPROVALRejectREJECTED.approvereason → remarks, red banner.
APPROVEDComplete RefundCOMPLETED.manageV13 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-…").
APPROVEDVoid ReturnVOIDED.managereason; 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

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

ActionWhereWhat it doesPermission
Print (Printer)list row menu; view page header iconprints the credit invoice PDF at once with the company setting for Credit Invoice (GET /api/sales-returns/{id}/receipt)sales.return.view
Print with optionslist row menu, next to Print; view page header, next to the print iconopens the full page /sales/return/:id/print (§3.4b)sales.return.view
Send Email (Mail)list row menu; view page header iconsends 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".

GroupChoice (label the user sees)KeyDefaultNotes
HeaderCompany logologoon
HeaderLogo shape — Square / Wide / Talllogo_shapesquarenot a checkbox; under Company logo, only when the logo is on
CustomerCustomer phone & e-mailcustomer_contactoncustomer name always prints
CustomerSales rep namesales_repoff"Sales rep · {name}" in the identity panel
ItemsItem codeitem_codeon
ItemsItem barcodeitem_barcodeofftext on the item's second line
ItemsItem noteitem_noteonthe returned serial numbers print here
ItemsTax code & ratetax_codeonoff hides the Tax column; the tax amount in the totals always prints
RefundRefund method & amountpaymentson"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)
RefundReturn reasonreturn_reasononthe 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 pageSignature lines (Prepared / Approved / Received by customer)signature_linesoff
Last pageDocument notedoc_noteoffthe note typed on the return (ft_sales_header.notes)
Last pageNotes & termsnotes_termsoffthe 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:

  1. 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;
  2. the store's RETURNED_ACTION_CONTROL;
  3. the company's RETURNED_ACTION_CONTROL (shipped RESTOCK);
  4. 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.

ChoiceStockMoneyWhere it shows
Back to stockthe units come back at the return's store (ft_stock_trans row, serial / batch kept)refund as usualstock movement report, item stock card
Write offnothing comes back — the units left stock when they were sold and stay out (F:4533 skips the line)refund as usualthe 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:

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).

MethodPathPermissionNew / changed
GET` · /{sales_id} · /dropdowns`sales.return.viewlist: 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}/receiptsales.return.viewchanged: 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-previewsales.return.viewnew — { html } envelope, one copy, no stamp; assert_receipt_access() first (invoice plan §3.11)
GET/{sales_id}/print-optionssales.return.viewnew — { options, required, can_save_company_default } for CREDIT_INVOICE
GET/{sales_id}/status-historysales.return.viewnew — same shape as the invoice's (G24)
POST`` (Idempotency-Key)sales.return.managechanged: 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}.managenew — edit a Draft / Rejected return; carries version and calculation_token; replaces lines and the payment row
DELETE/{sales_id}sales.return.deletenew — 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 · .managenew — same bodies and errors as the invoice routes
POST/{sales_id}/complete (Idempotency-Key).managenew — Complete Refund (V13)
POST/calculate.managenew — refund, tax and rounding for the picked lines and refund method (§3.2); returns calculation_token (V23); no write
POST/{sales_id}/send-email.managechanged: 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}/cashieras todaychanged — 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-invoicessales.return.managenew (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

MethodPathChange
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)

LayerCodeGatesNew?
L1 pagesales.return.viewSales Returns list, return view page, Print, Print with options page (/sales/return/:id/print), every GET routeexists (core/permissions.py:60)
L2 buttonsales.return.manageReturn 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.viewexists (:61)
L2 buttonsales.return.approveApprove, Rejectnew
L2 buttonsales.return.deleteDelete (Draft / Rejected), DELETE /api/sales-returns/{sales_id} — its own canDelete block, never under .managenew (the invoice has sales.invoice.delete, core/permissions.py:57)
L2 buttonsales.return.emailSend Email row action on the listexists (:62)
L2 buttonADMINISTRATION_COMPANY_MANAGE"Save as the company default" on the Print with options pageexists — the invoice's rule
L3 fieldsales.return.field.costCost / margin on the returned linesexists (: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

6.2 Sales Returns list (DataTable)

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.

6.4 Sales Return view page

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/):

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.

8.1 Acceptance

  1. A Completed invoice shows Return Items in the row menu and on the view page; a Draft / Approved / Voided invoice does not.
  2. A return cannot take more than sold − returned − already on other open returns — on the screen and on the API.
  3. The refund price equals what the customer paid per unit, discounts and tax included, whatever the request sends.
  4. A return with no original invoice is refused by the API; the screen has no way to start one.
  5. Nothing moves before Complete Refund: no stock, no money, not in reports, not on the till.
  6. 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.
  7. 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.
  8. The author cannot approve (the owner can); void only at Approved.
  9. 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.
  10. The refund of a line equals its stored invoice amounts pro-rata, on tax-inclusive invoices too; the last return leaves no cent behind.
  11. An invoice past the return limit (owner exempt), paid with a non-refundable tender, or a non-refundable line cannot be returned.
  12. 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.
  13. A Draft or Rejected return can be deleted and frees its qty.
  14. pytest, npm test, npm run typecheck, check_workflow_registry.py pass.
  15. 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.
  16. 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.
  17. 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.
  18. Rejecting, voiding or deleting a return whose card was already refunded shows the card warning with the terminal reference.
  19. 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

#QuestionOption AOption BRecommended
R3Return a till invoice from the Back-Office?No — Back-Office invoices only; till sales are returned at the tillYes — any completed invoiceA — a till offline at that moment can return the same unit again
R6E-invoice for returnsOut of this plan; a separate e-invoice plan (needs the original UUID)In this planA — nothing sends credit invoices today
R8Till returns and the Credit Invoice print settingTill credit invoices printed from the Back-Office follow the company's Credit Invoice setting tooOnly Back-Office returns follow it; till ones print as todayA — one credit invoice look; the invoice side already works this way for the e-invoice mail
R10Should the till also use the item value (once the Item module adds it)?Not this round — the till keeps its store / company settingYes — add the column to the item sync and the till's item table, and use it before the store settingA now, B as a follow-up — the till change needs a POS release
R11Print Back to stock / Write off on the credit invoice?No — it is an internal stock decisionYes, as a small "Written off" note under the itemA — the customer does not need it
R9Required rows on a credit invoiceCompany address, unit refund and tax lines always print (like a tax invoice)All optionalA — 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):

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).

#WhatSize
B0The invoice branch merged to main (print options, sales_doc_semantics.py, the return route)—
B1Fix 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 token2 days
B2Draft 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
B2aThe 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—
B3Step form (Invoice & Items on one step), list, view page, Return Items on the invoice3–4 days
B4Print 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 wording2 days
B5Predicate change in both apps, reader tests½ day

10. Decisions that shaped this plan

11. History