Skip to content

Budget — events

Per-module slice of docs/events-catalog.md (auto-generated). Update both files when adding or removing events.

Published

EventWhenPayload
budget.sentBudget marked as sent (email or manual delivery).budget_id, clinic_id, patient_id, budget_number, plan_id (nullable), delivery metadata.
budget.acceptedPatient (public link) or staff (in-clinic) accepts and signs.Snapshot incl. accepted_via, total, plan_id (nullable) and items[] — one entry per line: budget_item_id, treatment_id (nullable), catalog_item_id, quantity, net_amount (ex-tax, after line discount + prorated global discount via pricing.allocate_global_discount; issue #167). Subscribers: treatment_plan (pending → active; closed-as-rejected → active; reprices pending sessions from items[].net_amount), patient_timeline, notifications.
budget.rejectedPatient or staff rejects.Snapshot incl. rejection_reason, plan_id (nullable). Subscriber: treatment_plan (pending → closed).
budget.expiredDaily cron, valid_until < today while draft/sent.Snapshot incl. days_overdue, plan_id.
budget.renegotiatedPOST /budgets/{id}/renegotiate cancels a sent budget for renegotiation.budget_id, plan_id (nullable), patient_id, version, cancelled_at, cancelled_by. Subscriber: treatment_plan (pending → draft).
budget.cancelledPOST /budgets/{id}/cancel — staff cancels directly (issue #162). Not published when the cancel is initiated by treatment_plan.reopen() (publish_event=False) — the plan module owns that transition and an echo would deadlock.clinic_id, budget_id, patient_id, budget_number, plan_id (nullable), reason, cancelled_by, occurred_at. Subscriber: treatment_plan (pending → draft).
budget.supersededPOST /budgets/{id}/resend clones a terminal (rejected/expired/cancelled) budget to a new draft version (issue #162). Published after the request transaction commits — sole deviation from the pre-commit pattern; the subscriber points an FK at the new row, which is invisible pre-commit.clinic_id, budget_id (old), new_budget_id, patient_id, plan_id, version (new), resent_by, occurred_at. Subscriber: treatment_plan (repoints budget_id).
budget.viewedPatient opens the public link (first time, idempotent).budget_id, plan_id, patient_id, viewed_at, ip_hash.
budget.reminder_sentAutomatic reminder milestone (7d / 14d).budget_id, plan_id, patient_id, milestone_days, sent_at.

plan_id is resolved by reverse raw-SQL lookup (BudgetWorkflowService._lookup_plan) — never by importing treatment_plan models (ADR 0003). It is null for standalone budgets.

Subscribed

All three run transactionally (ADR 0019): they declare db and mirror the plan inside the publisher's transaction, so a failed mirror fails the request instead of quietly dropping a line (issue #183).

EventHandlerEffect
treatment_plan.budget_sync_requested__init__.py::_on_sync_requestedRebuild draft-budget lines from the snapshot payload.
treatment_plan.treatment_added__init__.py::_on_treatment_added_to_planAdd matching line to the linked draft budget (no-op on non-draft).
treatment_plan.treatment_removed__init__.py::_on_treatment_removed_from_planRemove matching line from the linked draft budget.

odontogram.treatment.performed used to appear here; the handler was a pass placeholder and was removed in #183.

Adding a new event

  1. Add the constant to backend/app/core/events/types.py (EventType).
  2. Publish from a service method, after flush — and pass db=db so transactional subscribers can join the transaction (ADR 0019).
  3. Add the row to the table(s) above.
  4. Run python backend/scripts/generate_catalogs.py to refresh the global catalog.