Skip to main content

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:

PathacceptedAtacceptanceOrigin
Student Acceptcommand timestudent
Deadline elapsedexactly deadlineAtdeadline
Operator upholdresolution timeoperator

Banner/action table (derived from status, not widgets):

StatusBannerStudent actionHub OpenHub History
outstandinginformation + deadlineAccept, Contestyesno
acceptedsuccess + PayPayyesyes
contestedwarning, review pendingnoneyesyes
resolvednonenonenoyes
paidnonenonenoyes

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:

  • -1s before boundary → not elapsed,
  • 0s at boundary → elapsed,
  • +1s after → 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.