Withdrawals & Multi-Sign Approvals
The Withdrawal and Multi-Sign Approval engine (WithdrawalController, WithdrawalApprovalController) governs campaign fund disbursements. It features multi-approver committee voting, administrative force-override capabilities, database lock ordering for race-condition prevention, and fail-closed security constraints.
Architectural Overview
When campaign funds are earned, disbursement requires a structured workflow:
- Creation: An authorized actor (e.g.
institution_admin,committee_member, orsystem_admin) initiates a withdrawal request against an active or completed campaign. - Approval Voting: Individual voting records (
withdrawal_approvals) are automatically generated for active committee members. Committee members review and vote (approveorreject). - Admin Overrides: System administrators or institution administrators may issue
force-approveorforce-rejectactions to bypass pending votes. - Fund Release & Receipt: Once a request reaches
approvedstatus, an admin issuesreleaseto execute fund disbursement, decrementing campaign available funds and recording disbursement receipts.
4-Stage Lifecycle Flow
sequenceDiagram
autonumber
actor Requester as Requester / Admin
participant WR as Withdrawal Request
actor Committee as Committee Members
actor Admin as Institution / System Admin
participant Campaign as Campaign Balance
Note over Requester, Campaign: Stage 1: Request Creation
Requester->>WR: POST /v1/withdrawal-requests (amount, bank_account)
WR->>Campaign: Verify effective balance (available - locked)
WR->>WR: Generate pending approval rows for active committee members
Note over Committee, Admin: Stage 2: Voting & Promotion (or Stage 3: Admin Override)
alt Multi-Sign Committee Voting Path
Committee->>WR: POST /v1/withdrawal-approvals/{id}/approve
alt Unanimous (100% Approved)
WR->>WR: Status auto-promotes to "approved"
else Single Rejection (Any 1 Rejected)
Committee->>WR: POST /v1/withdrawal-approvals/{id}/reject
WR->>WR: Status immediately transitions to "rejected"
end
else Admin Force Override Path
Admin->>WR: POST /v1/withdrawal-requests/{id}/force-approve (or force-reject)
WR->>WR: Status set to "approved" (or "rejected") with [Admin Override] note
end
Note over Admin, Campaign: Stage 4: Fund Release & Proof Receipt
Admin->>WR: POST /v1/withdrawal-requests/{id}/release
WR->>Campaign: Decrement available_amount by withdrawal amount
WR->>WR: Transition status to "released"
Admin->>WR: POST /v1/withdrawal-requests/{id}/receipt (upload proof)
Security, Locking & Concurrency Control
To guarantee financial integrity under high concurrency, the withdrawal system enforces strict database lock hierarchies and fail-closed guards.
1. Lock Hierarchy Specification
To eliminate deadlocks when processing concurrent withdrawal or voting requests, database transactions strictly acquire locks in top-down hierarchy:
campaigns (lockForUpdate) → withdrawal_requests (lockForUpdate) → withdrawal_approvals (lockForUpdate)
Every controller mutation (store, approve, reject, release, forceApprove, forceReject) acquires row locks in this exact order. Reversing or skipping lock hierarchy steps is strictly forbidden.
2. Soft-Deletion & Fail-Closed Guards
- Soft-Deleted Parents: Querying
Campaign::lockForUpdate()->find($id)returnsnullfor soft-deleted campaigns. The transaction fails closed immediately with HTTP 422 (Campaign no longer exists). - Tenant Context (
institution_id): Unscoped requests or actors operating outside their institution context fail closed with HTTP 403 Forbidden. - Effective Balance Protection: Requests calculate effective available balance (
available_amount - lockedAmount). Requests exceeding this limit return HTTP 422 (Insufficient campaign balance).
3. Voting Rules
- Unanimous Approval Rule: A parent withdrawal request reaches
approvedstatus via voting ONLY when 100% of active committee members cast positive votes. - Single Rejection Rule: A single negative vote (
reject) immediately transitions the parent withdrawal request status torejected, halting further approval processing.
Roles & Permissions Matrix
| Endpoint Action | system_admin | institution_admin | committee_member | donor |
|---|---|---|---|---|
List / View Requests (index, show) | ❌ | |||
Create Request (store) | ❌ | |||
Cancel Pending Request (destroy) | ❌ | |||
Vote on Approval (approve, reject) | ❌ | |||
| View Pending Votes / History / Stats | ❌ | |||
Force Override (force-approve, force-reject) | ❌ | ❌ | ||
Execute Fund Release (release) | ❌ | ❌ | ||
Submit Proof Receipt (receipt) | ❌ | ❌ |
API Reference: Withdrawal Requests (/v1/withdrawal-requests)
List Withdrawal Requests
GET /api/v1/withdrawal-requests
Returns a paginated list of withdrawal requests scoped by tenant institution_id.
| Query Parameter | Type | Description |
|---|---|---|
campaign_id | integer | Filter requests for a specific campaign |
status | string | Filter by status (pending, approved, rejected, released) |
per_page | integer | Results per page (default: 15, max: 100) |
Initiate Withdrawal Request
POST /api/v1/withdrawal-requests
Creates a new withdrawal request and generates voting records for committee members.
// Request Body
{
"campaign_id": 12,
"amount": 50000.00,
"bank_account_id": 3,
"purpose": "Disbursement for Q3 Scholarship Grants"
}
// Response (201 Created)
{
"success": true,
"message": "Withdrawal request created successfully",
"data": {
"id": 45,
"campaign_id": 12,
"institution_id": 2,
"requested_by": 8,
"amount": "50000.00",
"status": "pending",
"purpose": "Disbursement for Q3 Scholarship Grants",
"created_at": "2026-08-11T15:00:00.000000Z"
}
}
View Single Withdrawal Request
GET /api/v1/withdrawal-requests/{withdrawalRequest}
Fetches detailed information for a single request, including campaign, bank account, and approver voting states.
Cancel / Delete Withdrawal Request
DELETE /api/v1/withdrawal-requests/{withdrawalRequest}
Cancels a pending withdrawal request before voting or release completes. Only allowed while request status is pending.
Release Funds (Admin Only)
POST /api/v1/withdrawal-requests/{withdrawalRequest}/release
Executes actual payout, decrements campaign available_amount, and updates request status to released.
// Request Body
{
"reference_number": "TRX-99482710",
"notes": "Bank transfer processed via online banking"
}
Submit Disbursement Receipt
POST /api/v1/withdrawal-requests/{withdrawalRequest}/receipt
Uploads receipt proof or bank transaction confirmation attached to a released request.
// Request Body
{
"receipt_url": "https://storage.batchmates.org/receipts/rec_99482710.pdf",
"notes": "Official bank deposit acknowledgment slip"
}
API Reference: Approval Voting & Overrides (/v1/withdrawal-approvals)
List Approvals
GET /api/v1/withdrawal-approvals
Lists voting records across withdrawal requests.
List My Pending Votes
GET /api/v1/withdrawal-approvals/my-pending
Returns pending approval votes assigned specifically to the authenticated committee member.
List My Voting History
GET /api/v1/withdrawal-approvals/my-history
Historical record of votes cast by the authenticated user.
Voting Metrics & Statistics
GET /api/v1/withdrawal-approvals/stats
Metrics on committee voting activity, total approved counts, and released volumes.
View Approval Detail
GET /api/v1/withdrawal-approvals/{id}
Show details for a specific voting row.
Cast Approve Vote
POST /api/v1/withdrawal-approvals/{id}/approve
Casts a positive vote. If 100% of active members approve, parent request status automatically promotes to approved.
// Request Body
{
"notes": "Verified documents and budget allocation. Approved."
}
Cast Reject Vote
POST /api/v1/withdrawal-approvals/{id}/reject
Casts a negative vote. A single rejection immediately sets parent request status to rejected.
// Request Body
{
"reason": "Discrepancy in requested amount vs planned budget"
}
Force Approve (Admin Override)
POST /api/v1/withdrawal-requests/{withdrawalRequest}/force-approve
Bypasses pending committee votes and directly sets request status to approved.
// Request Body
{
"notes": "Emergency override approved by Institution Board"
}
Force Reject (Admin Override)
POST /api/v1/withdrawal-requests/{withdrawalRequest}/force-reject
Administrative override to force reject a withdrawal request.
// Request Body
{
"reason": "Administrative cancellation per compliance audit"
}
Error Codes & Handling
| HTTP Status | Error Scenario | Response Payload Summary |
|---|---|---|
403 Forbidden | Actor lacks required role/permission or requests outside tenant institution_id | {"message": "Unauthorized access to withdrawal request"} |
422 Unprocessable Entity | Requested amount exceeds effective campaign balance | {"message": "Insufficient campaign balance"} |
422 Unprocessable Entity | Action attempted on non-pending or already processed request | {"message": "Withdrawal request has already been processed"} |
422 Unprocessable Entity | Campaign has been soft-deleted | {"message": "Campaign no longer exists"} |
Cross-Campaign Reporting
The endpoints above are scoped to a single campaign's withdrawal requests. For a flat, filterable, cross-campaign withdrawal ledger (status, committee, date range, receipt presence) plus totals and an estimated payout-cost line, see GET /api/v1/reports/financial/withdrawals under Financial Reports — gated by view financial reports rather than the roles/permissions matrix above.