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:

  1. Creation: An authorized actor (e.g. institution_admin, committee_member, or system_admin) initiates a withdrawal request against an active or completed campaign.
  2. Approval Voting: Individual voting records (withdrawal_approvals) are automatically generated for active committee members. Committee members review and vote (approve or reject).
  3. Admin Overrides: System administrators or institution administrators may issue force-approve or force-reject actions to bypass pending votes.
  4. Fund Release & Receipt: Once a request reaches approved status, an admin issues release to 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)

2. Soft-Deletion & Fail-Closed Guards

  • Soft-Deleted Parents: Querying Campaign::lockForUpdate()->find($id) returns null for 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 approved status 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 to rejected, halting further approval processing.

Roles & Permissions Matrix

Endpoint Actionsystem_admininstitution_admincommittee_memberdonor
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 ParameterTypeDescription
campaign_idintegerFilter requests for a specific campaign
statusstringFilter by status (pending, approved, rejected, released)
per_pageintegerResults 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 StatusError ScenarioResponse Payload Summary
403 ForbiddenActor lacks required role/permission or requests outside tenant institution_id{"message": "Unauthorized access to withdrawal request"}
422 Unprocessable EntityRequested amount exceeds effective campaign balance{"message": "Insufficient campaign balance"}
422 Unprocessable EntityAction attempted on non-pending or already processed request{"message": "Withdrawal request has already been processed"}
422 Unprocessable EntityCampaign 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.

Was this page helpful?