Domain Logic
Pure, framework-free business rules. Two modules own all student-visible behavior.
Charge lifecycle (app/domain/charge_state.py)
stateDiagram-v2
[*] --> outstanding
outstanding --> accepted: accept
outstanding --> contested: contest
outstanding --> accepted: deadlineElapsed
accepted --> paid: pay
contested --> accepted: operatorUphold
contested --> resolved: operatorDismiss
resolved --> [*]
paid --> [*]
Total function transition(status, event) -> TransitionAccepted | InvalidTransition — every illegal (status,event) pair returns charge.invalid_transition, never a silent no-op. The 30-row table is exported as vectors/charge_state_vectors.json and replayed by both tests/unit/test_domain.py and test/unit/charge_state_vectors_test.dart.
class ChargeStatus(StrEnum): OUTSTANDING, ACCEPTED, CONTESTED, RESOLVED, PAID
class ChargeEvent(StrEnum): ACCEPT, CONTEST, PAY, DEADLINE_ELAPSED, OPERATOR_UPHOLD, OPERATOR_DISMISS
class AcceptanceOrigin(StrEnum): STUDENT, DEADLINE, OPERATOR
class Actor(StrEnum): STUDENT, SYSTEM, OPERATOR
_TRANSITIONS = {
(OUTSTANDING, ACCEPT): ACCEPTED,
(OUTSTANDING, CONTEST): CONTESTED,
(OUTSTANDING, DEADLINE_ELAPSED): ACCEPTED,
(ACCEPTED, PAY): PAID,
(CONTESTED, OPERATOR_UPHOLD): ACCEPTED,
(CONTESTED, OPERATOR_DISMISS): RESOLVED,
}
Student may raise accept, contest, pay; system clock deadlineElapsed; operator (dev endpoint) operatorUphold, operatorDismiss. accepted always carries acceptedAt + acceptanceOrigin:
| Path | acceptedAt | acceptanceOrigin |
|---|---|---|
| Student Accept | command time | student |
| Deadline elapsed | exactly deadlineAt | deadline |
| Operator uphold | resolution time | operator |
Banner/action table (derived from status, not widgets):
| Status | Banner | Student action | Hub Open | Hub History |
|---|---|---|---|---|
outstanding | information + deadline | Accept, Contest | yes | no |
accepted | success + Pay | Pay | yes | yes |
contested | warning, review pending | none | yes | yes |
resolved | none | none | no | yes |
paid | none | none | no | yes |
accepted intentionally appears on both tabs — explicit reconciliation of screenshot vs prose.
Vectors shape (one row):
{"from":"outstanding","event":"accept","actor":"student","outcome":"accepted","to":"accepted","acceptanceOrigin":"student"}
{"from":"accepted","event":"contest","actor":"student","outcome":"invalidTransition","to":null,"acceptanceOrigin":null}
Deadlines (app/domain/deadline.py)
deadlineAt = raisedAt + gracePeriodDays # UTC
is_elapsed(now, deadlineAt) -> bool # now >= deadlineAt inclusive
Default gracePeriodDays=30. Vectors pin:
-1sbefore boundary → not elapsed,0sat boundary → elapsed,+1safter → elapsed,- plus leap-day (2024-02-29) and Europe/London offset-change day.
ChargeService reconciles under the store lock before every charge/Hub read, before every mutation, and from its own AsyncioScheduler that schedules the next earliest deadlineAt.
Reconciliation write (once, idempotent):
status=accepted, acceptedAt=deadlineAt, updatedAt=<reconciliation time>,
acceptanceOrigin=deadline, version+1, charge.updated event
Race: if reconciliation wins against a concurrent student Accept/Contest, the Accepted commit wins and the student command fails 409 with {currentCharge}; no idempotency entry or attachment is finalized.
Graphemes (app/core/graphemes.py)
UAX #29 extended grapheme count for contestReason: CONTEST_REASON_MIN=10, MAX=2000. Validated in ChargeService.validate_contest_reason. Covers CR LF, control, combining/spacing marks, ZWJ emoji sequences, modifiers, variation selectors, regional-indicator flag pairs, Hangul syllables. Mirrors Dart package:characters. Boundary vectors stay inside this scope.
Namespace (app/domain/namespace.py)
Helpers for storage namespacing (StorageNamespace on Flutter side) — prefix per apiBaseUrl so fixture and remote datasets never collide in the same Drift DB.
Testing the pure layer
uv run pytest -q tests/unit/test_domain.py
# - replays charge_state_vectors.json + deadline_vectors.json
# - exhaustive: every (status,event) pair asserted
# - actors partitioned test
Same JSON replayed from Dart — one truth, two runtimes.