Disbursement Requests
A Disbursement Request is how money moves out of escrow. It is the most frequently touched feature in SeedTrust and the source of the most operational edge cases. Any developer working on payments, approvals, or financial workflows will spend significant time here.
What Is a Disbursement Request?
A DR is a formal request to release a specific amount from a case’s escrow account to a payee (typically the surrogate, a vendor, or an agency). Every DR must be approved before any money moves. The approval path depends on who submitted it, what stage the case is in, and how the case is configured.
Code:
DisbursementRequest,disbursement_requesttable
The DR State Machine
stateDiagram-v2 [*] --> SUBMITTED: DR created SUBMITTED --> PENDING: Admin sends for approval SUBMITTED --> INSUFFICIENT: Balance too low at creation PENDING --> APPROVED: Approver approves PENDING --> DENIED: Approver denies APPROVED --> PENDING: Admin sends back APPROVED --> PAID: Auto-approver or payment processing PENDING --> PAID: Auto-approver (direct) DENIED --> [*] PAID --> [*]Every Status Explained
| Status | What It Means | Who Sets It |
|---|---|---|
SUBMITTED | DR created and submitted; awaiting admin review | System (on creation) |
PENDING | Sent to the designated approver(s); awaiting decision | Admin |
APPROVED | Approved; queued for payment | Approver (via API or UI) |
AWAITING_ACH | Approved; waiting to be included in the next ACH batch | System |
SENT_FOR_AUTO_AUTH | Queued for auto-approval after the configured wait period | Admin |
PAID | Payment processed and sent | System (auto-approver or payment processing) |
DENIED | Rejected by an approver | Approver |
SEND_BACK | Reverted from Approved to Pending for re-review | Admin |
DELETED | Removed from the system | Admin |
INSUFFICIENT | Account balance too low to process | System (on creation) |
State Transitions in Detail
1. Created → SUBMITTED
Trigger: Any authenticated user calls the FastAPI POST /cases/{case_id}/disbursement-requests endpoint.
Who can create: Admin, Case Manager, Agency Owner, IP, IP Rep, Surrogate (access depends on case configuration — surrogate_dr_submit must be enabled for surrogates).
Validation on creation:
- The compensation record linked to the DR must exist with
calendar_date = NULL(see DR Types and the Compensation Model) - The compensation template must be
active = Trueandlegacy = False
Auto-approve shortcut: If the case has dr_auto_approve = True and the approval authority is IPCM or CM, the DR skips SUBMITTED entirely and is immediately set to APPROVED.
Side effect: If the DR amount exceeds the current escrow balance at creation, the DR is immediately flagged as INSUFFICIENT.
Code:
create_dr()inseedtrustapi/src/seedtrust/modules/case/disbursement_requests/service.py
2. SUBMITTED → PENDING
Trigger: Admin sends the DR for approval via the admin UI.
Who can trigger: Admins only.
What happens:
send_for_approval = True,status = PENDINGapproval_usersis populated from the case’s DR approval authority configuration- Notification email sent to all approvers
Code:
handle_dr_form_submission()inseedtrust_flask/seedtrust/views/admin/disbursement_management.py
3. PENDING → APPROVED
Trigger: An approver approves the DR via the FastAPI endpoint.
Who can approve: Only users listed in DisbursementRequest.approval_users. The approval authority is configured per case (approve_case_dr, approve_pre_gsa, approve_post_gsa).
Pre-GSA cases: When
Case.pre_gsa = True, the Case Manager is included inapproval_usersalongside the final approver — this is not a separate workflow step, they are simply an additional required approver in the same pending state.
Validation:
- Approver must pass the
can_approve()check Case.payment_haltedmust beFalse— a halted case blocks approval entirely
Side effects:
- A
LedgerTransactionrecord is created withstatus = PENDING(money is not yet moved — it is committed but not sent) - Background emails sent to surrogate, IP, case managers, and vendors depending on the payee type
Code:
approve_disbursement_request()inseedtrustapi/src/seedtrust/modules/case/disbursement_requests/route.py
4. PENDING → DENIED
Trigger: An approver denies the DR.
Who can deny: Same as approve — must pass can_approve().
Side effects:
- Email sent to the original submitter
- Email sent to the Escrow Specialist (legal admin)
Code:
deny_disbursement_request()inseedtrustapi/src/seedtrust/modules/case/disbursement_requests/route.py
5. APPROVED → PENDING (Send Back)
Trigger: Admin reverts an approved DR back to pending for re-review.
Who can trigger: Admins only, via the admin UI.
Use case: The approver approved in error, or new information requires re-review. The LedgerTransaction created on approval must also be handled (typically deleted or voided).
6. → PAID (Auto-Approver)
There are two separate auto-approver systems, often confused:
Payments Dashboard Auto-Approver — a manual trigger in the admin payments dashboard. An admin clicks to bulk-process eligible DRs. This is the only way to auto-approve through the dashboard UI.
Flask Cron Auto-Approver (job_dr_approval) — runs daily at 6am EST. Automatically approves DRs that have reached their auto_authorize expiry date (the configured wait period). This is the time-based path — once the wait period expires, no manual action is needed.
Both paths apply the same 8 eligibility rules. A DR must pass all of them:
The auto-approver applies 8 rules. A DR must pass all of them:
| Rule | What It Checks |
|---|---|
PaymentHalted | Case and Agency payment_halted must both be False |
PayeeIsSurrogate | If payee is the surrogate, the full name must match exactly |
SurrogateHasACH | If surrogate is the payee, a valid ACH form must exist (bank name, account number, routing number, account type all populated) |
AmountLimit | DR amount must be ≤ $5,000 |
LedgerBalance | Running total of auto-approved DRs must not exceed the case’s account balance |
LedgerTransactionNotRejected | No prior rejected transactions on this case |
PaymentIsInternational | Payee country must not be international |
PaymentIsCheck | Payment method must not be a paper check |
Side effects when auto-approved:
DisbursementRequest.status→PAIDCase.acct_balancedecrementedLedgerTransaction.status→PAID,post_transaction_balanceupdated- Background emails sent
- SPC completion emails checked and sent if this was the last scheduled payment
Code:
approve_all_eligible_transactions()inseedtrustapi/src/seedtrust/modules/auto_approver/service.py
DR Types and the Compensation Model
This is the most confusing design in SeedTrust. Read carefully.
The Compensation model is overloaded — a single table stores two conceptually different things:
calendar_date field | What the record represents |
|---|---|
NOT NULL | An SPC entry — a scheduled payment on a calendar date |
NULL | A DR type — a category of one-time disbursement available on the case |
When a user creates a DR, they are selecting from the DR-type Compensation records (calendar_date = NULL) on their case. The compensation_id is stored on the DR, linking it to its type template.
The DR’s type field (the human-readable name displayed in the UI) is derived from:
- The linked
AgencyCompTemplate.nameif available - Otherwise the
Compensation.title
Practical implication: If you’re querying Compensation records and want only DR types (not scheduled payments), filter with Compensation.calendar_date.is_(None). If you want only scheduled payments, filter with Compensation.calendar_date.isnot(None).
Code:
create_dr()inservice.pylines 334–344 for the exact filter applied at DR creation
The Approval Authority
Who must approve a DR is configured at the case level with three fields:
| Field | When It Applies |
|---|---|
Case.approve_case_dr | Default approver for all DRs |
Case.approve_pre_gsa | Overrides default when Case.pre_gsa = True |
Case.approve_post_gsa | Overrides default when Case.pre_gsa = False |
Each field can be set to: LEGAL (Escrow Specialist), AGENCY (Agency Owner/CM), IP (Intended Parent), IP_REP, CM (Case Manager), or IPCM.
IPCM means either the IP or the CM can approve — whichever acts first satisfies the requirement. It is used when approval authority is shared between the Intended Parent and Case Manager on a case.
The resolved approver(s) are written to DisbursementRequest.approval_users when the DR is sent for approval, as a comma-separated list of user_id:user_type pairs.
The Ledger Transaction
Approving a DR does not immediately move money. It creates a LedgerTransaction with status = PENDING that commits the funds. Money is considered moved only when the LedgerTransaction reaches status = PAID — which happens when the ACH batch is processed or the auto-approver runs.
This means:
DR.status = APPROVED+LedgerTransaction.status = PENDING→ funds committed, not yet sentDR.status = PAID+LedgerTransaction.status = PAID→ funds sent
Always check the LedgerTransaction status, not just the DR status, when determining whether a payment has actually been processed.
Audit Trail
Every status change on a DR is logged in two tables:
DisbursementActionLog— records every action withold_data,new_data, who performed it, and how long the DR spent in the previous statusDisbursementStatusLog— tracks time spent in each status (used for operational metrics)
These are written automatically by SQLAlchemy event listeners on the DisbursementRequest model — you do not need to write audit log entries manually.
Code:
log_dr_insert,log_dr_updateevent listeners indisbursement_request.py
Gotchas for Developers
A DR being APPROVED does not mean money has moved. The LedgerTransaction is what represents the actual payment. Check its status separately.
payment_halted on the case blocks everything. Even if a DR is already approved, payment_halted = True will prevent processing. This is the emergency stop.
The auto-approver $5,000 limit is a hard rule. A DR for $5,001 will never auto-approve, regardless of case settings. It must go through manual approval.
approval_users is a string, not a relation. It stores user_id:user_type pairs as a comma-separated string. Parsing this string to check approval eligibility is done in can_approve(). Do not try to join on it directly.
Duplicate invoice protection exists but is not a hard block. The system checks for duplicate invoice_num values and flags potential_duplicate = True, but does not prevent the DR from being created. Admins must review flagged duplicates manually.
Pre-GSA DR review is stage-independent. It is controlled by Case.pre_gsa (a boolean), not the case stage. A case can be in “Awaiting Executed GSA” stage with pre_gsa = False, or “Pregnant” with pre_gsa = True if it was never updated. Always check the boolean.