11.8 Finance transactions
Transactions are double-entry journal postings with maker-checker approval. Status values stored: INITIATED, PENDING_APPROVAL, PROCESSING, SUCCEEDED, FAILED, REVERSED, PARTIALLY_REFUNDED, REFUNDED.
Common rules for all five finance transaction routes:
- Body must be
application/json, at most 256 KiB, and non-empty. - Session cookie and
X-DFFMS-Tenant-Idare required. - The session must be created within the last 5 minutes (
requireFreshSession). Failure returns 401UNAUTHENTICATEDwith message "Fresh authentication is required". - Approve, reject, create, reversal and refund also require MFA enrolment (
requireStepUp). Failure: 401MFA_REQUIRED. - Provider-backed transactions are not posted when approved. They are posted only after provider verification.
POST /api/v1/finance/transactions
- Purpose. Creates a balanced transaction draft that awaits independent approval.
- Role. Route roles: CSA, PA, SCA, IA, FEO, FA (or PSA).
- Body. Strict object (unknown fields rejected with 422).
| Field | Type | Required | Rules |
|---|---|---|---|
reference | string | Yes | 8 to 100; ^[A-Za-z0-9][A-Za-z0-9._:/-]+$; unique per tenant |
idempotencyKey | string | Yes | 16 to 128; ^[A-Za-z0-9][A-Za-z0-9._:-]+$ |
type | enum | Yes | COLLECTION, DISBURSEMENT, ADJUSTMENT |
amountMinor | string | Yes | Positive integer string, up to 19 digits |
currency | string | Yes | ISO 4217 uppercase |
provider | enum | Collection and disbursement: Yes (or manual transfer, see rules) | FLUTTERWAVE, PAYSTACK, FINCRA, SQUAD, GENERIC |
beneficiaryId | UUID | No | |
programmeId | UUID | No | Scope; defaults from financing case metadata if present |
sapzId | UUID | No | Scope |
institutionId | UUID | No | Scope |
entries | array | Yes | 2 to 100 journal lines: accountId (UUID), direction (DEBIT or CREDIT), amountMinor (positive integer string), memo (max 500, optional) |
metadata | object | No | Free-form. Manual bank transfers require channel MANUAL_BANK_TRANSFER and bankTransferReference (4 to 120) |
Rules:
- Total debits and total credits must each equal
amountMinor(UNBALANCED_JOURNAL, 422). - Each ledger account must exist, belong to the tenant, be
ACTIVE, and use the transaction currency (ACCOUNT_INVALID, 422). - Collections and disbursements require a provider.
GENERICis accepted only as a manual bank transfer with evidence inmetadata. ADJUSTMENTdoes not require a provider.
curl -s -X POST https://dffms.org/api/v1/finance/transactions \
-H 'Cookie: __Secure-dffms.session_token=<session-cookie>' \
-H 'X-DFFMS-Tenant-Id: <tenantId>' \
-H 'Origin: https://dffms.org' \
-H 'Content-Type: application/json' \
-d '{
"reference": "TXN-2026-000145",
"idempotencyKey": "txn-create-2026-10-26-000145",
"type": "COLLECTION",
"amountMinor": "2500000",
"currency": "NGN",
"provider": "PAYSTACK",
"programmeId": "0190f2c4-6b3e-7a1d-8c2f-000000000020",
"entries": [
{ "accountId": "0190f2c4-6b3e-7a1d-8c2f-000000000070", "direction": "DEBIT", "amountMinor": "2500000", "memo": "Collection receivable" },
{ "accountId": "0190f2c4-6b3e-7a1d-8c2f-000000000071", "direction": "CREDIT", "amountMinor": "2500000", "memo": "Programme income" }
]
}'
Response (201): the stored transaction row, abbreviated:
{
"data": {
"id": "0190f2c4-6b3e-7a1d-8c2f-000000000080",
"tenantId": "<tenantId>",
"reference": "TXN-2026-000145",
"idempotencyKey": "txn-create-2026-10-26-000145",
"type": "COLLECTION",
"status": "PENDING_APPROVAL",
"amountMinor": "2500000",
"currency": "NGN",
"provider": "PAYSTACK",
"createdBy": "<userId>",
"approvedBy": null,
"postedAt": null,
"metadata": { "requestHash": "<sha256>", "ledgerDraft": [ "..." ] },
"...": "..."
},
"error": null,
"requestId": "<request-id>"
}
Errors:
| HTTP | Code | Meaning |
|---|---|---|
| 400 | INVALID_BODY / INVALID_CONTENT_TYPE | Body not JSON, empty, or wrong content type. |
| 401 | UNAUTHENTICATED | No session, or session older than 5 minutes. |
| 401 | MFA_REQUIRED | No MFA enrolment. |
| 403 | FORBIDDEN | Role not permitted. |
| 403 | ORIGIN_DENIED | Origin not allowed. |
| 409 | IDEMPOTENCY_CONFLICT | Key reused with different data, or reference already exists. |
| 413 | PAYLOAD_TOO_LARGE | Body over 256 KiB. |
| 422 | VALIDATION_ERROR | Schema error (fields), including missing provider or invalid manual transfer evidence. Also returned for an invalid or missing tenant header. |
| 422 | UNBALANCED_JOURNAL | Debits or credits do not equal amountMinor. |
| 422 | ACCOUNT_INVALID | Account missing, inactive, another tenant's, or wrong currency. |
| 500 | INTERNAL_ERROR | Unhandled error. |
Side effects: approval row (PENDING, step 1), outbox finance.transaction.pending_approval, audit FINANCIAL_TRANSACTION_CREATED (amount and reference only), in-app notification to the creator, and an approval-required notification to finance checker roles.
POST /api/v1/finance/transactions/{transactionId}/approve
- Purpose. Approves a pending transaction. For
GENERIC(no provider), the ledger is posted immediately. For provider-backed transactions, the status moves toPROCESSING; a payment-execution job is queued, and posting waits for provider verification. - Role.
FINANCE_APPROVER(or PSA). - Path.
transactionId: UUID. An invalid value returns 422VALIDATION_ERRORafter authorization. - Body.
{"reason": "<5 to 500 characters>"}(strict).
curl -s -X POST https://dffms.org/api/v1/finance/transactions/0190f2c4-6b3e-7a1d-8c2f-000000000080/approve \
-H 'Cookie: __Secure-dffms.session_token=<session-cookie>' \
-H 'X-DFFMS-Tenant-Id: <tenantId>' \
-H 'Origin: https://dffms.org' \
-H 'Content-Type: application/json' \
-d '{"reason":"Evidence matched; approved for payment."}'
Response (200): the transaction row with status PROCESSING (provider) or SUCCEEDED with postedAt set (GENERIC), and approvedBy set.
Errors:
| HTTP | Code | Meaning |
|---|---|---|
| 401 | UNAUTHENTICATED / MFA_REQUIRED | Fresh session or MFA missing. |
| 403 | FORBIDDEN | Not an approver. |
| 403 | MAKER_CHECKER_VIOLATION | Creator is approving their own transaction (PSA exempt). |
| 404 | NOT_FOUND | Transaction missing in the tenant. |
| 409 | INVALID_STATE | Not PENDING_APPROVAL, or no pending approval row. |
| 422 | VALIDATION_ERROR | Reason invalid, or transaction id not a UUID. |
| 422 | UNBALANCED_JOURNAL, ACCOUNT_INVALID | Re-validation of the stored draft failed. |
Side effects: updates approval to APPROVED, writes the ledger entries (GENERIC only), applies financing-case effects on posting, resolves escalations, writes audit FINANCIAL_TRANSACTION_APPROVED_AND_POSTED or FINANCIAL_TRANSACTION_APPROVED_FOR_PROVIDER, queues notifications, and enqueues an outbox job (finance.transaction.posted or finance.payment.execution_requested).
POST /api/v1/finance/transactions/{transactionId}/reject
- Purpose. Declines a pending transaction. No ledger entries are written.
- Role.
FINANCE_APPROVER(or PSA). - Body.
{"reason": "<5 to 500 characters>"}(strict).
Response (200): the transaction row with status FAILED.
Errors: as for approve, except there is no UNBALANCED_JOURNAL path.
Side effects: approval REJECTED; transaction FAILED; outbox finance.transaction.rejected; audit FINANCIAL_TRANSACTION_REJECTED (with reason); notification to the creator; escalations resolved.
POST /api/v1/finance/transactions/{transactionId}/reversal
- Purpose. Requests a full reversal of a posted (
SUCCEEDED) transaction. Creates a linkedREVERSALtransaction inPENDING_APPROVAL, which another approver must approve. - Role.
FINANCE_ENTRY_OFFICERorFINANCE_APPROVER(or PSA). - Body. Strict object.
| Field | Type | Required | Rules |
|---|---|---|---|
reference | string | Yes | Same rules as transaction reference |
idempotencyKey | string | Yes | 16 to 128, same rules as transaction key |
reason | string | Yes | 10 to 500 |
curl -s -X POST https://dffms.org/api/v1/finance/transactions/0190f2c4-6b3e-7a1d-8c2f-000000000080/reversal \
-H 'Cookie: __Secure-dffms.session_token=<session-cookie>' \
-H 'X-DFFMS-Tenant-Id: <tenantId>' \
-H 'Origin: https://dffms.org' \
-H 'Content-Type: application/json' \
-d '{
"reference": "REV-2026-000145",
"idempotencyKey": "rev-2026-10-26-000145-key",
"reason": "Duplicate collection posted in error."
}'
Response (201): the new REVERSAL transaction row (status PENDING_APPROVAL, metadata.originalTransactionId set).
Errors:
| HTTP | Code | Meaning |
|---|---|---|
| 401 | UNAUTHENTICATED / MFA_REQUIRED | Session or MFA. |
| 403 | FORBIDDEN | Role not permitted. |
| 404 | NOT_FOUND | Original transaction missing. |
| 409 | INVALID_STATE | Original not SUCCEEDED, or no posted ledger. |
| 409 | REVERSAL_EXISTS | A non-failed reversal already exists for this original. |
| 409 | IDEMPOTENCY_CONFLICT | Key reused with different data. |
| 422 | VALIDATION_ERROR | Body invalid. |
Side effects: creates the correction, approval row, outbox finance.reversal.pending_approval, audit FINANCIAL_REVERSAL_REQUESTED, notifications to the requester and finance checker roles.
POST /api/v1/finance/transactions/{transactionId}/refund
- Purpose. Requests a partial or full refund of a posted transaction. Creates a linked
REFUNDinPENDING_APPROVAL. - Role.
FINANCE_ENTRY_OFFICERorFINANCE_APPROVER(or PSA). - Body. As for reversal, plus:
| Field | Type | Required | Rules |
|---|---|---|---|
amountMinor | string | Yes | Positive integer string |
The sum of non-failed refunds for the original must not exceed the original amount.
curl -s -X POST https://dffms.org/api/v1/finance/transactions/0190f2c4-6b3e-7a1d-8c2f-000000000080/refund \
-H 'Cookie: __Secure-dffms.session_token=<session-cookie>' \
-H 'X-DFFMS-Tenant-Id: <tenantId>' \
-H 'Origin: https://dffms.org' \
-H 'Content-Type: application/json' \
-d '{
"reference": "REF-2026-000145-A",
"idempotencyKey": "ref-2026-10-26-000145-a-key",
"amountMinor": "500000",
"reason": "Partial refund for over-collection on the receipt."
}'
Response (201): the new REFUND transaction row.
Errors: as for reversal, plus:
| HTTP | Code | Meaning |
|---|---|---|
| 409 | INVALID_STATE | Original not SUCCEEDED or PARTIALLY_REFUNDED. |
| 422 | INVALID_AMOUNT | Missing amount, zero, or above the unrefunded balance. |
Side effects: as for reversal, with audit FINANCIAL_REFUND_REQUESTED.