The plan now only keeps the register empty on Back-Office (BO) documents and makes sure nothing breaks because of that. It then answers your 5 questions: what happens in POS (POPC), the End Shift and cash, how the credit invoice looks in POS (POPC), and which store the credit invoice belongs to. The server fix for the empty register is done (not deployed). The rules and the POS (POPC) app change are not built yet.
13 to 14 Oct 2026 · Branch Redy-20261005-Sales-Return · Company OHY33941 (company id 12), dev data · REAL = read from your database or code. EXAMPLE = made up to explain. NOT CHECKED = I did not verify it.
POS : Back office and does not crash. Needs a new POS (POPC) build.Decided on 14 Oct 2026: D1 to D7 use my picks (D4 is your choice). Nothing left to decide before the build, except the open checks in section 7. See section 8.
1. Plan scope is only "register stays empty": the server accepts it, and the POS (POPC) app stops crashing on it. No fake register, no new data.
2. After BO returns a POS (POPC) sale, the POS (POPC) syncs the return and shows the sale as Returned. Found in your real data: two link ids written by BO do not match what POS (POPC) looks for, so POS (POPC) cannot connect the return to its sale. It should still show the right "left to return" quantity, but this must be fixed and tested (section 2).
3. Yes, it is better to block it. A cash refund in BO takes cash out of the drawer without the shift knowing, so End Shift will be short. At your store the only refund method BO can use is Cash, so blocking Cash means POS (POPC) sales are returned at POS (POPC). BO does not stop the return at the start: the user picks the items first and meets the Cash block at the refund step. Details in section 3.
4. The credit invoice shows in the POS (POPC) Sale History as a "Credit Invoice" with no register and no shift. Opening it can crash today. After the fix it should read "Back office" (section 4).
5. Today BO always gives the credit invoice the same store as the original sale. POS (POPC) does the opposite: it uses the store of the device doing the return. Both ways have effects on stock, sync and reports. Your decision (14 Oct): BO lets the user change the store, starting on the original store (section 5).
On 13 Oct 2026 you asked me to turn on BO returns of POS (POPC) sales. It is on in the working tree (SOURCES_RETURNABLE = (SOURCE_TYPE_BO, "POS") in sales_commit_repository.py:40) and is not committed. The rules R1 to R5 below are not built yet. If this branch is merged before them, BO can refund a POS (POPC) sale in Cash and End Shift is short. So R1 to R5 must go in the same merge as the switch (decision D6).
13 Oct: two reviewers checked the page against the real code and your dev database. They found 1 critical problem and several high or medium ones. One was a code bug: the server fix for the register was incomplete (section 1).
14 Oct, after you dropped rule R2: one more reviewer found 4 high and 10 medium or low points. They were leftovers of R2, mockup details that did not match the real POS (POPC) code, and one missing piece of work on the BO return form. All are fixed below.
The problem: After you returned a POS (POPC) sale in BO and synced, POS (POPC) showed this error:
POST /api/v1/sales/get-sales — 500 — 2 validation errors for SalesHeaderInfo: register_id Input should be a valid integer (input_value=None); register_code Input should be a valid string (input_value=None)BO has no POS (POPC) device, so every BO document has an empty register and an empty shift. In your company that is 40 BO documents that sync (24 invoices, 12 orders, 4 returns). It is an old problem: your BO return was just the latest one inside the sync window.
| Part | Change | State |
|---|---|---|
Server (v2-sellable-api) | register_id (and register_code on the header) become optional in six models in sales_model.py: the header, SalesDetailInfo, SalesItemTaxInfo, SalesPaymentInfo, SalesPromoDiscInfo and SalesParentChildInfo. The first fix covered only the header, so the sync would have failed again on the next list. Found in review and fixed. | Done, not deployed · 3 tests pass |
| POS (POPC) app | Opening a sale with no register must not crash. Show "Back office". sale_utils.dart:189 does registerId! then .first (called from sale_detail_controller.dart:222 and :818); sale_history_controller.dart:570-571 has the same pattern. | To build · needs a new POS (POPC) build |
| BO data | Nothing for the register. Register and shift stay empty on BO documents. | No change |
Why the register is not filled in: other brands do the same (Shopify, Square, Toast keep the device empty and use a "source" field; you already have one, source_type). A fake id would still crash POS (POPC). A real "BO_REG" register per store would cost one register row per store and may use up the plan's register limit. Both are kept in the box at the end of this page in case you want them.
This is the real case from your data. The sale was made at POS (POPC) store SC0D (Pedro Lazada), register REG-01, shift 2807.
| # | Who | What happens |
|---|---|---|
| 1 | POS (POPC) | Sells 3 × BEV-004 by card: SI-8/10/261013122636417, RM 314.80, shift 2807. |
| 2 | BO user | Returns 2 units. BO writes the credit invoice CI-8/BO/261013052505369: store SC0D, no register, no shift, one payment of −RM 209.90 Cash. |
| 3 | BO | The original sale changes to Returned. Its line shows qty 3, returned 2 (stored as −2). |
| 4 | BO | At Complete Refund the 2 units go back into stock. |
| 5 | POS (POPC) syncs | It receives the updated original sale and the new credit invoice. With the server fix there is no error. |
| Question | Answer | How sure |
|---|---|---|
| Does the sync store the credit invoice? | Yes. An empty register or shift does not stop it. It only fails if the offline sale id, document number, type, source type or date is empty, and BO fills those. | Read in code (sales_http_sync.dart, sale_header_table.dart). Not run. |
| Does the original sale show as Returned? | Yes, the newer server copy overwrites the old one. | Read in code (sale_header_dao.dart:63-79). Not run. |
| Can the cashier return the same 2 units again at POS (POPC)? | It should not be possible: POS (POPC) shows 1 left. It reads the returned quantity that came from the server (see the box below). If that quantity is not synced, the cashier could refund twice. | Open risk — must be tested on a device |
| Does the credit invoice appear in Sale History? | Yes (section 4). | Read in code. |
POS (POPC) joins a return to its sale using ids. I compared what BO wrote with what POS (POPC) looks for:
| Link | What BO wrote | What it should be | Match? |
|---|---|---|---|
| Return link row, "child" id | e3c01777-… | the credit invoice's own id 70d26d65-… | No |
| Return link row, "parent" id | 5e095b52-… | the original sale's id 5e095b52-… | Yes |
| Credit invoice line, "refers to original line" id | a733f460-… | the original line's id b3f9f045-… | No |
What it means: POS (POPC) finds the returns of a sale by joining the link row's child id to a sale it has. That join finds nothing, so POS (POPC) cannot list the credit invoice under its sale. For "how many are left" it then falls back to the returned quantity on the sale line (sale_return_exchange_utils.dart:242-272), which BO did update (returned = −2). So the left-to-return quantity should still be right, but only by luck of the fallback.
Cause (found in review, read in code):
RETURNING at sales_commit_repository.py:297). So BO passes nothing (sales_commit_service.py:2273) and the link row makes a new random id (sales_commit_repository.py:1156).ref_offline_sales_detail_id fills itself with a random id (tables_pg.sql:2724), and the BO line insert never sets it. BO must first read the original line's id (add it to the get_returnable_lines query), then write it.Fix (small, in BO): write the real ids into the link row and into the credit invoice lines.
The 4 BO returns already written keep their random ids. They rely on the fallback. One fix: a one-off update of those rows. I will not touch old data without your OK (decision D3).
Your question: End Shift will not tally if the BO return is paid in cash. Is it better not to let BO refund a POS (POPC) sale in cash? My answer: yes.
Assumption: a BO cash refund is paid from the POS (POPC) cash drawer. If your shop pays BO refunds from an office or petty-cash box, the drawer is not short. Please confirm.
End Shift adds up only documents that carry that shift's number. A BO return has none, so it is left out of every shift, and it is not taken off the original sale's shift. (Checked in the database functions, functions_pg.sql:1868-2465, and by running the system's own shift report on your shift.)
| Shift 2807 (REG-01) | Amount |
|---|---|
| Opening float (inferred: expected cash 1,105.95 − cash sales 1,005.95) | 100.00 |
| Cash sales (4 sales) | 1,005.95 |
| Card sales (1 sale) | 314.80 |
| System expects cash in drawer | 1,105.95 |
| BO cash refund (not in any shift) | −209.90 |
| Cashier counts (1,105.95 − 209.90) | 896.05 → short by 209.90 |
| Refund paid by | Drawer cash | Card / e-wallet total | Net sales and tax of the shift |
|---|---|---|---|
| Cash, from the drawer | Short by the refund | Correct | Too high |
| Card or e-wallet (on the machine / app) | Correct | Not correct if compared with the machine (EXAMPLE: system 314.80, machine 104.90) | Too high |
| "No Refund From Store" | Correct | Correct | Too high |
| Same return made at POS (POPC) | Correct | Correct | Correct |
Also: the original sale's shift discount line changes sign once the sale becomes Returned (functions_pg.sql:2287-2299), even on a closed shift. And the BO business-day total leaves BO returns out (business_day_repository.py:176-186). Both are report figures, not cash.
| Method | Enabled for refund at SC0D? | Can BO use it? |
|---|---|---|
| Cash | Yes | Yes |
| Credit Note (store credit) | Yes | No — the server refuses store credit and gift card (sales_commit_service.py:1747-1757) |
| Credit / Debit Card, E-wallet Payment | No | No (not enabled at this store) |
| Gift Card | No | No |
_price_return (sales_commit_service.py:1682). Create, edit, calculate, and the re-check at submit, approve and Complete Refund all go through it. A Cash refund of a POS (POPC) sale is refused with the existing reason REFUND_METHOD_NOT_ALLOWED and its own plain message: "Cash refunds of POS (POPC) sales are paid at POS (POPC)."sales_commit_service.py:1739-1744).| Question | Rule |
|---|---|
| Which refund methods does BO list for a POS (POPC) sale? | The ones the store has enabled, minus Cash (R1) and minus the ones the server already refuses (store credit, gift card). If none is left, the user gets the R5 message. |
| Work on the return form (found in review) | Today, once items are picked, the Items step already prices the refund with a preview method: a non-cash one if the store has it, otherwise the first one (SalesReturnFormPage.tsx:327-339). At SC0D that is Cash, so with R1 the server would refuse the preview one step early. So the form must (1) leave Cash out of the method list for a POS (POPC) sale (SalesReturnFormPage.tsx:223-231); (2) when the list is empty, skip the preview and show the R5 message on the Refund step; (3) the Items step then shows no refund amounts for such a sale, and the message says why. Decide the exact wording of that Items-step hint when building. |
| Which sales does this apply to? | Every sale that is not made in BO (not only the text "POS"). Company 12 also has 1 sale with source "POPC" and 15 rows with an old document type. They cannot be returned today, but the rule should not depend on the exact word. |
| Returns already open before the rule? | A Draft or For Approval return of a POS (POPC) sale with a Cash refund will be stopped at the next submit or approve, and it keeps holding its quantity. The message must say "Void this return" (void exists). In dev there are 0 such returns. Production: not checked. Run /tmp/open_returns.sql before deploying. |
SI-8/10/261013122636417 could not be returned in BO under these rules (R5). Cash is not listed for a POS (POPC) sale. Refund the card on the machine first, then type its reference. This refund will not show in the POS (POPC) shift's card total.What I could read in the POS (POPC) code (not run on a device):
| Screen | What the user sees | Breaks? |
|---|---|---|
| Sale History, tabs All / Normal / Return | The credit invoice is listed, with document type "Credit Invoice" (sale_history_controller.dart:330-395, sale_constants.dart:178-246) | No |
| Sale History, "this shift" tab | Not listed: it has no shift (sale_header_dao.dart:242-258) | Hidden, by design |
| Sale History, search by register | Not listed (sale_header_dao.dart:278-285) | Hidden |
| A row in the list | No visible mark. The "other shift" check (list_sale.dart:354-355) only disables "Change cashier", which a credit invoice never shows | No |
| Opening the sale detail | Looks up the register with registerId! and .first | Crash risk (sale_utils.dart:189) |
| Detail: "different register / shift" flags | Treated as a different register and shift (sale_detail_controller.dart:230, 799, 817, 936) | Logic only; how the screen uses it: NOT CHECKED |
| Receipt reprint, X-report, payment line, total sign | NOT CHECKED | — |
The POS (POPC) does not treat a credit invoice from BO differently from one made at POS (POPC) in these list and filter queries (checked only in sale_history_controller.dart and sale_header_dao.dart).
I rebuilt these from the real POS (POPC) code, not from my own idea. The Sale History screen has two parts: a list on the left and a receipt-style preview on the right (list_sale.dart, sale_detail_view.dart). The register is shown only in the preview, as the line "POS : name" (the label text is "POS": PRINT_POS_INFO.REGISTER_NAME in en_US.json). The list does not show the register at all.
sale_utils.dart:189). What the screen then shows is NOT CHECKED.sales_commit_service.py:2220-2222). The time is an example (the number in the document name holds 05:25, probably UTC). The items, payment and totals below it are unchanged."Back office" needs a new key in assets/languages/en_US.json (for example SALES.BACK_OFFICE), added in the same change. Other languages only if a verified translation exists. Screens touched: sale_utils.dart:189 (lookup returns nothing instead of crashing), sale_detail_controller.dart:229 (sets the name), :817-827 and :937-941 (Return / Edit messages that name the register), sale_history_controller.dart:571. Option B also list_sale.dart.
NOT CHECKED: a real screenshot (I read the code only), receipt print and reprint, and what the Return and Edit buttons show for a BO credit invoice.
You pointed out that the real return happens in BO, possibly for a store other than the one that made the sale. The credit invoice (CI) carries a store, and the stock, the sync and the reports all follow it.
| CI | CI store | Original sale | Original store |
|---|---|---|---|
CI-8/BO/261013052505369 | SC0D | SI-8/10/261013122636417 | SC0D |
CI-994/BO/261008055122358 | 7MOM | SI-994/BO/261007035358012 | 7MOM |
BO has no store field on the return form. It copies the original sale's store (sales_commit_service.py:2182-2192), checks that the user holds that store (:1709), and checks that store's stock location and its refund methods (:1748). POS (POPC) does the opposite: the server overwrites a return's store with the store of the device that makes it (sales_service.py:724-726).
The sale was made at S1 = Pedro Lazada (SC0D). The customer brings the goods to S2 = Sellable KL Sentral (RYS-KL), and a BO user returns it.
Stock goes back to S1's default location, though the goods are at S2.
Refund uses S1's refund methods and is booked to S1.
Sync: S1 devices get the CI. S2 devices never get it: with the setting IS_ALLOW_EXCHANGE_REFUND_OTHER_STORE on, a device also gets credit invoices whose original sale is in its store, and here that is S1.
Reports: the return sits under S1.
Work: none. Every check already uses S1.
Stock goes to S2's default location (the database function follows the CI's store, functions_pg.sql:3629-3660).
Refund uses S2's refund methods, booked to S2.
Sync: S2 devices get the CI. S1 devices only with that setting on.
Reports: the return moves to S2 in sales lists, credit-note report and e-invoice. S1's totals no longer show it.
Work: see "What the store picker needs" below.
| Part | Change |
|---|---|
| Request | Add an optional store_id to ReturnCalculateRequest (models/sales_creation.py:254-261). Empty means the original store. The create request inherits it. |
| Price check code | The code that proves the quoted figures (calculation_token, sales_commit_service.py:243-262) must cover the chosen store, so a store cannot be swapped after pricing. |
| Access check | Keep the check on the original store (:1709). Add the same check on the chosen store. |
| Pricing and refund method | _price_return reads the store settings (:1728) and the refund method (:1749) of the chosen store. R1 and R5 use the same store. |
| Create | Stock location check (:2152), document number and the store written on the header (:2181-2193) use the chosen store. The replay check (:2132-2146) already reads the stored header. |
| Edit, submit, approve | The edit path (:2573) and the checks that read the header's own store (:2323, :2411) already follow the header. Check both with a test. |
| Complete Refund | The stock location check at :2630 must use the header's store, not the original's. Stock then goes to the chosen store (database function functions_pg.sql:3629-3660). |
| Return form | New "Return to store" field on the Refund step (SalesReturnFormPage.tsx). The method list (useStorePaymentMethods) reads the chosen store. Changing the store clears the chosen method. |
| Tests | Default = original store; changed store changes stock location, methods and document number; a user without access to the chosen store is refused; a token from another store is refused. |
R5 gets easier, but only when the goods really move. If the original store has Cash as its only refund method (like SC0D), the user can pick a store that has card refund enabled. They must do this only when the customer really returns the goods there. Cash is still blocked (R1).
POS (POPC) devices: the device of the chosen store receives the credit invoice. The device of the original store sees its sale as Returned (the returned quantity syncs), but not the credit invoice, unless the setting IS_ALLOW_EXCHANGE_REFUND_OTHER_STORE is on.
Reports: the return moves to the chosen store in the sales list, the credit-note report and e-invoice. The original store's totals no longer show it. End Shift: unchanged, a BO return is in no shift.
Risk: a user can book stock into the wrong store. The red hint above the field is there for that.
The return form has no store field today (models/sales_creation.py:254-273). NOT CHECKED: whether the credit-note report's store equals the CI header store; the e-invoice store mapping end to end; the full End-of-day roll-up; every database trigger.
| Area | What changes | Risk |
|---|---|---|
| POS sync (get-sales) | Accepts an empty register (done, not deployed) | Low |
| POS (POPC) app: Sale History detail | No crash on an empty register; show "Back office" | Medium — new build, test on a device |
| BO: link ids of a return | Write the real ids so POS (POPC) can connect the CI to its sale | Medium — check on a synced device |
| BO: refund rules R1 to R5 for POS (POPC) sales | Server check, plain message, no Cash for a POS (POPC) sale, and changes on the return form (method list, empty list, Items step without figures) | Medium |
| End Shift | No change to the maths. R1 stops the case that breaks the cash count. | Card and net sales stay off |
| Store of the CI | Store picker on the return form, default = original store; server uses the chosen store for access, methods, stock and document number (section 5) | Medium — touches create, edit, approve and Complete Refund |
| Old data | No change unless you say yes to D3. The 4 BO returns already written keep random link ids and rely on the fallback. | Low |
| BO: return after a POS (POPC) return | Returns made at POS (POPC) are not matched to the original line in BO (all 337 lines have no reference). The quantity left is still right, but the "last return takes the rest" rule and the serial check can miss them. Cash refunds are refused (R1), but any POS (POPC) sale refunded by card, e-wallet or "No Refund From Store" can still hit it. | Medium |
| E-invoice (LHDN) | A BO credit invoice of a POS (POPC) sale is new. Not checked. | Decide before release |
v2-sellable-api (all six models). The sync works again. Can go first and alone. Add a test that runs one BO document through the whole get-sales answer._price_return; return form changes (see "Work on the return form"); screen message and hint. Tests: Cash refund refused on a POS (POPC) sale, whatever the sale was paid with; card or "No Refund From Store" allowed where the store enables it; a store whose only method is Cash gets the R5 message and no preview call; BO-made sale unchanged. Must be in the same merge as the switch (D6).Rollback: every step is code only. Redeploy the previous build. Returns written with the new ids stay valid.
fn_api_dayshift_get_xreport_shift_summary at functions_pg.sql:1772 and fn_api_get_shift_summary at :2708). I read the first; which one End Shift calls is not shown.| # | Decision | Option A | Option B | Decided |
|---|---|---|---|---|
| D1 | No Cash refund in BO for a POS (POPC) sale (R1) | Yes — cash refunds are done at POS (POPC) | No — accept the shortage | Yes |
| D2 | Card, e-wallet or "No Refund From Store" refunds of a POS (POPC) sale (R3) | Allow with a warning, if the store enables the method | Block too | Allow with a warning, unless your shift compares the card total with the machine |
| D3 | Fix the link ids so POS (POPC) can connect a return to its sale | New returns only (old returns, including the real one in section 2, show no "Ori #Ref") | New returns and the 4 old rows (needs your OK) | New returns now, old rows later |
| D4 | Store of the credit invoice | Original store only (today) | Store chosen in BO, picker starts on the original | B — decided by you on 14 Oct |
| D8 | Who may change the store | Anyone who can create a return and has access to that store | A new permission just for this | No new permission |
| D5 | Show BO returns on the shift report | Yes, later | No | Later |
| D6 | Release gate: when may BO returns of POS (POPC) sales go live? | Only in the same merge as R1 to R5 | Now, rules later | Same merge |
| D7 | A POS (POPC) sale at a store whose only refund method is Cash (like SC0D) | Tell the user to return it at POS (POPC) (R5) | Turn on card refund for the store in settings | R5 message |
A — a real "BO_REG" register in each store. Works with no POS (POPC) code change, but needs one register row per store (and for each new store and company), may count against the plan's register limit, may be picked by a cashier, and old BO documents need a database update.
B — a fake register id (0) with no register row. Still crashes POS (POPC): it looks the register up by id and takes the first result of an empty list.
"Code only, no id" does not work: the server needs a number for the id too.
What other brands do: Shopify, Square and Toast keep the device empty and add a "source" field. Lightspeed X-Series requires a register and uses a real register made for web shops (extra registers can cost money). Odoo and Microsoft Dynamics keep back-office orders apart from POS sessions, and a POS refund is written on the POS's own session or terminal.
Source of this page: apps/v2-sellable-bo/docs/modules/sales-return/till-sale-return-plan.html. Facts checked against: db-postgresql/tenants.schema/functions_pg.sql, apps/v2-sellable-api/src/modules/sales/sales_query.py, sales_model.py, sales_service.py, apps/v2-sellable-bo/backend/app/services/sales_commit_service.py, business_day_repository.py, apps/v2-pos-client/client/lib/… (sale_utils, sale_history, sale_detail, sync) and your dev database (read only).