Skip to main content

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-Id are required.
  • The session must be created within the last 5 minutes (requireFreshSession). Failure returns 401 UNAUTHENTICATED with message "Fresh authentication is required".
  • Approve, reject, create, reversal and refund also require MFA enrolment (requireStepUp). Failure: 401 MFA_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).
FieldTypeRequiredRules
referencestringYes8 to 100; ^[A-Za-z0-9][A-Za-z0-9._:/-]+$; unique per tenant
idempotencyKeystringYes16 to 128; ^[A-Za-z0-9][A-Za-z0-9._:-]+$
typeenumYesCOLLECTION, DISBURSEMENT, ADJUSTMENT
amountMinorstringYesPositive integer string, up to 19 digits
currencystringYesISO 4217 uppercase
providerenumCollection and disbursement: Yes (or manual transfer, see rules)FLUTTERWAVE, PAYSTACK, FINCRA, SQUAD, GENERIC
beneficiaryIdUUIDNo
programmeIdUUIDNoScope; defaults from financing case metadata if present
sapzIdUUIDNoScope
institutionIdUUIDNoScope
entriesarrayYes2 to 100 journal lines: accountId (UUID), direction (DEBIT or CREDIT), amountMinor (positive integer string), memo (max 500, optional)
metadataobjectNoFree-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. GENERIC is accepted only as a manual bank transfer with evidence in metadata.
  • ADJUSTMENT does 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:

HTTPCodeMeaning
400INVALID_BODY / INVALID_CONTENT_TYPEBody not JSON, empty, or wrong content type.
401UNAUTHENTICATEDNo session, or session older than 5 minutes.
401MFA_REQUIREDNo MFA enrolment.
403FORBIDDENRole not permitted.
403ORIGIN_DENIEDOrigin not allowed.
409IDEMPOTENCY_CONFLICTKey reused with different data, or reference already exists.
413PAYLOAD_TOO_LARGEBody over 256 KiB.
422VALIDATION_ERRORSchema error (fields), including missing provider or invalid manual transfer evidence. Also returned for an invalid or missing tenant header.
422UNBALANCED_JOURNALDebits or credits do not equal amountMinor.
422ACCOUNT_INVALIDAccount missing, inactive, another tenant's, or wrong currency.
500INTERNAL_ERRORUnhandled 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 to PROCESSING; a payment-execution job is queued, and posting waits for provider verification.
  • Role. FINANCE_APPROVER (or PSA).
  • Path. transactionId: UUID. An invalid value returns 422 VALIDATION_ERROR after 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:

HTTPCodeMeaning
401UNAUTHENTICATED / MFA_REQUIREDFresh session or MFA missing.
403FORBIDDENNot an approver.
403MAKER_CHECKER_VIOLATIONCreator is approving their own transaction (PSA exempt).
404NOT_FOUNDTransaction missing in the tenant.
409INVALID_STATENot PENDING_APPROVAL, or no pending approval row.
422VALIDATION_ERRORReason invalid, or transaction id not a UUID.
422UNBALANCED_JOURNAL, ACCOUNT_INVALIDRe-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 linked REVERSAL transaction in PENDING_APPROVAL, which another approver must approve.
  • Role. FINANCE_ENTRY_OFFICER or FINANCE_APPROVER (or PSA).
  • Body. Strict object.
FieldTypeRequiredRules
referencestringYesSame rules as transaction reference
idempotencyKeystringYes16 to 128, same rules as transaction key
reasonstringYes10 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:

HTTPCodeMeaning
401UNAUTHENTICATED / MFA_REQUIREDSession or MFA.
403FORBIDDENRole not permitted.
404NOT_FOUNDOriginal transaction missing.
409INVALID_STATEOriginal not SUCCEEDED, or no posted ledger.
409REVERSAL_EXISTSA non-failed reversal already exists for this original.
409IDEMPOTENCY_CONFLICTKey reused with different data.
422VALIDATION_ERRORBody 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 REFUND in PENDING_APPROVAL.
  • Role. FINANCE_ENTRY_OFFICER or FINANCE_APPROVER (or PSA).
  • Body. As for reversal, plus:
FieldTypeRequiredRules
amountMinorstringYesPositive 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:

HTTPCodeMeaning
409INVALID_STATEOriginal not SUCCEEDED or PARTIALLY_REFUNDED.
422INVALID_AMOUNTMissing amount, zero, or above the unrefunded balance.

Side effects: as for reversal, with audit FINANCIAL_REFUND_REQUESTED.