Financial Reports

Read-only reporting over donations and withdrawals — payment movements, platform revenue, withdrawal ledgers, per-campaign reconciliation, and file export (PDF/Excel/CSV).

Source files: app/Services/FinancialReportService.php · app/Http/Controllers/Api/FinancialReportController.php


Permission Gates

PermissionGranted to
view financial reportssystem_admin, institution_admin
export financial reportssystem_admin only

committee_member and donor hold neither — 403 Forbidden at the middleware layer for every endpoint on this page, before any scoping logic runs.


Scoping

RoleSees
system_adminAll institutions. May narrow with institution_id. Only role that receives platform_gross_revenue in the summary response.
institution_adminForced to their own institution_id. If their user record has no institution_id, every query fails closed to an empty result — never falls back to showing everything. Never receives platform_gross_revenue — institutions don't see Batchmates' own take.
committee_member, donor403 Forbidden.

withdrawal_requests has no institution_id column of its own — scoping is done via whereHas('campaign', fn ($q) => $q->where('institution_id', ...)), the same join WithdrawalApprovalController::stats() already uses.


Shared Filters

These filter keys are recognized by summary, movements, and timeseries (as query-string params) and by the filters object on export:

FilterTypeNotes
date_fieldstringpaid_at (default) or created_at
date_from, date_todateBounds on date_field
institution_idintegersystem_admin only
campaign_idinteger
campaign_typestring
campaign_statusstring
payment_gatewaystringpaymongo
payment_methodstringcard, gcash, paymaya, grab_pay, qrph
statusstring or arrayDonation status, multi-select
donation_typestringone_time or recurring
amount_min, amount_maxnumber
donor_searchstringMatches donor_name or donor_email, partial
anonymous_onlyboolean
include_deletedbooleanInclude soft-deleted donations (default excluded)

movements additionally accepts sort_by (paid_at | created_at | amount | total_amount | convenience_fee | system_fee, default paid_at), sort_direction (asc|desc, default desc), and per_page (capped at 100).

timeseries additionally accepts group_by (day | week | month | institution | campaign | gateway | method, default day).

withdrawals uses a different filter set — see below.


GET/api/v1/reports/financial/summary

Summary

Totals and breakdowns over the donation ledger, cached 60s per role/institution/filter combination.

Response

{
  "success": true,
  "data": {
    "completed_count": 214,
    "completed_volume": 512340.00,
    "average_donation": 2394.11,
    "fee_recovery": 15980.44,
    "pending_count": 6,
    "failed_count": 3,
    "expired_count": 9,
    "cancelled_count": 2,
    "by_gateway": { "paymongo": 214 },
    "by_method": { "card": 90, "gcash": 60, "qrph": 30 },
    "by_status": { "completed": 214, "pending": 6 },
    "recurring_count": 40,
    "one_time_count": 174,
    "recurring_volume": 60000.00,
    "one_time_volume": 452340.00,
    "platform_gross_revenue": 7685.10
  }
}

GET/api/v1/reports/financial/movements

Payment Movements

Paginated donation-level ledger, same filters as Summary plus sort/pagination.

Request

curl -G https://batchmates-v2.revlv.com/api/v1/reports/financial/movements \
  -H "Authorization: Bearer {token}" \
  -d "payment_gateway=paymongo" \
  -d "status[]=completed" \
  -d "sort_by=amount" -d "sort_direction=desc"

Response

{
  "success": true,
  "data": {
    "current_page": 1,
    "data": [
      {
        "id": 88,
        "reference_number": "a1b2c3d4-...",
        "amount": "5000.00",
        "convenience_fee": "111.25",
        "system_fee": "75.00",
        "total_amount": "5186.25",
        "payment_gateway": "paymongo",
        "payment_method": "card",
        "status": "completed",
        "is_anonymous": false,
        "donor_name": "Jane Cruz",
        "paid_at": "2026-08-15T10:00:00.000000Z"
      }
    ],
    "per_page": 20,
    "total": 214
  }
}

GET/api/v1/reports/financial/withdrawals

Withdrawals

Paginated, cross-campaign withdrawal ledger — the first place withdrawals are listable outside a single campaign's detail page.

Filters

  • Name
    status
    Type
    string or array
    Description

    pending | approved | rejected | released

  • Name
    committee_id
    Type
    integer
    Description
  • Name
    requested_by
    Type
    integer
    Description
  • Name
    has_receipt
    Type
    boolean
    Description
  • Name
    date_from
    Type
    date
    Description

    Against requested_at

  • Name
    date_to
    Type
    date
    Description

    Against requested_at

  • Name
    institution_id
    Type
    integer
    Description

    system_admin only

  • Name
    include_deleted
    Type
    boolean
    Description
  • Name
    per_page
    Type
    integer
    Description

    Capped at 100

