Base URL: /api/v1
All endpoints require authentication unless stated otherwise.
| Method | Path | Description |
|---|---|---|
| GET | /campaigns |
Search campaigns with filtering, pagination and sorting |
| GET | /donations |
Search donations with filtering, pagination and sorting |
| GET | /beneficiaries |
Search beneficiaries with filtering, pagination, sorting and facets |
| GET | /global |
Global search across all entities |
| GET | /advanced |
Advanced search with entity-type filtering |
Access: Private — ADMIN and VERIFIER roles only (returns beneficiary PII).
Other authenticated roles receive 403 Insufficient permissions.
Search beneficiaries with advanced filtering, pagination, sorting, and faceted aggregation. Results, the total count, and all facet aggregates are computed in a single database transaction so the facets always reflect a consistent snapshot.
Facets use drill-down semantics: each facet is counted with its own active
filter removed, so the UI can show alternative values to pivot to (e.g. after
filtering country=KE, the countries facet still lists other countries). As a
result, a facet's counts sum to the total only when that dimension is unfiltered.
| Param | Type | Default | Description |
|---|---|---|---|
q |
string | – | Free-text match on first name, last name, ID document number, phone number, and needs assessment |
country |
string | – | Filter by country |
city |
string | – | Filter by city |
needsCategory |
string | – | Filter by needs category |
verificationStatus |
enum | – | One of PENDING, VERIFIED, REJECTED, SUSPENDED, ACTIVE |
riskScoreMin |
int | – | Minimum risk score (inclusive) |
riskScoreMax |
int | – | Maximum risk score (inclusive) |
ageMin |
int | – | Minimum age in years (derived from date of birth) |
ageMax |
int | – | Maximum age in years (derived from date of birth) |
familySizeMin |
int | – | Minimum family size (inclusive) |
familySizeMax |
int | – | Maximum family size (inclusive) |
page |
int | 1 |
Page number (min 1) |
limit |
int | 20 |
Page size (min 1, max 100) |
sortBy |
enum | createdAt |
One of relevance, createdAt, updatedAt, riskScore, age, familySize |
sortOrder |
enum | desc |
asc or desc |
Notes:
agesorting is applied againstdateOfBirthand inverted internally so thatsortOrder=descreturns the oldest beneficiaries first.relevancecurrently falls back to recency (createdAt); there is no full-text relevance score yet.- Range parameters are validated such that the
*Minvalue must be less than or equal to the matching*Maxvalue.
{
"success": true,
"data": [ /* Beneficiary[] */ ],
"pagination": {
"page": 1,
"limit": 20,
"total": 0,
"totalPages": 0
},
"facets": {
"countries": [{ "value": "KE", "count": 5 }],
"cities": [{ "value": "Nairobi", "count": 3 }],
"needsCategories": [{ "value": "FOOD", "count": 2 }],
"verificationStatuses": [{ "value": "VERIFIED", "count": 4 }],
"riskScoreRanges": [
{ "range": "0-25", "count": 0 },
{ "range": "26-50", "count": 0 },
{ "range": "51-75", "count": 0 },
{ "range": "76+", "count": 0 }
],
"ageRanges": [
{ "range": "0-17", "count": 0 },
{ "range": "18-25", "count": 0 },
{ "range": "26-35", "count": 0 },
{ "range": "36-50", "count": 0 },
{ "range": "51-65", "count": 0 },
{ "range": "66+", "count": 0 }
],
"familySizeRanges": [
{ "range": "1", "count": 0 },
{ "range": "2-3", "count": 0 },
{ "range": "4-5", "count": 0 },
{ "range": "6+", "count": 0 }
]
}
}GET /api/v1/search/beneficiaries?country=KE&needsCategory=FOOD&ageMin=18&ageMax=40&riskScoreMin=50&sortBy=riskScore&sortOrder=desc&page=1&limit=20
| Status | Condition |
|---|---|
400 |
Invalid search parameters (e.g. ageMin greater than ageMax, out-of-range values) |
401 |
Missing or invalid authentication |
403 |
Authenticated but not an ADMIN/VERIFIER |
429 |
Rate limit exceeded |
Beneficiary search relies on database indexes declared in prisma/schema.prisma:
- B-tree indexes on
country,city,riskScore,familySize,dateOfBirth,needsCategory, plus composite[country, city]and[status, country]. - GIN trigram indexes (
gin_trgm_ops) onfirstName,lastName,idDocumentNumber,phoneNumberso theqfree-text search (ILIKE '%term%') is index-backed instead of doing a sequential scan. These require the PostgreSQLpg_trgmextension, declared via thepostgresqlExtensionspreview feature.
Apply with a migration before deploying:
npx prisma migrate dev --name beneficiary_search_indexes
# (the generated migration includes CREATE EXTENSION IF NOT EXISTS pg_trgm)Tax receipts provide donors with an official PDF record of confirmed donations, including tax-deduction information that varies by region. Receipts are generated automatically when a donation is confirmed, stored in the configured storage backend, emailed to the donor, and available for download via the API.
All routes are prefixed with /api/v1. All endpoints require a Bearer JWT
(Authorization: Bearer <token>).
- A donation transitions to
CONFIRMED(viaPOST /donations/:id/confirm). - If receipts are enabled and the donation is tied to a donor account, a
GENERATE_RECEIPTjob is enqueued (best-effort; never blocks confirmation). - The worker renders a branded PDF, stores it, creates an immutable
TaxReceiptrecord, and enqueues aSEND_RECEIPT_EMAILjob. - The donor receives the receipt by email (with the PDF attached) and can download it any time from the API.
Generation is idempotent — the unique donationId constraint guarantees a
single receipt per donation even under concurrent generation.
Downloads the receipt PDF.
- Access: the donor who owns the donation, the owning organization, or an
ADMIN/AUDITOR. - Rate limit: 20 requests/minute.
- Response:
200 OK,Content-Type: application/pdf,Content-Disposition: attachment; filename="RCPT-2026-XXXXX.pdf". - Supports a single byte-range request (
Range: bytes=0-1023→206 Partial Content).
| Status | Meaning |
|---|---|
| 200 / 206 | PDF returned (full / partial) |
| 401 | Not authenticated |
| 403 | Authenticated but not permitted to access this receipt |
| 404 | Receipt not yet generated for this donation |
| 410 | Receipt file is no longer available in storage |
| 416 | Requested range not satisfiable |
Lightweight status for portal polling.
- Access: same as above.
- Response:
200 OK
{
"success": true,
"data": {
"generated": true,
"generatedAt": "2026-06-21T10:00:00.000Z",
"emailSent": true,
"emailSentAt": "2026-06-21T10:00:05.000Z",
"emailDeliveryStatus": "SENT",
"receiptNumber": "RCPT-2026-1A2B3C4D5E",
"fileUrl": "https://.../signed-url"
}
}When no receipt exists yet: { "generated": false, "emailSent": false }.
Mounted under /api/v1/admin/receipts.
List receipts. Access: ADMIN, AUDITOR.
Query filters: organizationId, donationId, donorId, emailStatus
(PENDING|SENT|BOUNCED|FAILED), dateFrom, dateTo, page, limit
(max 100). Returns a paginated list with pagination metadata.
Full receipt details + donor/organization summary. Access: ADMIN, AUDITOR.
Starts an asynchronous batch generation job for confirmed donations that do not
yet have a receipt. Access: ADMIN.
Body (at least one filter required):
{
"organizationId": "org_123",
"campaignId": "cmp_123",
"donationIds": ["don_1", "don_2"],
"dateRange": { "from": "2026-01-01", "to": "2026-06-30" },
"region": "US"
}Response 202 Accepted:
{ "success": true, "data": { "jobId": "job_123", "status": "PENDING", "totalMatched": 42 } }Batch job status/progress. Access: ADMIN, AUDITOR.
{
"success": true,
"data": {
"id": "job_123",
"status": "PROCESSING",
"progress": 75,
"totalCount": 42,
"generatedCount": 30,
"failedCount": 1,
"organizationId": "org_123",
"createdAt": "2026-06-21T10:00:00.000Z",
"completedAt": null
}
}Resends the receipt email to the donor. Access: ADMIN.
{ "success": true, "data": { "sent": true, "timestamp": "2026-06-21T10:05:00.000Z" } }The donation amount is formatted in its original currency. ISO currency
codes (USD, EUR, GBP, …) are formatted with the appropriate symbol and locale;
non-ISO / crypto codes (e.g. XLM) fall back to "{amount} {CODE}".
Each region defines deductibility, the expected tax-ID label, language, and the
statement printed on the receipt. Built-in regions: US, UK, CA, EU,
AU, plus a DEFAULT fallback for unconfigured regions.
Override or extend the built-ins with the REGIONAL_TAX_REQUIREMENTS
environment variable (JSON merged over the defaults):
{
"US": { "taxDeductible": true, "requiredTaxId": "EIN", "language": "en-US", "statement": "This donation is tax-deductible as allowed by law." },
"UK": { "taxDeductible": true, "requiredTaxId": "CHN", "language": "en-GB", "statement": "Gift Aid may be available." }
}Invalid JSON is ignored (logged) and the built-in defaults are used.
| Variable | Default | Purpose |
|---|---|---|
RECEIPTS_ENABLED |
true |
Master switch for generation/worker |
RECEIPT_STORAGE_PREFIX |
receipts |
Key prefix within the storage backend |
RECEIPT_SENDER_EMAIL |
EMAIL_FROM |
From address for receipt emails |
RECEIPT_URL_EXPIRY_SECONDS |
86400 |
Signed download URL lifetime |
RECEIPT_DEFAULT_REGION |
US |
Region applied when none is supplied |
REGIONAL_TAX_REQUIREMENTS |
(built-ins) | JSON overriding regional config |
RECEIPT_MAX_BATCH_SIZE |
1000 |
Max donations processed per batch job |
STORAGE_PROVIDER |
local |
local, s3, or azure |
Storage files are written to {RECEIPT_STORAGE_PREFIX}/{organizationId}/{donationId}_{timestamp}.pdf.
Downloads use time-limited signed URLs / authenticated streaming. Receipt
metadata is retained permanently for audit; apply a separate retention
policy to the stored PDF files as required for GDPR compliance.
The schema adds TaxReceipt and ReceiptBatchJob models, the
ReceiptEmailStatus / ReceiptBatchJobStatus enums, and a
receiptGeneratedAt column on Donation.
This repository keeps prisma/migrations/ out of version control and applies
schema changes with db push:
npx prisma generate
npx prisma db push # apply the new models/columns to the databaseIf you prefer a versioned migration, generate one locally with
npx prisma migrate dev --name tax_receipts (the resulting SQL only adds new
objects). Existing donations are unaffected — the new column is nullable and no
receipt is back-filled; use the batch endpoint to generate receipts for
historical donations.