Business day for an online store — proposal and options
| Status | Proposal, Draft 2 — 15 Oct 2026. For the owner to review. Nothing is built. Draft 1 was checked by two reviewers against the code; this draft fixes what they found. It replaces the earlier "Sales and Dayshift" plan, which was removed. |
| Branch read | Redy-20261005-Sales-Return |
| Scope | apps/v2-sellable-bo (backend + frontend), apps/v2-sellable-api, db-postgresql/tenants.schema. legacy/ not read. |
| The idea (owner) | 1. The store settings hold a business day: when it opens and when it closes. 2. Sales on an online store can be created only inside that period (example: open 08:00, close 23:59). 3. Outside the period nothing can be created. 4. ft_dayshift gets one day and one shift for each day, by default. 5. The register in BO stays empty (NULL). |
0. Summary
The idea works. A day and a shift with no register run through the shift functions and the shift view without an error (read from the code; nothing was run). The reports keep working.
What the idea does not yet cover, and this proposal fills in:
- The shift totals. On a till, the POS works out the shift totals and confirms the payment lines; the server does not. BO would have to do both, and a closed day would not see a document that is completed or paid later. This proposal avoids the problem: for an online day BO reads the totals live from the sales, so there is nothing to freeze and nothing to refresh (decision D2).
- Back-dated documents. BO lets a user pick a document date up to 90 days back. A back-dated document goes on its own day (D5).
- A return changes its date when it is completed (
redate_return,sales_commit_service.py:2637-2640). So a return created on Monday and completed on Wednesday must move to Wednesday's day (D7). - Cash refunds. A Cash refund on a BO return is a payment row that the shift adds up as cash out, with nobody to count it. The proposal blocks it (D6).
- Existing online stores already carry a "day end" of 23:59 from the form defaults. The window turns on only when Opens at is filled in, so no store changes by surprise (section 2.1).
- Two store guards would break: changing the store type, and (with the sweep option) renaming the store code (section 5).
My picks (section 7): D1 your design (Option 1) · D2 day rows are made on the first sale, born closed, with live totals (no sweep) · D3 reuse the day-end fields and add one start-time column · D4 hard block outside the hours · D5 a back-dated document goes on its own day · D6 block Cash refunds in BO · D7 a completed return moves to the completion day · D8 no empty days.
1. The options
| Option 1 — your design | Option 2 — window only | Option 3 — soft window | Option 4 — 24 hours | |
|---|---|---|---|---|
| Store hours | Open and close time | Open and close time | Open and close time | None (empty = 24 hours) |
| Outside the hours | Cannot create | Cannot create | Warning, or allowed with a manager permission | Always allowed |
ft_dayshift day + shift | One of each per day | None. dayshift_id stays empty | One of each per day | One of each per day, cut at midnight |
| Register | Empty | Empty | Empty | Empty |
| BO sales in shift and business-day views | Yes | No | Yes | Yes |
| New work | Large | Small | Large | Large (same as Option 1, window off) |
| Matches the market | Partly | Partly | Closest — Toast says staff orders are never limited by online hours | Yes |
| Verdict | Recommended | Fallback if Option 1 is too big | A switch inside Option 1 (D4) | Not a separate build: leave Opens at empty in Option 1 |
What other products do (official docs where they could be fetched; UNVERIFIED where only a forum or snippet):
- Toast: hours are set per weekday, up to 3 periods a day, with date overrides for holidays. Hours may run past midnight but must end 15 minutes before the business-day cut-off (default 4 a.m.). With scheduling off, guests cannot order outside the hours; with it on, they pre-order for the next opening. Orders placed on the POS (staff) are never limited by online hours. There is no online shift: one cut-off ends the business day.
- Square: online hours limit pickup times; the QR-code ordering page does block outside the hours. The close-of-day setting only groups payouts.
- Shopify: the online store has no hours. Pickup has an on/off switch and a preparation time, no cut-off.
- Dynamics 365 Commerce: store hours are information only. Shifts belong to the POS. Online orders are not tied to a shift (inferred).
- Odoo: self-ordering follows an open POS session. Auto-close and opening-hour limits exist only as add-ons.
- Grab / foodpanda: weekly hours plus special dates, a pause switch, and an automatic pause when no menu item is available.
What this means for you. All the products limit the customer, not the staff. Your rule limits the staff who use BO, which none of them does. That is your call (D4). The products also keep opening hours and the business day apart; your design joins them, which is simpler and fine for a store that works one block a day.
1.1 When the day and shift rows are made, and when they change to closed
The example: an online store, hours 08:00 to 23:59, store time. Today is 16 Oct. "Row" means the two ft_dayshift rows of that day: the day (EOD) and the shift (SHIFT).
For comparison — a till store today (not changed). The cashier opens the business day: the POS writes the day row OPENED. The cashier opens a register shift: the POS writes a shift row OPENED with the register. The cashier counts the cash and closes the shift: the shift row is updated to CLOSED with the counted cash. When every shift is closed, the day is closed: the day row is updated to CLOSED. A person does every step. (dayshift_service.py: close needs all shifts closed.)
Option 1 with D2 = A (born closed, live totals) — recommended
| Time (16 Oct) | What happens | ft_dayshift |
|---|---|---|
| 07:59 | A user tries to create a sale. Refused: "This online store is closed now..." | nothing |
| 09:12 | The first sale of the day is created inside the hours | INSERT two rows (day + shift). status = CLOSED, opened_datetime = 16 Oct 08:00, closed_datetime = 16 Oct 23:59, register_id empty. The sale gets the shift id. |
| 10:30 and every later sale | The row is found. | nothing |
| 18:00 | A payment is added to a sale. | nothing (totals are read live) |
| 22:00 | An invoice is issued or a return completed. | nothing |
| 23:59 | The hours end. No job runs. | nothing. The row was already closed. |
| 00:05 (17 Oct) | A user tries to create a sale. Refused. | nothing |
| 17 Oct 09:00 | The first sale of 17 Oct. | INSERT two new rows for 17 Oct |
| 17 Oct 11:00 | A return created on 16 Oct is completed. It moves to 17 Oct (D7). | the 17 Oct rows are found or made; the return's own dayshift_id changes, not ft_dayshift |
| any time | A back-dated invoice dated 14 Oct is created on 16 Oct at 10:00 (D5). | INSERT two rows for 14 Oct if missing (born closed) |
| A day with no sale | Nothing happens. | no rows (D8 = A). With D8 = B a job at 08:00 inserts the rows every day. |
The rows are written once. They are never updated.
Option 1 with D2 = B (opened by the first sale, closed by a sweep)
| Time (16 Oct) | What happens | ft_dayshift |
|---|---|---|
| 09:12 | The first sale of the day | INSERT two rows, status = OPENED, closed_datetime empty |
| during the day | Sales, payments | nothing. The rows stay OPENED. Renaming the store code and changing the company currency are blocked while a row is open. |
| 23:59 | The hours end. | nothing yet |
| 00:00 to 00:14 (17 Oct) | The sweep runs (every 15 minutes). It works out the shift totals, writes the payment lines as CONFIRMED, then | UPDATE the shift to CLOSED, closed_datetime = 23:59, then UPDATE the day to CLOSED, then call the close function (it writes the day's payment lines and totals) |
| 17 Oct, 10:00 | An invoice of 16 Oct is issued or paid. | refresh the closed day: delete its payment lines and run the totals again (the close function does nothing when lines exist) |
| The server was down at midnight | The next sweep run closes every day that is past its closing time. | UPDATE, as above |
| A day with no sale | Nothing happens. | no rows (or a daily job, D8) |
Option 2 (window only)
| Time | What happens | ft_dayshift |
|---|---|---|
| All day | The window refuses sales outside 08:00–23:59. | never written. Sales keep dayshift_id empty. |
Option 3 (soft window)
Same as Option 1 (A or B). The only difference: a user with the permission can create a sale at 02:00 on 17 Oct, before the store opens. It goes on the 17 Oct rows, the calendar day in the store's time zone (opened_datetime 08:00, closed_datetime 23:59, even though the sale came earlier). The rows are made by that first sale, as in the tables above.
Option 4 (24 hours: "Opens at" empty)
Same as Option 1, but the day is the whole calendar day: opened_datetime = 00:00, closed_datetime = 23:59:59. With D2 = A the rows are inserted by the first sale of the day and never updated. With D2 = B the sweep closes the day just after 00:00.
2. How Option 1 works
2.1 Store settings
The store table already has hours fields (tables_pg.sql:6956-6968): business_hours (free text, shown on the form and passed to the POS, but no logic uses it), business_dayend_time (time) and force_business2close (switch). There is no open-time field.
| Field on the Online store | Source | Note |
|---|---|---|
| Opens at | new column business_daystart_time time (one migration) | D3 |
| Closes at | existing business_dayend_time | the form sends it only when "force close" is on and clears it otherwise (StoreFormPage.tsx:118-119,208); for an Online store show it always |
| Close the day automatically | existing force_business2close | not needed with D2 option A; kept as it is |
| Time zone | existing store.timezone | required (text NOT NULL) but not checked as a real name. Check it when the store is saved; fall back to the company zone (resolve_company_timezone). |
When the window is on. The window is on only when Opens at is filled in. Closes at empty then means 23:59. If Opens at is empty the store is open 24 hours, as it is today, and its day ends at 00:00. This matters because every new store form already fills in a day end of 23:59 (StoreFormPage.tsx:148-149; the setup wizard setup_guide_service.py:420), so those stores would otherwise be closed to sales from one day to the next.
Store › General Type: Online
Business hours (online store)
Opens at [ 08:00 ]
Closes at [ 23:59 ]
Time zone [ Asia/Kuala_Lumpur ]
Leave "Opens at" empty to take orders 24 hours a day.
Rules for the first version: opening must be earlier than closing, and closing may be 23:59. The same hours every day. Hours that pass midnight, a different time for each weekday, and holidays are for a later version (Toast and Grab have all three).
2.2 The window check
A new function assert_store_open(store, now) runs at the start of each place that creates a BO document, using the server clock in the store's time zone. It is not put inside _resolve_store, because that function is also used by price calculation, coupon check and draft replace (sales_commit_service.py:782,2754; sales_document_write_service.py:118), which must not be blocked, and create_return does not call it at all.
| Create path | Where | Store comes from |
|---|---|---|
| Create invoice | sales_commit_service.py:~1057 (create_sale) | _resolve_store |
| Create order | sales_order_write_service.py:~79 | _resolve_store |
| Create return | sales_commit_service.py:~2098 | the original sale's store |
| Convert order to invoice | sales_order_write_service.py:~718,783,803 (uses now) | the order's store |
Create exchange is not built (it refuses every request: sales_commit_service.py:2719-2740); the check is added when it is.
Outside the hours the user sees: "This online store is closed now. You can create sales from 08:00 to 23:59." The New buttons show the hours.
Not blocked: price calculation, edit a draft, submit, approve, reject, void, issue, payments, and complete a return. A document keeps the day it was created on, except a return, which moves on completion (D7).
2.3 The day and the shift
When a document is created inside the hours the server finds or makes that store's day and shift for the document's date, then writes the shift id on the document (dayshift_id, offline_dayshift_id). The header insert (sales_commit_repository.py:261) must add both columns; today it leaves them empty ("A8", line 256).
Create a sale (online store)
|-- Opens at set, and server time outside the hours? --> refuse with the plain message
|-- find the day + shift for the document's date (store time zone)
|-- not found? take the lock, look again, create day + shift (born closed)
|-- write dayshift_id on the document; register_id stays empty
'-- save
Columns the new rows must fill (tables_pg.sql:1311-1360):
| Column | Value |
|---|---|
dayshift_type | EOD for the day, SHIFT for the shift |
company_id, store_id | from the store |
currency_iso_code | from the company (the store has no currency column) |
business_date | the calendar day in the store's time zone |
opened_datetime | the opening time of that day |
closed_datetime, status | with D2 option A: the closing time of that day, and CLOSED. The column default for status is 0, which is not a valid value, so always set it. |
opened_by_user_id | the system user SYS001. It is created for new companies (company_seed.py:71-77) and back-filled for existing ones (migration 20260302_0300). There is no foreign key. Live data not read. |
parent_dayshift_id and offline_parent_dayshift_id | on the SHIFT, the ids of the EOD. POS sync reads the offline one (sales_query.py:5225-5257). |
register_id | empty. The comment says mandatory for a SHIFT; there is no CHECK and the API model accepts empty (dayshift_model.py:201,355). |
ft_dayshift has no unique index and none can be added without created_date (the table is partitioned). Two sales at the same moment could both create a day. Look first without a lock; only when nothing is found take the lock and look again. Reuse the lock from sales_document_repository.py:36: pg_advisory_xact_lock(hashtext('online_day:{company}:{store}:{date}')). The two-number form does not fit (the store id is a bigint and a date is not a number).
ft_dayshift is not a workflow table in code (not in WORKFLOW_TABLES; the CI script lists it as excluded). Use a plain INSERT / UPDATE with the audit fields; do not call change_status. (The BO CLAUDE.md rule 17 still names it; that text is out of date.)
BO has no code that writes ft_dayshift today (the only writer is the API, dayshift_query.py:686,729). This is a new repository and service in BO.
2.4 Totals — why there is no sweep
On a till, a closed day shows stored totals (business_day_repository.py:153-189). The POS works them out and sends them. The server functions only add up lines that the POS has already confirmed:
fn_api_set_dayshift_payment(functions_pg.sql:6194) only inserts payment lines, with no status; running it twice duplicates them.fn_api_dayshift_close_business_day(:891) raises unless the day is alreadyCLOSED, adds up only lines with statusCONFIRMED, copies the shift'stotal_*columns (which nothing on the server fills), and returns early if the day already has payment lines.
So a BO-closed day would end with empty totals unless BO builds the totals, confirms the lines, and handles any later change itself. And the rollup counts only documents in a final status (RETURNED, COMPLETED), while a BO invoice is created today and issued tomorrow.
Proposed instead (D2 option A): online days are born closed and their totals are read live from the sales, by joining store.store_type. The live read must use the sales-summary filters (_SUMMARY_DOC_TYPE_SQL and SALES_REPORT_STATUSES = COMPLETED / RETURNED, business_day_repository.py:1023-1024), not the open-day count at :176-186. That count takes every non-voided header, and an online day will also hold drafts, rejected documents and orders (they get a day at creation), so a converted order would be counted twice. Then:
- nothing to roll up, no payment lines to confirm, no refresh when a document is completed, issued or paid later;
- no sweep job, no tenant loop, no catch-up after downtime, no row left
OPENED; - the store code can still be renamed and the company currency changed all day (
store_repository.py:103,company_settings_repository.py:35-47block those while a day isOPENED).
The cost: every place that today reads the stored totals of a closed day needs one shared test, is_live = closed_datetime IS NULL OR the store is ONLINE (section 3 lists them), and the status column shows a fixed label "Online day" for these days instead of Open / Closed (the status is filtered and counted in SQL, and five pages map OPENED / CLOSED by hand, so a status worked out from the clock is not a small change). D2 option B is the till way: open on the first sale, close by a sweep, freeze the totals. It needs the work listed in section 4.
2.5 What the managers see
- The Business Day and Shift lists show an online day and shift with a blank register (the shift list already tolerates it:
business_day_repository.py:363-374;ShiftDetailPage.tsx:163says "No Register"). - The Register tab lists the register-less shift (
get_register_summary,:847-876), but opening that row leads to a page that fails, because the header query joinsregister(:1361-1375,RegisterTab.tsx:106-108). The row must not be clickable when there is no register. - The X-report and shift summary show the non-cash payments by method (
functions_pg.sql:1772,views_pg.sql:9). With Cash refunds blocked there is no cash line to explain. - Sales Summary and the other reports keep working: they LEFT JOIN
ft_dayshiftand now read the shift's business date (COALESCE(d.business_date, h.doc_trx_date::date)).
3. What changes in the code
| Area | Change | Size |
|---|---|---|
| Database | One migration: store.business_daystart_time time NULL, and the same line in tables_pg.sql (a test checks the DDL mirrors the migration). Alembic folder db-postgresql/migrations/tenant/versions; the head must stay single. | S |
| Store form and API | The new column goes into the four explicit column lists in store_repository.py (:21,45,200-214,294-310), models/store.py, store_service.py (_parse_time, validation, time zone check), StoreFormPage.tsx, GeneralTab.tsx, and the setup wizard (setup_guide_service.py:413-427, StoreStep.tsx). Show Closes at always for an Online store: StoreFormPage.tsx:208 today sends it empty whenever "force close" is off, so that line changes too. | M |
| Window check | assert_store_open in the four create paths (section 2.2). Invoice and order already load the store with _resolve_store; the return and the convert take the store id from the original document or the order, so they must load the store through get_store_for_sale (sales_commit_repository.py:80-85) too. That query must also read the time zone and the hours. Plain message; hours shown on the New buttons. | M |
| Day and shift | New repository + service online_day_service: find or create under the lock; the header insert writes dayshift_id and offline_dayshift_id | M–L |
| Totals (D2 A) | One shared test is_live (closed_datetime IS NULL OR store_type = 'ONLINE') applied in: the day list and the export that shares it (business_day_repository.py:139-146,186,295,316); the shift list, which has no live branch today (:346-361); the day detail (business_day_service.py:665,679,700: the shift override, the register override that needs register_code, and the day totals); the shift detail (:817,912); the register sum (business_day_repository.py:847-876). The live read uses the sales-summary filters. Status column shows "Online day" in BusinessDayListTab.tsx:26-34, DayshiftTab.tsx:23-31, BusinessDayDetailPage.tsx:77-85, ShiftDetailPage.tsx:25-26, ShiftDayPage.tsx:51-52. The register row is not clickable. | M–L |
| Return completion | complete_return re-dates the return (redate_return); also move its day (D7) | S–M |
| Dating | Local day for doc_trx_date: the form default (SalesDocumentPage.tsx:202), the "not in the future" check and the stored time (sales_commit_service.py:129-157) — they use the UTC day today (D5) | M |
| Store guards | store_has_any_dayshift (store_repository.py:123; it also feeds has_business_day on the form) must ignore online days | S |
| Cash refund | Hide and refuse Cash on a BO return (D6) | S |
| Reports | The business date of a BO document is now real. Keep it blank on the document page or show it (section 5). | S |
| Open-day counts | Provisioning and the subscription mail count open days (tenant_provisioning_repository.py:175-225, subscription_scheduler.py:157); born-closed days are not counted | none (A) |
| Tests | Section 6 | M |
No change to the POS apps. If D2 option B is chosen add: the sweep (a loop over tenants and companies through tenant_pool_manager, safe to run twice, with catch-up after downtime), the totals build, and a refresh when a document changes after the close.
4. Other ways to handle it
These are variations on Option 1. Each is a decision in section 7.
- How the day rows are made and closed (D2).
- A. Born closed, live totals (recommended): described in 2.3 and 2.4. No job, no frozen totals, nothing to refresh. The work moves to the reading side (section 3, "Totals"). A variant that leaves
closed_datetimeempty on aCLOSEDrow would make the existing live paths fire without code, but it stores a contradiction (a closed day with no closing time); not proposed. - B. Opened on the first sale, closed by a sweep, totals frozen (the till way). The sweep closes the shift then the day, BO sets the shift totals itself, writes the payment lines as
CONFIRMED, sets the status, then calls the close function. A refresh means deleting the payment lines and running it again. Late issue, late payment and late completion each need a refresh. The sweep runs in the BO scheduler (asyncio loop with a file lock, 2 workers:main.py:58-70,startup.sh:93-108) or the API one (no lock). The rows areOPENEDduring the day, which blocks a store-code rename and a company currency change. - Where the hours live (D3). Reuse the day-end fields and add one start column (one migration). Or keep both times as keys in
store_settings_json(no migration, but a second place for hours;store_settings.py:17and its comment must change). - Outside the hours (D4). Hard block (your rule). Or block unless the user holds a new permission (a manager override): Toast and Square never limit staff.
- Back-dated documents (D5). Own day: the document goes on the day of its document date, and that past day is made if missing (born closed). Or no back-dating on online stores: the document date is always today.
- Cash refund (D6). Block it. Or keep it: a Cash refund is a payment row that the shift adds up as cash out; nobody counts the cash, so the expected cash of the online shift goes negative. Keeping it needs a rule for how the cash audit shows that (
build_cash_audit_figures,business_day_service.py:293). - A completed return (D7). Moves to the completion day (as its date already does today). Or stop re-dating, so it stays on the day it was created.
- Days with no sales (D8). No row. Or a row every day, which needs a daily job and adds about 730 rows a year for each online store to the lists.
5. Risks and things this changes
| Risk | Note |
|---|---|
store_has_any_dayshift would lock the store type for every online store. | It blocks a store-type change once any ft_dayshift row exists (store_repository.py:123, store_service.py:355-364) and feeds has_business_day on the form (store_service.py:167). Make it ignore rows with no register. |
| Existing online stores. | The form defaults already give them a day end of 23:59. The window turns on only when Opens at is filled in, so none changes when this ships. |
| Business date shown on the document page. | Owner decision 2 Oct 2026: a BO document shows no business date. Now it has one. Keep it blank (nothing changes) or show it. |
| Existing BO documents have no day. | They keep dayshift_id empty. Reports use the document date for them, as today. A back-fill is possible but not proposed. |
| POS load and download. | The POS loads each store's open day and the latest closed day (dayshift_query.py:132-186) and downloads by day or shift (sales_query.py:5222-5257). An online store has no POS, so nothing changes. If a POS is ever registered on an online store it would see these days and these sales. |
| Document numbers. | The API's sales-sequence query groups by sibling days (sales_query.py:73). Low risk with no POS on the store. |
| Partitions. | ft_dayshift has monthly partitions to Dec 2027, then a default partition. No job adds partitions. This risk already exists for the POS. |
| Time zone. | store.timezone is required but free text. A wrong name breaks AT TIME ZONE. Check it on save and fall back to the company zone. |
| Server clock vs. the document date. | The create form fills in the UTC date and the server builds the stored time from the UTC clock (SalesDocumentPage.tsx:202, sales_commit_service.py:129-157). For a store in Malaysia (UTC+8) a sale made between 00:00 and 08:00 goes on the previous day. The day-and-shift step needs the local day, so this is fixed as part of the work. Read from the code, not reproduced. |
| Rows per year. | About 365 days + 365 shifts for each online store with sales every day. Small. |
| Rule 12 (RBAC). | L1 page: no new page. L2 button: D4 option B adds one permission. L3 field: none. Store scope unchanged. Every new query and lock key carries company_id (rule 6). |
| Project rule 17 text is out of date. | It names ft_dayshift as a workflow table; the code and the CI script do not. Fix the text. |
6. Tests (written with the work)
- A sale inside the hours is saved on today's shift,
register_idempty. - On an 08:00–23:59 store a sale at 07:59 and at 00:00 is refused with the plain message; one at 08:00 and at 23:59 is accepted.
- A store with Opens at empty accepts sales at any hour, even when a day end is set.
- Create invoice, create order, create return and convert order are refused outside the hours; calculate, edit, submit, approve, issue, payment and complete are not.
- Two sales at the same moment make one day and one shift.
- A back-dated document goes on its own day; that day is made once (D5).
- A return created on Monday and completed on Wednesday ends on Wednesday's day (D7).
- The live totals of an online day count only COMPLETED / RETURNED invoices and returns; a draft, a rejected document and an order are not counted, and a converted order is not counted twice.
- The live totals of a closed online day change when a document on it is issued or paid later.
- The Business Day list, its export, the shift list, the day detail and the shift detail show the same live totals for an online day; the status column reads "Online day".
- Cash is not offered and not accepted on a BO return (D6).
- Changing the store type of an online store with online days is allowed; with a real till shift it is refused.
- The register row of a register-less shift is not clickable.
- Existing tests that assume
dayshift_idis empty for BO documents (test_sales_summary_repository.py, classTestBackOfficeSaleHasNoShift) still pass; update the comments insales_doc_semantics.py:124-145.
7. Decisions for the owner
| # | Question | Option A | Option B | My pick |
|---|---|---|---|---|
| D1 | Which option? | Option 1: hours + window + a day and shift per day. | Option 2: hours + window only, no day or shift rows (smaller). | A |
| D2 | How are the day rows made and closed? | Born closed on the first sale; totals read live from the sales. No job. | Opened on the first sale, closed by a sweep, totals frozen (the till way). More code. | A |
| D3 | Where do the hours live? | Existing day-end fields + one new start column (one migration). | Two keys in store_settings_json (no migration). | A |
| D4 | Outside the hours | Hard block for everyone. | Block, unless the user has a "create outside hours" permission. | A (your rule); B if staff must enter late sales |
| D5 | A back-dated document on an online store | Goes on its own day; that day is made if missing. | Not allowed: the date is always today. | A |
| D6 | Cash refund on a BO return | Block it. Cash is refunded at the till. | Keep it; the online shift shows cash out that nobody counts. | A |
| D7 | A return completed on a later day | Moves to the completion day (as its date already does). | Stays on its creation day (stop re-dating). | A |
| D8 | A day with no sales | No row. | A row every day (needs a daily job). | A |
8. Checked against the repository
| Claim | Result | Where |
|---|---|---|
ft_dayshift NOT NULL columns without default: type, company, store, business_date, opened_datetime, opened_by_user_id, currency | VERIFIED | tables_pg.sql:1317-1336 |
| Currency comes from the company; the store has no currency column | VERIFIED (reviewer) | tables_pg.sql:6934-6982 |
status default is 0, not a valid value | VERIFIED | tables_pg.sql:1335; field_options.py:383-389 |
register_id nullable; API model accepts empty | VERIFIED | tables_pg.sql:1323; dayshift_model.py:201,355 |
| No unique index, no trigger, monthly partitions to 2027-12 plus a default | VERIFIED | tables_pg.sql:1356,1398-1399,7637-7647 |
ft_dayshift is not a workflow table | VERIFIED | workflow_service.py:35-43; check_workflow_registry.py:87 |
SYS001 is seeded for new companies and back-filled for existing ones | VERIFIED in code; live row not read | company_seed.py:71-77; migration 20260302_0300 |
Close function needs the day already CLOSED, adds only CONFIRMED lines, returns early if lines exist; set-payment inserts without status | VERIFIED (two reviewers) | functions_pg.sql:891-1172,957,961,6194 |
| The shift rollup counts only final-status documents | VERIFIED (reviewer) | set_dayshift_payment, XYZ section |
| The shift functions and view run clean for a register-less shift | VERIFIED by reading, nothing was run | functions_pg.sql:1772,2708,6194; views_pg.sql:9 |
| Open-day list counts every non-voided sale live; closed days use stored totals | VERIFIED | business_day_repository.py:153-189 |
_resolve_store is used by calculate, coupon check and draft replace; create_return does not call it | VERIFIED (reviewer) | sales_commit_service.py:782,2098,2754; sales_document_write_service.py:118 |
| Create exchange is a stub | VERIFIED (reviewer) | sales_commit_service.py:2719-2740 |
complete_return re-dates the return to now | VERIFIED (reviewer) | sales_commit_service.py:2637-2640 |
Convert order to invoice uses now | VERIFIED | sales_order_write_service.py:783,803 |
| Store guards on type change and code rename | VERIFIED | store_repository.py:103,123; store_service.py:355-364 |
business_dayend_time, force_business2close, business_hours exist; no open-time column; store form defaults to force on + 23:59 | VERIFIED | tables_pg.sql:6956-6968; StoreFormPage.tsx:118-119,148-149,208 |
store.timezone is text NOT NULL, not checked as a real name | VERIFIED | tables_pg.sql:6951; models/store.py:125,182 |
| BO store SQL uses explicit column lists | VERIFIED (reviewer) | store_repository.py:21,45,200-214,294-310 |
Lock form pg_advisory_xact_lock(hashtext(...)) exists in BO | VERIFIED (reviewer) | sales_document_repository.py:36 |
| BO scheduler uses a file lock, 2 workers; no scheduler touches dayshifts | VERIFIED | main.py:58-70; startup.sh:93-108; scheduler.py |
| BO can call tenant functions | VERIFIED feasible; database grants not checked | business_day_repository.py:1743 |
| Late payment and late issue change a closed day under frozen totals | Reasoned from the functions above; not run | — |
| A Cash refund on a BO return is a payment row that the shift adds up | VERIFIED by reading (reviewer); nothing was run | sales_commit_service.py:1747-1760; rollup base_payment_data |
| BO sales can take Cash on create | CONFLICT: the create path refuses Cash (sales_commit_service.py:622-626) but the change-due code at :688-712 assumes a cash tender. Not resolved; does not change this proposal because Cash is blocked on returns. | — |
| Nothing checks a closed day when a sale is saved | UNVERIFIED (grep only) | v2-sellable-api/src/modules/sales |
| Export, dashboard, RBAC and store scope on the business-day pages | UNVERIFIED | not checked |
9. Sources
- Repository: files and lines in sections 2, 3, 5 and 8.
- External (15 Oct 2026): Toast platform guide (online ordering hours, scheduled orders, business day cut-off); Square Online and Square close-of-day help; Shopify local pickup help; Microsoft Learn Dynamics 365 Commerce (store hours, pickup time slots, trickle feed); Odoo POS self-ordering (forum) and third-party add-ons; Grab merchant blog; Lightspeed Bopple help; foodpanda via a partner knowledge base. Pages that did not load are marked UNVERIFIED in the research notes; Olo has no public documentation.
- Review: Draft 1 was checked by a red-team critic (REVISE) and a database/API verifier (GAPS); Draft 2 was checked once more (REVISE: two high findings on the totals design), and those are fixed here. No third review was run.