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
All filter-building logic lives in one place (FinancialReportService). The list endpoints, the summary endpoint, and the export pipeline all call the same query methods — an exported file can never disagree with what the screen shows for the same filters.
Permission Gates
| Permission | Granted to |
|---|---|
view financial reports | system_admin, institution_admin |
export financial reports | system_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
| Role | Sees |
|---|---|
system_admin | All institutions. May narrow with institution_id. Only role that receives platform_gross_revenue in the summary response. |
institution_admin | Forced 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, donor | 403 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:
| Filter | Type | Notes |
|---|---|---|
date_field | string | paid_at (default) or created_at |
date_from, date_to | date | Bounds on date_field |
institution_id | integer | system_admin only |
campaign_id | integer | |
campaign_type | string | |
campaign_status | string | |
payment_gateway | string | paymongo |
payment_method | string | card, gcash, paymaya, grab_pay, qrph |
status | string or array | Donation status, multi-select |
donation_type | string | one_time or recurring |
amount_min, amount_max | number | |
donor_search | string | Matches donor_name or donor_email, partial |
anonymous_only | boolean | |
include_deleted | boolean | Include 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.
Summary
Totals and breakdowns over the donation ledger, cached 60s per role/institution/filter combination.
fee_recovery (convenience_fee) and platform_gross_revenue (system_fee) are always separate fields — convenience_fee is a pass-through to the payment gateway, never platform income. Never sum them into one "revenue" number when building UI on top of this endpoint.
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
}
}
platform_gross_revenue is present only for system_admin — omitted entirely for institution_admin, not zeroed out.
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
}
}
Respect is_anonymous in any UI built on this — when true, donor_name/donor_email are still present in the raw row for system_admin (the backend does not redact them here), so the frontend is responsible for hiding donor identity when is_anonymous is true, same convention as the existing donations list.
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_adminonly
- 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
}
}
estimated_payout_cost is an approximation — withdrawal_requests has no gateway column, so every released withdrawal is counted as PayMongo-costed (₱10/transfer, PAYMONGO_PAYOUT_FEE); treat this as directional, not exact.
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 }
]
}
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
}
]
}
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_exportsrow is created andGenerateFinancialReportExportis 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
}
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."
}
Generated files live on the private local disk (never public/) and are purged, along with their report_exports tracking row, by an hourly scheduled command (PurgeFinancialReportExports) once older than REPORTS_EXPORT_RETENTION_HOURS (default 24h).
Environment Variables
# .env
REPORTS_SYNC_ROW_LIMIT=5000
REPORTS_EXPORT_RETENTION_HOURS=24
REPORTS_DOWNLOAD_URL_LIFETIME_MINUTES=720
Error Codes
| HTTP Status | Scenario |
|---|---|
403 Forbidden | Caller 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 Found | Download requested for an export that isn't ready, has expired, or whose file is missing |