Business day for an online store — proposal and options

StatusProposal, 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 readRedy-20261005-Sales-Return
Scopeapps/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:

  1. 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).
  2. 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).
  3. 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).
  4. 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).
  5. 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).
  6. 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 designOption 2 — window onlyOption 3 — soft windowOption 4 — 24 hours
Store hoursOpen and close timeOpen and close timeOpen and close timeNone (empty = 24 hours)
Outside the hoursCannot createCannot createWarning, or allowed with a manager permissionAlways allowed
ft_dayshift day + shiftOne of each per dayNone. dayshift_id stays emptyOne of each per dayOne of each per day, cut at midnight
RegisterEmptyEmptyEmptyEmpty
BO sales in shift and business-day viewsYesNoYesYes
New workLargeSmallLargeLarge (same as Option 1, window off)
Matches the marketPartlyPartlyClosest — Toast says staff orders are never limited by online hoursYes
VerdictRecommendedFallback if Option 1 is too bigA 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):

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 happensft_dayshift
07:59A user tries to create a sale. Refused: "This online store is closed now..."nothing
09:12The first sale of the day is created inside the hoursINSERT 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 saleThe row is found.nothing
18:00A payment is added to a sale.nothing (totals are read live)
22:00An invoice is issued or a return completed.nothing
23:59The 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:00The first sale of 17 Oct.INSERT two new rows for 17 Oct
17 Oct 11:00A 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 timeA 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 saleNothing 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 happensft_dayshift
09:12The first sale of the dayINSERT two rows, status = OPENED, closed_datetime empty
during the daySales, paymentsnothing. The rows stay OPENED. Renaming the store code and changing the company currency are blocked while a row is open.
23:59The 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, thenUPDATE 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:00An 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 midnightThe next sweep run closes every day that is past its closing time.UPDATE, as above
A day with no saleNothing happens.no rows (or a daily job, D8)

Option 2 (window only)

TimeWhat happensft_dayshift
All dayThe 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 storeSourceNote
Opens atnew column business_daystart_time time (one migration)D3
Closes atexisting business_dayend_timethe 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 automaticallyexisting force_business2closenot needed with D2 option A; kept as it is
Time zoneexisting store.timezonerequired (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 pathWhereStore comes from
Create invoicesales_commit_service.py:~1057 (create_sale)_resolve_store
Create ordersales_order_write_service.py:~79_resolve_store
Create returnsales_commit_service.py:~2098the original sale's store
Convert order to invoicesales_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):

ColumnValue
dayshift_typeEOD for the day, SHIFT for the shift
company_id, store_idfrom the store
currency_iso_codefrom the company (the store has no currency column)
business_datethe calendar day in the store's time zone
opened_datetimethe opening time of that day
closed_datetime, statuswith 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_idthe 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_idon the SHIFT, the ids of the EOD. POS sync reads the offline one (sales_query.py:5225-5257).
register_idempty. 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:

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:

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

3. What changes in the code

AreaChangeSize
DatabaseOne 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 APIThe 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 checkassert_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 shiftNew repository + service online_day_service: find or create under the lock; the header insert writes dayshift_id and offline_dayshift_idM–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 completioncomplete_return re-dates the return (redate_return); also move its day (D7)S–M
DatingLocal 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 guardsstore_has_any_dayshift (store_repository.py:123; it also feeds has_business_day on the form) must ignore online daysS
Cash refundHide and refuse Cash on a BO return (D6)S
ReportsThe business date of a BO document is now real. Keep it blank on the document page or show it (section 5).S
Open-day countsProvisioning and the subscription mail count open days (tenant_provisioning_repository.py:175-225, subscription_scheduler.py:157); born-closed days are not countednone (A)
TestsSection 6M

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.

5. Risks and things this changes

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

7. Decisions for the owner

