Skip to content

Business rules

Two kinds of rule live here. Enforced rules are in the code and covered by tests. Accepted rules were decided in BRV-02 (13 September 2026, evidence in improvement-plans/BRV-02-Business-Contract.md) and become enforced when the named ticket lands. Every change to this page happens in the same pull request as the behaviour it describes.

Rule Where
Every business record belongs to the host tenant. Queries are scoped automatically, cross-tenant identifiers return 404, and a submitted workspace_id is overwritten. BelongsToWorkspace, IdentifyTenant, tenant boundary tests
Foreign keys in requests must exist within the tenant. TenantRules::existsInTenant()
Authorization answers who may act; policies check tenant alignment first, then role. See Roles and permissions. Policies, gates, form requests, role matrix test
Purchase cost and procurement prices are visible to managers and above only (ADR-001). Hidden model attributes, revealCost(), revealSupplyCost()
Viewers see masked customer contact details. Order::maskPii()
A sole workspace owner cannot delete their account (ADR-002). User::workspacesBlockingDeletion()
Custom bouquets never appear in public lists. Bouquet::catalogue()
Order and cart items snapshot the product name and unit price at the time. order_items, cart_items
Stock is consumed first-expiring-first-out, nulls last, then by creation date. Flower::consumeStock()
Search is case-insensitive on every column that is searchable. whereLike(..., caseSensitive: false)
The trial is 14 days from workspace creation. Nothing yet restricts an expired workspace. Workspace::isOnTrial()
Reserved subdomains: www, api, admin, app, mail, dashboard, login, register. Onboarding validation
Order totals are exact and server-authoritative: subtotal is the sum of (agreed unit price + packaging price) times quantities, total is subtotal plus delivery fee, tax and discount are held at zero. Browser-submitted totals, line prices and currency are rejected. OrderTotals, Money, StoreOrderRequest / UpdateOrderRequest (BRV-03)
Every order snapshots the workspace currency at creation; carts are created in the workspace currency; amounts use the currency’s own exponent (JPY has none). orders.currency, Workspace::currency(), Currency (BRV-03)
The workspace currency is locked once any priced record exists: orders, flowers, bouquets, packagings, supplies or expenses, soft-deleted ones included. Changing it in an empty workspace expires active carts priced in the old currency. Catalogue, packaging and supply prices are validated in the workspace currency (no more decimals than it allows, not negative). New workspaces store UAH and Europe/Kyiv, and the interface always receives the effective currency. UpdateWorkspaceSettingsRequest, TenantRules::moneyInTenant() (BRV-03, B12; S2-F01)
Amounts are recomputed only while an order is amendable (pending or confirmed). Frozen orders keep the amounts they were agreed at; a read-only report lists legacy totals. Order::isAmendable(), orders:report-totals (BRV-03, ADR-010)
Every order has numbered, immutable revisions holding the agreed header, items and each item’s flower composition with names, SKUs and estimated costs at that moment. Creation writes revision 1; a change to the agreed content of an amendable order writes the next one with actor and reason. Catalogue edits never touch a revision. OrderRevisions, order_revisions* tables (BRV-04, B11)
Every order line is written by OrderCreator, for staff orders and storefront checkout alike. Prices come from the catalogue, never from the caller. A line that an amendment leaves on the same product and packaging keeps the unit price, packaging and name it was agreed at, even after a quantity change; a new or changed line is priced from the catalogue as it stands. OrderCreator (Stage 3A, B11, B15)
Edits carry the order’s version; an edit based on an older version is rejected naming who changed it and when, with no side effects. UpdateOrderRequest (BRV-04)
A frozen order (in progress or later) rejects new items; notes and payment fields still change. UpdateOrderRequest (BRV-04, ADR-010)
Flowers and bouquets that recorded orders depend on cannot be deleted; deactivate them instead. Flower::isReferencedByOrders(), Bouquet::isReferencedByOrders() (BRV-04)
Stock changes only through the inventory ledger: an append-only movement per action, projections updated in the same transaction, on-hand ≥ reserved ≥ 0 after every action, repeated actions applied once (ADR-011). InventoryLedger (BRV-05)
Batches are never hard-deleted; removal reverses the receipt and is refused once stems are reserved or consumed. A batch cannot move to another flower. Receipt corrections cannot go below what is reserved. SupplyItem hooks, SupplyController (BRV-05)
Pending orders reserve nothing; creating, editing, cancelling or deleting a pending order leaves stock unchanged. OrderController (BRV-05, B01)
Stock is recorded from supplies only; the flower form shows stock read-only. FlowerController (BRV-05)
Legacy stock without a supply record is kept as a review batch with unknown cost and expiry and is never allocated automatically. inventory:backfill-opening-balances (BRV-05, B06, B13)
Confirming an order reserves its complete flower composition, FEFO, from batches eligible for the fulfilment cutoff, in one transaction; if any flower is short the whole confirmation is rejected with the per-flower shortage and nothing is reserved (B01, B02, B04). OrderReservations::reserve() (BRV-06)
A batch is eligible when it is active, has a known expiry, is not expired today, lasts through the order’s cutoff and has free stems (B05, B06). Availability (BRV-06)
A batch’s freshness label is judged once, on the server: expired; “expiring soon” in the last 30% of the flower’s shelf life (at least two days); otherwise fresh. SupplyItem appends freshness_status and days_until_expiration, and the flower, supply, supplies list and supplier pages all read them. Until 26 October 2026 three pages used a fixed seven-day rule of their own and disagreed with the flower page. SupplyItem::getFreshnessStatus() (#98)
Cancelling a confirmed order releases its reservations exactly once (B07). OrderReservations::release() via OrderStatusChanger (BRV-06)
A repeated confirmation applies once; an Idempotency-Key header with a different request under the same key is refused. Competing confirmations for the same stems yield exactly one winner. idempotency_keys, row locks flower→batch, deadlock retry (BRV-06; race suite)
The storefront’s availability is advisory (free eligible stems for today) and shown as a label (available, few_left, sold_out), never as a stock count. Viewing or adding to a cart reserves nothing; the cart refuses quantities the stems cannot cover right now. StorefrontAvailability, Availability::availableToPromiseMany() (BRV-06; storefront platform phase A)
Only adjacent transitions are legal for every role; an illegal move is refused naming the next actions and changes nothing. Both order routes use the same path (B10). OrderTransitions, OrderStatusChanger (BRV-07)
Starting assembly rechecks every reservation, then consumes it exactly once with the batch’s unit cost; a reservation that is no longer suitable blocks assembly and consumes nothing (B03, B14). A confirmed order without reservations is refused; only a legacy one may be reserved, explicitly (S2-F02). OrderConsumption, Order::isLegacyConfirmed() (BRV-07)
Cancelling after assembly keeps the consumed stems consumed and their cost on the order; cancelling before releases (B07, B08, ADR-009). OrderStatusChanger (BRV-07)
Every status change is recorded with actor, reason, version and action key; a repeated change under the same idempotency key applies once; payment status is never changed by a stock transition. order_transitions, IdempotencyKeys (BRV-07)
Amending a confirmed order records the new revision and re-reserves the whole new requirement in the same transaction; if stock or date eligibility is insufficient the amendment is refused with the shortage and the previous revision and reservations stand untouched. A delivery date, time or slot change is treated exactly like a composition change (B09). OrderReservations::reallocate() via the order update route (BRV-08)
Edits are serialized with assembly and cancellation: the update locks the order row and re-checks the version under the lock, so two edits or an edit and an assembly based on the same state cannot both succeed. OrderController::update (BRV-08; race suite)
Once assembly has started, items, delivery date, time, slot and fee are all frozen; notes and payment fields still change (ADR-010). UpdateOrderRequest (BRV-08)
An order with reservation or status history cannot be deleted; it is cancelled. Repeating a cancellation changes nothing. Stem recovery after cancelling an assembled order is not offered in this release (ADR-009). OrderController::destroy, OrderStatusChanger (BRV-08)
Spoilage and returns are explicit, reasoned ledger movements (write-off, supplier return) per batch, never below what is reserved; the operator is told to amend or cancel the holding orders first. A repeated request under the same key applies once. SupplyItemController::writeOff, inventory:write-off (BRV-09)
A batch’s expiry or status may not be changed in a way that strands existing reservations; the edit is refused naming the affected orders. Quarantine and review states are set on the batch. SupplyController::update (BRV-09)
Cost corrections never rewrite consumed orders: allocations keep the unit cost recorded at consumption. A receipt correction cannot take a batch below its consumed plus reserved stems; a batch is received exactly once. ledger invariants, allocation snapshots (BRV-09)
Expiry is derived from the clock: a reservation on a batch that has passed its expiry blocks assembly at consumption time regardless of any scheduler. OrderConsumption (BRV-07/09)
Server-side messages follow the interface language: the front end mirrors its language into the locale cookie and X-Locale header; every __() string has a Ukrainian translation, checked by a unit test. Ukrainian is the default language when nobody has chosen one (APP_LOCALE=uk, the control panel and storefronts too, since 6 October 2026). SetLocale, lang/uk.json, TranslationScanner (BRV-09)
Material cost is either the estimate (revision components × their cost at revision time, plus known packaging cost per item) or, once assembly has consumed stems, the actual (consumed allocations × the batch cost recorded at consumption). A missing component cost makes the figure unknown, never zero (B13, B15). Later catalogue or supplier price edits change neither. OrderCosting, CostFigure (BRV-10)
Material profit = merchandise revenue − material cost; material margin = profit ÷ revenue; markup = profit ÷ cost. Merchandise revenue excludes the delivery fee. Zero revenue leaves margin undefined and zero cost leaves markup undefined; both show as a dash. Labelled as material figures, never as net profit. MaterialMetrics (BRV-10)
A cancelled order that was assembled keeps its consumed cost as a material loss with zero revenue. OrderCosting::summary() (BRV-10)
Custom bouquets are costed from their components like any bouquet; a bouquet with a component lacking cost shows “unknown” and lists the missing flowers. Bouquet pages show material margin and markup, correctly named. Bouquet::calculateCost(), lib/costing.ts (BRV-10)
Costing and margins are visible to managers and above only. OrderController::show gate (BRV-10, D2)
Reconciliation is read-only and tenant-explicit. It separates corruption (movements versus batch and flower figures, allocations versus reservations, orders versus their revisions, invariants), which fails the run, from operational exceptions (reserved stems on unusable batches, review or unknown-cost batches, legacy confirmed orders awaiting their first reservation, orders needing reallocation); a non-legacy confirmed order without allocations is corruption, which are reported. No repair command exists; corrections are compensating movements. Reconciliation, inventory:reconcile (BRV-11)
A confirmed order whose reservations can no longer be fulfilled is visibly flagged on its page and on the Attention page, assembly stays blocked, and the resolution is explicit: amend the order, press “Reserve again from eligible batches”, or cancel it. ReservationHealth, OrderController::reallocate (BRV-11)

The BRV-01 baseline (12 September 2026) traced every stock and money writer; the full picture with reproductions is on Stock and money today. In short:

  • Order totals omit the delivery fee (D-01) fixed in BRV-03.
  • Orders change the flower total but never batches; editing items changes neither; deleting a cancelled order restores stock twice D-02 and D-03 fixed in BRV-05 (pending orders touch no stock); D-04, consumption at assembly, arrives with BRV-07.
  • The flower form and supply edits can set stock detached from receipts and consumption fixed in BRV-05.
  • Custom bouquets report a material cost of zero fixed in BRV-10. The stock and money rules of the contract are enforced; BRV-11 adds reconciliation views and BRV-12 the end-to-end closeout.
  • Custom bouquets report a material cost of zero rather than unknown (D-07) fixed in BRV-10.
  • Cart currency is hard-coded and orders carry none (D-08) fixed in BRV-03.
  • Status changes set a field; they carry no business effect (D-09, BRV-07).
  • The login cart merge is a no-op (ADR-005).

Stage 2 contract (accepted 13 September 2026)

Section titled “Stage 2 contract (accepted 13 September 2026)”
ID Topic Rule Enforced by
B01 Pending orders and carts Neither reserves stock. A pending order may describe a request awaiting stock; the UI must not imply a fulfilment commitment. enforced (BRV-06)
B02 Confirmation Reserve the entire required composition atomically from eligible batches. Insufficient stock leaves the order pending with a shortage explanation; no partial reservation survives. enforced (BRV-06)
B03 Consumption Consume reserved stems when assembly starts (confirmed to in_progress). ready and delivered do not deduct again. enforced (BRV-07)
B04 Future delivery Reserve only batches valid through the scheduled delivery or collection cutoff. Orders needing future procurement stay pending (ADR-008). enforced (BRV-06)
B05 Date and expiry Use the workspace’s IANA timezone, default Europe/Kyiv. A date-only expiry is usable through that local date. A missing delivery time means the end of the promised date. enforced (BRV-06)
B06 Unknown expiry Derive expiry only from trusted receipt date and shelf life; otherwise flag the batch and exclude it from automatic allocation while keeping it on hand. enforced (BRV-06)
B07 Cancellation before assembly Release reservations; physical stock and consumed cost unchanged. enforced (BRV-06)
B08 Cancellation after assembly Do not return stems automatically. Consumed cost stays as the order’s loss. Recovery is specified but disabled in the first release (ADR-009). enforced (BRV-07)
B09 Order edits Pending orders are editable. Confirmed-order edits reprice and reallocate atomically. Once assembly starts, composition, price and cutoff are frozen; notes, payment status and legal status moves remain (ADR-010). enforced (BRV-08)
B10 State transitions Legal adjacent transitions are enforced for every role. No manager permission bypasses stock or history rules. enforced (BRV-07)
B11 Historical records Ordered composition, agreed prices, packaging, currency and revisions are retained. Catalogue changes never reprice an existing order. BRV-04; staff edits since Stage 3A (see below)
B12 Currency Snapshot currency on every order and cart. No FX conversion. Block workspace currency changes after monetary records exist. BRV-03
B13 Historical costing Known allocations use their recorded costs; unavailable historical cost is unknown, not zero. enforced (BRV-10)
B14 Reservations over time No automatic timeout on confirmed reservations. Eligibility is rechecked at consumption; expiry or quarantine requires explicit reallocation, never silent overselling. enforced (BRV-07)
B15 Packaging Snapshot sale price and known cost; no packaging stock counts. Cost completeness states whether packaging or labour is excluded. enforced (BRV-10); order lines snapshot packaging since Stage 3A

Money contract (enforced since BRV-03, 13 September 2026)

Section titled “Money contract (enforced since BRV-03, 13 September 2026)”
  • All arithmetic happens in App\Support\Money\Money: integer minor units in one currency, strict parsing (no more decimals than the currency allows, no silent rounding, range of the database column), no mixing of currencies.
  • The currency exponent comes from Currency, never a constant: JPY and KRW have no minor unit, everything else offered has two.
  • OrderTotals: subtotal is the sum of (agreed unit price + packaging price) times quantities; total is subtotal plus delivery fee; tax and discount are zero. The staff order form and the storefront cart both derive from it.
  • The server computes totals; subtotal_amount, total_amount, tax_amount, discount_amount, currency and item prices are prohibited in requests. A delivery fee must be a non-negative amount valid in the workspace currency.
  • Every order snapshots its currency at creation. Carts are created in the workspace currency. The workspace currency locks once any priced record exists (until 4 October 2026 only orders counted, so a priced catalogue could be reinterpreted; S2-F01).
  • Packaging is counted exactly once. Staff orders usually carry it inside the bouquet price. A storefront order snapshots it on the line (packaging_id, packaging_name, packaging_price, packaging_cost) and charges it per item, as the cart does: 2 × (200 + 30) + 100 delivery = 560 (G1).
  • Packaging has no cost field yet, so a packaged line’s known cost is empty and the material estimate reports the packaging as missing rather than as zero. A packaging cost field is a follow-up.
  • Found 5 October 2026 (Stage 3A PR 3): saving the staff edit form deleted every line and recreated it at the current catalogue price, so a header-only edit after a catalogue price change silently repriced the order (against B11). OrderCreator now keeps unchanged lines at their agreed price; tests/Feature/OrderPackagingTest.php covers it.
  • Found 5 October 2026 (Stage 3A PR 3): order numbers were counted per shop but unique across all shops, so the second shop to take an order on any day failed with a database error, which the staff form then showed verbatim; a deleted order’s number was also handed out again. Numbers are now unique per shop, deleted orders count, numbering locks the shop row inside the creating transaction, the date in the number is the shop’s business date (it was the server’s UTC date, so a Kyiv order after midnight carried yesterday’s date), and the order form shows only domain messages (tests/Feature/OrderNumberTest.php).
  • Custom bouquets are priced as the exact sum of their components.
  • Catalogue price fields still accept two decimals in every currency; a JPY workspace must enter whole numbers or order creation reports an invalid amount. Currency-aware catalogue validation is a follow-up.
  • Each workspace has an IANA timezone setting, default Europe/Kyiv. Backend and frontend use it for every business date; the application’s PHP timezone stays UTC and the browser’s zone is never used for business dates.
  • The timezone cannot change while any order is confirmed, in progress or ready: those delivery dates and the expiry of their reserved batches were judged in the current zone and are never silently reinterpreted. Deliver or cancel them first (S2-F03).
  • A reservation stays healthy only while its batch lasts through the order’s promised delivery, not merely today. Assembly, the order page, the Attention page and reconciliation share this check (ReservationHealth), so a batch that is fresh today but expires before delivery blocks assembly on every surface (S2-F03; until 4 October 2026 the check compared expiry with today only).
  • A batch expiry is a calendar date in the workspace zone, usable through the end of that day. The exclusive cutoff is the start of the next local day.
  • An order’s fulfilment cutoff is the delivery date plus the specific time if given, otherwise the end of the chosen slot (morning 12:00, afternoon 17:00, evening 21:00), otherwise the end of the delivery day.
  • A batch is eligible for an order when the order’s cutoff is before the batch’s cutoff, the batch is not expired now, not quarantined, and has free stems.
Situation Message and effect
Confirm with insufficient stock Lists every short flower with required and available quantities. Order stays pending; nothing partially reserved
Confirm for a date beyond all batches’ expiry Same shortage message, with the date each flower is usable by
Amend a confirmed order beyond available stock Rejected with the shortage; the previous revision and its reservations stand
Stale form “This order was changed by at
Illegal transition Names the next available actions for the current state
Start assembly when a reserved batch expired “N reservations need reallocation before assembly”, linking to the amendment flow
Cancel after assembly The confirmation states stems are not returned and the material cost stays on the order
Change workspace currency with orders present “Currency is locked: N orders are recorded in .”