Skip to content

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_request table


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

StatusWhat It MeansWho Sets It
SUBMITTEDDR created and submitted; awaiting admin reviewSystem (on creation)
PENDINGSent to the designated approver(s); awaiting decisionAdmin
APPROVEDApproved; queued for paymentApprover (via API or UI)
AWAITING_ACHApproved; waiting to be included in the next ACH batchSystem
SENT_FOR_AUTO_AUTHQueued for auto-approval after the configured wait periodAdmin
PAIDPayment processed and sentSystem (auto-approver or payment processing)
DENIEDRejected by an approverApprover
SEND_BACKReverted from Approved to Pending for re-reviewAdmin
DELETEDRemoved from the systemAdmin
INSUFFICIENTAccount balance too low to processSystem (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 = True and legacy = 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() in seedtrustapi/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 = PENDING
  • approval_users is populated from the case’s DR approval authority configuration
  • Notification email sent to all approvers

Code: handle_dr_form_submission() in seedtrust_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 in approval_users alongside 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_halted must be False — a halted case blocks approval entirely

Side effects:

  • A LedgerTransaction record is created with status = 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() in seedtrustapi/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() in seedtrustapi/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:

RuleWhat It Checks
PaymentHaltedCase and Agency payment_halted must both be False
PayeeIsSurrogateIf payee is the surrogate, the full name must match exactly
SurrogateHasACHIf surrogate is the payee, a valid ACH form must exist (bank name, account number, routing number, account type all populated)
AmountLimitDR amount must be ≤ $5,000
LedgerBalanceRunning total of auto-approved DRs must not exceed the case’s account balance
LedgerTransactionNotRejectedNo prior rejected transactions on this case
PaymentIsInternationalPayee country must not be international
PaymentIsCheckPayment method must not be a paper check

Side effects when auto-approved:

  • DisbursementRequest.statusPAID
  • Case.acct_balance decremented
  • LedgerTransaction.statusPAID, post_transaction_balance updated
  • Background emails sent
  • SPC completion emails checked and sent if this was the last scheduled payment

Code: approve_all_eligible_transactions() in seedtrustapi/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 fieldWhat the record represents
NOT NULLAn SPC entry — a scheduled payment on a calendar date
NULLA 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:

  1. The linked AgencyCompTemplate.name if available
  2. 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() in service.py lines 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:

FieldWhen It Applies
Case.approve_case_drDefault approver for all DRs
Case.approve_pre_gsaOverrides default when Case.pre_gsa = True
Case.approve_post_gsaOverrides 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 sent
  • DR.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 with old_data, new_data, who performed it, and how long the DR spent in the previous status
  • DisbursementStatusLog — 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_update event listeners in disbursement_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.