#QuestionOption AOption BMy pick
D1Which option?Option 1: hours + window + a day and shift per day.Option 2: hours + window only, no day or shift rows (smaller).A
D2How 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
D3Where do the hours live?Existing day-end fields + one new start column (one migration).Two keys in store_settings_json (no migration).A
D4Outside the hoursHard block for everyone.Block, unless the user has a "create outside hours" permission.A (your rule); B if staff must enter late sales
D5A back-dated document on an online storeGoes on its own day; that day is made if missing.Not allowed: the date is always today.A
D6Cash refund on a BO returnBlock it. Cash is refunded at the till.Keep it; the online shift shows cash out that nobody counts.A
D7A return completed on a later dayMoves to the completion day (as its date already does).Stays on its creation day (stop re-dating).A
D8A day with no salesNo row.A row every day (needs a daily job).A

8. Checked against the repository

ClaimResultWhere
ft_dayshift NOT NULL columns without default: type, company, store, business_date, opened_datetime, opened_by_user_id, currencyVERIFIEDtables_pg.sql:1317-1336
Currency comes from the company; the store has no currency columnVERIFIED (reviewer)tables_pg.sql:6934-6982
status default is 0, not a valid valueVERIFIEDtables_pg.sql:1335; field_options.py:383-389
register_id nullable; API model accepts emptyVERIFIEDtables_pg.sql:1323; dayshift_model.py:201,355
No unique index, no trigger, monthly partitions to 2027-12 plus a defaultVERIFIEDtables_pg.sql:1356,1398-1399,7637-7647
ft_dayshift is not a workflow tableVERIFIEDworkflow_service.py:35-43; check_workflow_registry.py:87
SYS001 is seeded for new companies and back-filled for existing onesVERIFIED in code; live row not readcompany_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 statusVERIFIED (two reviewers)functions_pg.sql:891-1172,957,961,6194
The shift rollup counts only final-status documentsVERIFIED (reviewer)set_dayshift_payment, XYZ section
The shift functions and view run clean for a register-less shiftVERIFIED by reading, nothing was runfunctions_pg.sql:1772,2708,6194; views_pg.sql:9
Open-day list counts every non-voided sale live; closed days use stored totalsVERIFIEDbusiness_day_repository.py:153-189
_resolve_store is used by calculate, coupon check and draft replace; create_return does not call itVERIFIED (reviewer)sales_commit_service.py:782,2098,2754; sales_document_write_service.py:118
Create exchange is a stubVERIFIED (reviewer)sales_commit_service.py:2719-2740
complete_return re-dates the return to nowVERIFIED (reviewer)sales_commit_service.py:2637-2640
Convert order to invoice uses nowVERIFIEDsales_order_write_service.py:783,803
Store guards on type change and code renameVERIFIEDstore_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:59VERIFIEDtables_pg.sql:6956-6968; StoreFormPage.tsx:118-119,148-149,208
store.timezone is text NOT NULL, not checked as a real nameVERIFIEDtables_pg.sql:6951; models/store.py:125,182
BO store SQL uses explicit column listsVERIFIED (reviewer)store_repository.py:21,45,200-214,294-310
Lock form pg_advisory_xact_lock(hashtext(...)) exists in BOVERIFIED (reviewer)sales_document_repository.py:36
BO scheduler uses a file lock, 2 workers; no scheduler touches dayshiftsVERIFIEDmain.py:58-70; startup.sh:93-108; scheduler.py
BO can call tenant functionsVERIFIED feasible; database grants not checkedbusiness_day_repository.py:1743
Late payment and late issue change a closed day under frozen totalsReasoned from the functions above; not run—
A Cash refund on a BO return is a payment row that the shift adds upVERIFIED by reading (reviewer); nothing was runsales_commit_service.py:1747-1760; rollup base_payment_data
BO sales can take Cash on createCONFLICT: 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 savedUNVERIFIED (grep only)v2-sellable-api/src/modules/sales
Export, dashboard, RBAC and store scope on the business-day pagesUNVERIFIEDnot checked

9. Sources