Response

{
  "success": true,
  "data": {
    "current_page": 1,
    "data": [
      {
        "id": 12,
        "campaign_id": 5,
        "committee_id": 2,
        "amount": "20000.00",
        "status": "released",
        "requested_at": "2026-08-01T09:00:00.000000Z",
        "completed_at": "2026-08-03T14:00:00.000000Z"
      }
    ],
    "per_page": 20,
    "total": 41
  },
  "summary": {
    "by_status": { "released": 30, "pending": 5, "approved": 4, "rejected": 2 },
    "released_count": 30,
    "released_amount": 480000.00,
    "estimated_payout_cost": 260.00
  }
}

GET/api/v1/reports/financial/timeseries

Timeseries

Aggregated donation volume/count for charting, bucketed by group_by. Day/week/month bucketing is done in PHP (not raw SQL) so it behaves identically on PostgreSQL (production) and SQLite (tests).

Response — group_by=day (default)

{
  "success": true,
  "data": [
    { "bucket": "2026-08-14", "count": 12, "volume": 34000.00 },
    { "bucket": "2026-08-15", "count": 9, "volume": 21500.00 }
  ]
}

Response — group_by=gateway

{
  "success": true,
  "data": [
    { "payment_gateway": "paymongo", "count": 214, "volume": 512340.00 }
  ]
}

GET/api/v1/reports/financial/reconciliation

Reconciliation

Per-campaign comparison of the stored raised_amount/available_amount against what the completed-donations and released-withdrawals tables would produce — surfaces drift between the two without changing anything. Mirrors CampaignBalanceService's own aggregation so the two can never disagree.

Filters: institution_id (system_admin only), campaign_id.

Response

{
  "success": true,
  "data": [
    {
      "campaign_id": 5,
      "campaign_title": "Engineering Scholarship Fund",
      "raised_amount_stored": 100000.00,
      "donated_sum": 100000.00,
      "raised_diff": 0.00,
      "available_amount_stored": 20000.00,
      "expected_available": 20000.00,
      "available_diff": 0.00,
      "released_withdrawals_sum": 80000.00,
      "in_sync": true
    }
  ]
}

POST/api/v1/reports/financial/export

Export

Generates a movements or withdrawals report as PDF, Excel, or CSV — using the exact same FinancialReportService query as the on-screen report for the given filters, so an export can never show different rows.

Permission: export financial reports (system_admin only — institution_admin gets 403)

Request Body

  • Name
    format
    Type
    string
    Description

    pdf | xlsx | csv

  • Name
    report
    Type
    string
    Description

    movements | withdrawals

  • Name
    filters
    Type
    object
    Description

    Same filter keys as the corresponding list endpoint, nested under filters

Row count is checked before generating anything:

  • Below REPORTS_SYNC_ROW_LIMIT (default 5,000) → generated and streamed back synchronously in the same request.
  • At or above the limit → a report_exports row is created and GenerateFinancialReportExport is queued; the requester is notified by email (FinancialReportReadyNotification) with a signed download link once it's ready.

Every export request — sync or queued — is written to the activity log (financial_report_exported) via activity()->causedBy($user)->withProperties([...])->log(...), including the filters used and resulting row count.

Request

{
  "format": "xlsx",
  "report": "movements",
  "filters": { "payment_gateway": "paymongo", "date_from": "2026-08-01" }
}

Response — small export (sync)

// 200 OK, binary file stream
// Content-Disposition: attachment; filename="movements-20260818-140501.xlsx"

Response — large export (queued)

{
  "status": "queued",
  "message": "Your movements export is being generated (18,204 rows). You'll be notified by email when it's ready.",
  "export_id": 7
}

GET/api/v1/reports/financial/export/{export}/download

Download

Downloads a queued export once ready. Not behind Sanctum auth — the link is delivered by email, so authorization is entirely the signed URL itself (URL::temporarySignedRoute, lifetime REPORTS_DOWNLOAD_URL_LIFETIME_MINUTES, default 12h). A tampered or expired signature is rejected by Laravel's signed middleware before the controller runs. Returns 404 if the export isn't ready yet, or if it has passed its own expires_at.

Error — expired or tampered signature

{
  "message": "This action is unauthorized."
}

Environment Variables

# .env
REPORTS_SYNC_ROW_LIMIT=5000
REPORTS_EXPORT_RETENTION_HOURS=24
REPORTS_DOWNLOAD_URL_LIFETIME_MINUTES=720

Error Codes

HTTP StatusScenario
403 ForbiddenCaller lacks view financial reports (any endpoint) or export financial reports (export/download)
200 OK (empty result)institution_admin with no institution_id — fails closed, never all institutions
404 Not FoundDownload requested for an export that isn't ready, has expired, or whose file is missing

Was this page helpful?