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