Order Lifecycle, Edit Permissions, and Receipt Re-emit¶
This page is the rules layer for the Order entity: which states an order moves through, who may perform each transition, what is reversible, what is editable in each state, and which edits trigger a receipt re-emit. Cross-cuts: V2 scope M10/M12/M13/M14, data model — Order, use cases — UC-1, UC-3, client identity & sharing. Architectural counterpart: ADR-0007 — Order lifecycle state machine (Theo, in parallel — link added on merge). Tracked in racket-book#80.
Scope of this page¶
- The state machine: which conceptual states exist for an Order and which transitions are legal.
- The actor matrix: who may perform each transition (stringer, admin, system, never).
- Reversibility per transition.
- The edit-permission matrix: which fields can be edited in each state, by whom.
- The price-edit policy after Strung: when prices remain editable and when they lock.
- The receipt re-emit triggers: which edits cause a new PDF email; which only refresh on next render.
- Self-job vs. client-job differences (per locked decision 3 — lifecycle dates blank-allowed for self-jobs).
- Hidden requirements (UI affordances the rules imply).
Out of scope: schema, validators, audit table columns (Theo / ADR-0007); UX surfaces (Mira); implementation (Pax).
State machine¶
The Order lifecycle is date-driven, not status-flag-driven: each lifecycle date column on the Order doubles as the state-transition record. A state is the conjunction of which dates are filled.
+ ordered_at + strung_at
┌──────┐ ────────────────────► ┌──────────┐ ──────────────► ┌────────┐
│Draft │ │ Ordered │ │ Strung │
└──────┘ └──────────┘ └───┬────┘
▲ │
│ (no client-job route │ + returned_at
│ to/from Draft — │ AND/OR
│ self-jobs only) │ + paid_at
│ ▼
┌────────────┐ ┌──────────┐
│ Self-job │ (drafts that may │ Returned │
│ shortcut │ skip directly to │ + Paid = │
└────────────┘ Strung; see below) │ "Done" │
└──────────┘
Conceptual state set:
| State | Defined as | Notes |
|---|---|---|
| Draft | No lifecycle dates set yet. | Newly-created order; not yet committed to client. |
| Ordered | ordered_at set; strung_at NULL. |
Client has placed the order; stringer hasn't strung yet. |
| Strung | strung_at set; both returned_at and paid_at may still be NULL. |
Receipt is final at this moment (M14). |
| Returned | strung_at AND returned_at set; paid_at may still be NULL. |
Racket is back with the player. |
| Paid | strung_at AND paid_at set; returned_at may still be NULL (client paid before pickup). |
Payment received. |
| Done | strung_at AND returned_at AND paid_at all set. |
Job fully complete. Derived state — no separate flag. |
Self-job specialization (locked decision 3): for orders where client_profile.is_stringer_self = TRUE, the typical lifecycle is Draft → Strung directly; ordered_at, returned_at, and paid_at are normally left NULL forever. The state machine permits this: every transition into Strung is legal regardless of whether ordered_at is set, for self-jobs only. For client-jobs, the form should default ordered_at from order creation and require it at Strung-time (with a stringer-override path; see "Edge cases" below).
Transition table¶
| # | Transition | Trigger | Actor allowed | Reversible? | Audit |
|---|---|---|---|---|---|
| T1 | Draft → Ordered | Stringer fills ordered_at (often defaults to today on create-and-save). |
Stringer (any), Admin | Yes — clear ordered_at returns to Draft. |
Audit row: actor + timestamp + transition. |
| T2 | Ordered → Strung | Stringer fills strung_at. Receipt is rendered + emailed (M14). |
Stringer (owner only), Admin (override) | Yes via T2-r below. | Audit row + receipt-emit log entry. |
| T2-r | Strung → Ordered | Stringer clears strung_at. Implicit: receipt was wrong — see "Receipt re-emit" below. |
Stringer (owner only), Admin (override) | Yes (re-set strung_at). |
Audit row marking the un-Strung event. Subsequent re-Strung is a separate audit row + a new receipt-emit. |
| T3 | Strung → Returned | Stringer fills returned_at. |
Stringer (owner only), Admin | Yes — clear returned_at. |
Audit row. |
| T4 | Strung → Paid | Stringer toggles paid_at (M12 manual paid-date toggle). |
Stringer (owner only), Admin | Yes — clear paid_at. |
Audit row. |
| T5 | Returned → Done | Stringer fills paid_at. |
Stringer (owner only), Admin | Yes via T4-r. | Audit row. |
| T6 | Paid → Done | Stringer fills returned_at. |
Stringer (owner only), Admin | Yes via T3-r. | Audit row. |
| T-DEL | Any → Deleted | Stringer or Admin deletes the order. | Admin only if order is past Strung (preserves audit + receipt history); Stringer can hard-delete a Draft they own; Stringer-with-admin-confirm for Ordered. | Soft-delete preferred (sets deleted_at); hard-delete is admin-only. |
Audit row; soft-delete leaves the row intact for FADP/audit; hard-delete is logged with reason. |
Causal-date invariant (V1's implicit rule, now enforced by V2; see data model — Order):
returned_at and paid_at may be in either order (client may pay before pickup or after); both must be ≥ strung_at if set.
Date unset = state regression. Clearing a lifecycle date moves the order back to its prior state. This is allowed but loud: every regression is audit-logged with actor + reason (free-text required from the stringer when undoing past Strung).
Actor model¶
| Role | What they may transition |
|---|---|
| Stringer (owner) | All forward transitions on orders they own (order.stringer_id = :me). All reversals on orders they own, with audit (free-text reason required for un-Strung). |
| Admin (Stefan) | Same as a stringer-owner on any order, plus hard-delete (T-DEL). The admin-bypass is logged in share_audit with actor_kind = admin so it is auditable. |
| Shared-in stringer (via Rule 1/2/3 — see client identity & sharing) | No transitions. Read-only forever. Re-asserted from ADR-0004's write-protection invariant. |
| System | None. State changes are always actor-driven; the system does not auto-progress orders. |
The auth-and-edit chokepoint enforces this: the same SQLAlchemy session-event hook from ADR-0004 refuses any UPDATE on orders where stringer_id != :me AND :me is not admin. Lifecycle-date writes are subject to the same gate.
Edit-permission matrix (per state, by field)¶
| Field group | Draft | Ordered | Strung | Returned | Paid | Done |
|---|---|---|---|---|---|---|
ClientProfile FK (client_profile_id) |
Editable (owner) | Editable (owner) | Locked — admin override | Locked — admin override | Locked — admin override | Locked — admin override |
Racket FK (racket_id) |
Editable (owner) | Editable (owner) | Locked — admin override | Locked — admin override | Locked — admin override | Locked — admin override |
| Main string spec (id/text/tension/byo/color) | Editable (owner) | Editable (owner) | Editable (owner; triggers receipt re-emit) | Editable (owner; receipt re-emit) | Editable (owner; receipt re-emit) | Editable (owner; receipt re-emit) |
| Cross string spec | Editable (owner) | Editable (owner) | Editable (owner; triggers receipt re-emit) | Editable (owner; receipt re-emit) | Editable (owner; receipt re-emit) | Editable (owner; receipt re-emit) |
Pricing — labor, main_price, cross_price |
Editable (owner) | Editable (owner) | Editable (owner) — every edit re-emits the receipt; old receipt version retained for audit | Editable (owner) — every edit re-emits | Editable (owner) — every edit re-emits | Editable (owner) — every edit re-emits |
Lifecycle dates (ordered_at, strung_at, returned_at, paid_at) |
Settable (owner) | Editable (owner) | Editable (owner) — clearing strung_at triggers state regression and audit |
Editable (owner) | Editable (owner) | Editable (owner) — date-clearing audited |
Optional self-fields (main_color, cross_color, method, dynamic_tension_after) |
Editable (owner) | Editable (owner) | Editable (owner) | Editable (owner) | Editable (owner) | Editable (owner) |
comments (free text) |
Editable (owner) | Editable (owner) | Editable (owner) | Editable (owner) | Editable (owner) | Editable (owner) |
Conventions:
- Editable (owner) = the order's stringer (or admin) may change it freely; saved with audit.
- Locked = the field cannot be edited via the normal stringer UI in this state; an admin-only override path exists (and is audit-logged with reason).
- "Admin override" is rare-by-design (the platform expects most orders to mature past Strung without identity edits); when used, the override is logged in
share_auditwithactor_kind = admin, the field name, the before/after values, and a free-text reason supplied by the admin.
Why ClientProfile and Racket lock at Strung — once a receipt has been emitted, the client and the racket on the receipt are an identity-statement that the receipt-recipient relied on. Changing the ClientProfile after Strung would silently re-attribute the work and break the audit chain; it is admin-only by design. The racket lock follows the same rationale (the receipt names a specific racket).
Why pricing has its own rule — see Price-edit policy after Strung below.
Price-edit policy after Strung¶
Decision (Stefan, 2026-05-04 — closes OQ-L-1 with option (a)):
Price fields (
labor,main_price,cross_price, derivedtotal) are always editable by the owning stringer in any post-Draft state, including afterpaid_atis set. Every saved edit re-emits the receipt; the old receipt version is retained in the receipt-emit log for audit. There is no "lock atpaid_at" gate.
This is option (a) from the issue's open question. Rationale:
- The receipt is "final at Strung" in the sense that it has been delivered (printed + emailed per M14) — but the platform also commits to "always re-generatable from order data" (M13, ADR-0002 R5). The platform's truth is the order row, not the delivered PDF; corrections to the order row must propagate.
- Stringers in practice discover small price errors ("forgot the labor surcharge", "BYO wasn't supposed to count", "billed CHF 30 instead of CHF 32") at any point in the order's life — sometimes after
paid_at. Routing those through an admin override path is paperwork-only friction (Stefan is the admin on the V2 instance; the override would be him asking himself). - Re-emitting the receipt on every edit keeps the customer-facing record honest: if a price was wrong on the originally-emailed PDF, the customer gets a corrected one. The old receipt version is retained in
Order.receipt_emit_logso the audit chain shows which numbers were stated when.
Old receipt version retention (audit-relevant). Each receipt emit (initial, auto re-emit, manual re-emit, admin re-emit) writes a row to Order.receipt_emit_log (see Receipt re-emit triggers below). For audit purposes, every emit row carries a snapshot of the receipt-affecting fields at emit time (pricing fields, string-spec fields, racket FK, ClientProfile FK, business-identity fields, locale used) so the historical state of any past-emitted receipt can be reconstructed. The current PDF is always re-renderable from the live order; the snapshot lets us answer "what did the customer's emailed PDF say in May?" without keeping the binary on disk. Schema details are Theo's call (ADR-0007 amendment); the requirement here is that the historical fields are recoverable per emit event.
Alternatives considered:
- (b) Editable until
paid_at, then locked with admin override. Rejected: the only admin on the V2 instance is the owning stringer, so the lock generates self-paperwork. The post-paid_atcorrection case is real and not adversarial. - (c) Locked at receipt emit (Strung). Rejected: too tight; rules out small post-Strung corrections that stringers routinely need.
No admin-override mechanics needed for pricing. The owner-edit path is the only path; admin and owner are the same actor for pricing edits in V2. The general "admin override" affordance still exists for ClientProfile FK and Racket FK changes after Strung (the identity-changing edits described in the edit-permission matrix).
Receipt re-emit triggers¶
The receipt is always regeneratable from the order row (M13, ADR-0002 R5) — every render is data-driven, no PDF cached on disk. That means regeneration on demand is silent and free: a stringer or client viewing the receipt always sees the current order data.
A re-emit is different: it means a new PDF is rendered AND re-emailed to the client (and made available for re-printing). Re-emit is an event with customer-visible side effects, so it must be triggered explicitly or by a closed list of edits.
Triggers that cause an automatic re-emit¶
When the order is in state Strung or later and any of the following fields are edited and saved, RBO automatically re-renders + re-emails the receipt to the ClientProfile's Person email (if email is non-null):
- Pricing:
labor,main_price,cross_price, derivedtotal. - String spec:
main_string_id/main_string_text/main_tension/main_byo/main_color; same for cross. - Racket FK (admin override path only — see edit-permission matrix).
- ClientProfile FK (admin override path only).
- Stringer business-identity fields if they are edited via the stringer profile (these affect every receipt). Auto-re-emit is per-order on demand only, not bulk; a stringer-profile change does NOT silently re-email all past receipts. The stringer profile UI surfaces a "regenerate affected receipts" button (Mira to design) that selects orders and triggers per-order re-emit; this is out of scope for the V2 first cut and flagged as a follow-up requirement.
Triggers that DO cause re-emit (additions per OQ-R-1, OQ-R-4 flips)¶
commentsfield changes — per OQ-R-1 (closed with YES, see receipt-content),Order.commentsIS on the receipt for client-bound emits. Edits tocommentstherefore trigger an automatic re-emit on Strung+ orders. Caveat (Rule #1 redaction): when a Rule #1 grantee (a receiving stringer) views or downloads the receipt, the comments band is redacted server-side — it never reaches the receiving stringer's PDF. The auto-re-emit goes only to the client (ClientProfile.person.email); a Rule #1 grantee's view is regenerated on demand without comments.ordered_atedits — per OQ-R-4 (closed: receipt shows onlyordered_at+strung_at),ordered_atis now load-bearing on the receipt face. Edits toordered_aton Strung+ orders trigger re-emit.
Triggers that do NOT cause an automatic re-emit¶
returned_atandpaid_atedits — per OQ-R-4, these are never shown on the receipt (admin-tracking-only, not customer-facing). Their edits do not re-emit.- Optional self-fields (
method,dynamic_tension_after) — these are stringer-internal, not on the receipt.
Manual re-emit¶
A "re-send receipt" button is always available on the order page (matches docs/architecture/receipts.md "Email delivery" section). The stringer can re-send the current-state receipt at any time — typical use is "client lost the email, please resend." This is a manual trigger; no audit reason required (it is non-mutating).
Un-Strung (T2-r) re-emit¶
Clearing strung_at (un-Strung) does NOT auto-re-email. The receipt is conceptually invalidated for that moment — but the platform does not silently push an "ignore the previous receipt" email (that is more confusing than helpful for the client). Re-Strung (T2 again) emits a fresh receipt; the audit log records both transitions. The receiving stringer-profile UI flags un-Strung-then-re-Strung orders with a "re-emitted" badge so the stringer knows to inform the client if needed.
Re-emit log¶
Every re-emit (auto or manual) writes a row to Order.receipt_emit_log (or equivalent — schema is Theo's call):
| Field | Notes |
|---|---|
emit_at |
Timestamp. |
kind |
Enum: initial (T2 first emit) | auto_re_emit (field edit triggered) | manual_re_emit (button) | admin_re_emit (admin override). |
triggered_by_stringer_id |
The actor. |
email_to |
Snapshot of the email address sent to (in case Person.email later changes). |
delivery_status |
Enum: pending | sent | bounced | failed. |
This log is per-order; it is referenced from the order page (a small "history" toggle showing "Receipt sent 3 times — last 2026-05-04 14:23"). Mira owns the surface design.
Self-job vs. client-job differences¶
| Aspect | Client job (is_stringer_self = FALSE) |
Self job (is_stringer_self = TRUE) |
|---|---|---|
| Typical lifecycle | Draft → Ordered → Strung → Returned → Paid → Done | Draft → Strung. Other dates left NULL forever. |
ordered_at required at Strung? |
Strongly defaulted; UI requires it (override available with stringer confirmation). | No. Skipping straight to Strung is the norm. |
| Receipt emitted at Strung? | Yes (auto-emit per M14, sent to Person.email). |
Conditionally: if the self-ClientProfile's Person has an email (typically the stringer themselves), the receipt is rendered and available for download but not auto-emailed (the stringer doesn't need to email themselves a receipt for their own work). UI shows the PDF; the stringer can manually emit if they want a record. |
| Pricing editable post-Paid? | Locked; admin override. | N/A — self-jobs typically have no pricing entered (the "free re-string at 25 CHF" pattern from UC-8 is the exception; same lock rule applies). |
| State regressions allowed? | Yes, with audit + free-text reason for un-Strung. | Yes; un-Strung less load-bearing because no client receipt was emitted. |
Hidden requirements (UI affordances the rules imply)¶
Surfaces that must exist for the lifecycle rules to be usable:
- Order state badge. Every order in lists and on the order page shows its derived state ("Draft" / "Ordered" / "Strung" / "Returned" / "Paid" / "Done"). Mira owns the design.
- Lifecycle-date inline editor. The order page has a date row showing all four dates with edit affordances; setting/clearing each date is a transition.
- "Edit price (admin override)" affordance. Visible only to admins on locked-pricing orders. Opens a confirmation dialog with a required reason field.
- "Re-send receipt" button. Always visible on Strung+ orders.
- Receipt-emit history view. A togglable section of the order page listing every emit event (kind + timestamp + actor + delivery status).
- State-regression confirmation. Clearing
strung_atopens a confirmation dialog requiring a free-text reason. - "Un-Strung then re-Strung" badge. On the order header when the order has more than one Strung transition in its audit history.
- Self-job receipt UI affordance. "Render receipt" button available; auto-email suppressed by default with an opt-in toggle.
UX surfacing of items 1–8 is owned by Mira (UX/UI Designer) — flagged as a follow-up requirement; not in scope for this requirements doc.
Acceptance criteria (rules-layer)¶
For the chokepoint + transition validators (Theo's ADR-0007 pins the structural shape; these are the rules they must enforce):
- A1. Every (state, transition, actor) tuple has a documented outcome — allow, refuse, or refuse-with-admin-override. The matrix above is exhaustive.
- A2. The causal-date invariant is enforced on every save: any save that violates
ordered_at ≤ strung_at ≤ {returned_at, paid_at}is rejected with a clear error. - A3. Shared-in stringers (Rules 1/2/3) cannot transition any field on a shared order, ever. Re-asserted from ADR-0004 write-protection.
- A4. Every transition writes an audit row (transition kind + actor + timestamp + before/after for date fields).
- A5. State regressions (clearing a lifecycle date) require a free-text reason from the stringer; the reason is stored on the audit row.
- A6. Auto-re-emit fires on the documented field edits (Strung+ states only); the re-emit is logged in
Order.receipt_emit_log. - A7. Manual re-emit is always available on Strung+ orders; one click; logged.
- A8. Admin overrides on locked fields fire only via the dedicated admin UI affordance; logged in
share_auditwith reason. - A9. Self-jobs admit Draft → Strung directly without
ordered_at. - A10. Hard-delete of an order past Strung is admin-only.
Open questions¶
All open questions on this page are now closed.
- ~~OQ-L-1~~ — Closed 2026-05-04 with option (a): price fields are always editable for the owning stringer; every edit re-emits the receipt; old receipt versions are retained in
Order.receipt_emit_logsnapshots for audit. See Price-edit policy after Strung. - ~~OQ-L-2~~ — Closed 2026-05-04 (resolved by OQ-R-1 flip on receipt-content):
commentsIS on the receipt (client-facing only — redacted from Rule #1 grantees). Comments edits on Strung+ orders therefore trigger an automatic receipt re-emit. See Receipt re-emit triggers.
Cross-references¶
- Architecture: ADR-0007 (Theo, in parallel) — order lifecycle state machine, transition validators, audit table shape, chokepoint integration. Link added on merge.
- ADR-0002 +
docs/architecture/receipts.md: receipt rendering, "always regeneratable from order data", email delivery on Strung. - ADR-0004 + client identity & sharing: read-chokepoint and shared-in write-protection. Lifecycle transitions are subject to the same chokepoint as ordinary writes.
- V2 scope M10/M12/M13/M14: lifecycle dates, manual paid-date toggle, regeneratable receipt, printed-and-emailed delivery.
- Use cases — UC-1, UC-3: the stringing-day flow that this lifecycle implements.
- Receipt content fields (issue #82): what appears on the receipt determines the re-emit trigger list.
- UX: Mira owns the surface design for items 1–8 in hidden requirements.
- Issue tracking: racket-book#80 (this requirements page); ADR-0007 issue (Theo, parallel) — link added on merge.