Skip to content

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

ordered_at ≤ strung_at ≤ returned_at  AND  ordered_at ≤ strung_at ≤ paid_at

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_audit with actor_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, derived total) are always editable by the owning stringer in any post-Draft state, including after paid_at is 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 at paid_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_log so 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_at correction 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, derived total.
  • 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)

  • comments field changes — per OQ-R-1 (closed with YES, see receipt-content), Order.comments IS on the receipt for client-bound emits. Edits to comments therefore 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_at edits — per OQ-R-4 (closed: receipt shows only ordered_at + strung_at), ordered_at is now load-bearing on the receipt face. Edits to ordered_at on Strung+ orders trigger re-emit.

Triggers that do NOT cause an automatic re-emit

  • returned_at and paid_at edits — 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:

  1. 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.
  2. 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.
  3. "Edit price (admin override)" affordance. Visible only to admins on locked-pricing orders. Opens a confirmation dialog with a required reason field.
  4. "Re-send receipt" button. Always visible on Strung+ orders.
  5. Receipt-emit history view. A togglable section of the order page listing every emit event (kind + timestamp + actor + delivery status).
  6. State-regression confirmation. Clearing strung_at opens a confirmation dialog requiring a free-text reason.
  7. "Un-Strung then re-Strung" badge. On the order header when the order has more than one Strung transition in its audit history.
  8. 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_audit with 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_log snapshots for audit. See Price-edit policy after Strung.
  • ~~OQ-L-2~~ — Closed 2026-05-04 (resolved by OQ-R-1 flip on receipt-content): comments IS 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.