What is missing
The app offers three refund-shaped transactions — Merchant Issued Refund, Payout Refund and Goodwill Credit. The platform accepts two more that have no UI at all.
Confirmed against a running apache/fineract using the #268 discriminator, where an unrecognised command answers error.msg.query.parameter.value.unsupported on the command parameter:
| Probe |
Result |
POST /loans/{id}/transactions?command=totallyNotACommand |
error.msg.query.parameter.value.unsupported — unrecognised (control) |
POST /loans/{id}/transactions?command=writeoff |
validation.msg.loan.transaction.transactionDate.cannot.be.blank — recognised (control) |
POST /loans/{id}/transactions?command=refundByCash |
validation.msg.loan.transaction.transactionDate.cannot.be.blank — recognised |
POST /loans/{id}/transactions?command=chargeRefund |
validation.msg.loan.charge.refund.transaction.loanChargeId.cannot.be.blank — recognised |
Permissions REFUNDBYCASH_LOAN and CHARGEREFUND_LOAN are both present in GET /permissions.
What the UI offers today
The Actions menu on a real progressive Active loan — 17 entries, neither refund among them:

The two halves are not the same size
Refund by cash needs only a date and an amount, which is exactly what the existing generic transaction form already sends. loan-transaction-form.component.ts is driven by the route's :type parameter and posts postLoansLoanIdTransactions(loanId, transaction, type) with transactionDate, dateFormat, locale, transactionAmount and paymentTypeId. Adding it is a menu entry, a title key, AMOUNT_VISIBLE_TYPES membership and an i18n string — no new form.
Charge refund additionally requires loanChargeId, so it needs a picker listing the loan's charges. That is a genuinely new field on the shared form, and worth doing separately rather than bolting onto the simpler change.
A stale comment to fix while in here
loan-transaction-form.component.ts:406 says:
the transaction template endpoint accepts fewer commands than the transaction endpoint itself — reAmortize, chargeRefund and undowriteoff are rejected there with "unsupported value" even though the POST works.
But NO_TEMPLATE_TYPES is { approve, undoDisbursal, reAmortize, undowriteoff } — chargeRefund is not in the set the comment describes. Today that is harmless, because nothing can reach the form with that type. The moment charge refund is offered, the form will issue a template request the comment already predicts will fail. Either the comment is wrong or the set is missing an entry, and whoever implements charge refund needs to settle which.
Why it is worth doing
Refund by cash is how an over-payment is handed back across the counter — the ordinary counterpart to taking a repayment, and the one refund route that needs no card or wallet rail behind it. Charge refund reverses a fee that should not have been levied, which today can only be approximated with a goodwill credit; that records a gift rather than a correction, and the two are not the same thing in the ledger.
Environment
main at 7c51d90b. Probes against apache/fineract running locally via deploy/docker-compose-e2e.yml; screenshot from a loan seeded with the repository's own e2e/utils/seed-api.ts helpers.
What is missing
The app offers three refund-shaped transactions — Merchant Issued Refund, Payout Refund and Goodwill Credit. The platform accepts two more that have no UI at all.
Confirmed against a running
apache/fineractusing the #268 discriminator, where an unrecognised command answerserror.msg.query.parameter.value.unsupportedon thecommandparameter:POST /loans/{id}/transactions?command=totallyNotACommanderror.msg.query.parameter.value.unsupported— unrecognised (control)POST /loans/{id}/transactions?command=writeoffvalidation.msg.loan.transaction.transactionDate.cannot.be.blank— recognised (control)POST /loans/{id}/transactions?command=refundByCashvalidation.msg.loan.transaction.transactionDate.cannot.be.blank— recognisedPOST /loans/{id}/transactions?command=chargeRefundvalidation.msg.loan.charge.refund.transaction.loanChargeId.cannot.be.blank— recognisedPermissions
REFUNDBYCASH_LOANandCHARGEREFUND_LOANare both present inGET /permissions.What the UI offers today
The Actions menu on a real progressive Active loan — 17 entries, neither refund among them:
The two halves are not the same size
Refund by cash needs only a date and an amount, which is exactly what the existing generic transaction form already sends.
loan-transaction-form.component.tsis driven by the route's:typeparameter and postspostLoansLoanIdTransactions(loanId, transaction, type)withtransactionDate,dateFormat,locale,transactionAmountandpaymentTypeId. Adding it is a menu entry, a title key,AMOUNT_VISIBLE_TYPESmembership and an i18n string — no new form.Charge refund additionally requires
loanChargeId, so it needs a picker listing the loan's charges. That is a genuinely new field on the shared form, and worth doing separately rather than bolting onto the simpler change.A stale comment to fix while in here
loan-transaction-form.component.ts:406says:But
NO_TEMPLATE_TYPESis{ approve, undoDisbursal, reAmortize, undowriteoff }—chargeRefundis not in the set the comment describes. Today that is harmless, because nothing can reach the form with that type. The moment charge refund is offered, the form will issue a template request the comment already predicts will fail. Either the comment is wrong or the set is missing an entry, and whoever implements charge refund needs to settle which.Why it is worth doing
Refund by cash is how an over-payment is handed back across the counter — the ordinary counterpart to taking a repayment, and the one refund route that needs no card or wallet rail behind it. Charge refund reverses a fee that should not have been levied, which today can only be approximated with a goodwill credit; that records a gift rather than a correction, and the two are not the same thing in the ledger.
Environment
mainat7c51d90b. Probes againstapache/fineractrunning locally viadeploy/docker-compose-e2e.yml; screenshot from a loan seeded with the repository's owne2e/utils/seed-api.tshelpers.