Appearance
Fee Calculation Flow
Fee engine that matches rules by acquirer/country/payment method and calculates fees per transaction type.
Overview
| Attribute | Value |
|---|---|
| Called by | OrchestratorService.calculateFees() (real-time) and BulkReprocessProcessorService (bulk) |
| Service | src/service/FeesCalculatorService.ts |
| Interface | src/repository/IFeesCalculatorService.ts |
| Data source | MongoDB financesFeeRules collection |
The fee engine queries MongoDB for matching fee rules (one per fee_type: IN and OUT), then dispatches to a type-specific calculator. It handles 5 transaction types with different formulas and business rules.
Flow Diagram
Input Schema
IFeesCalculatorRequest.body is one of:
IFeeCalculatorPayInRequest (PAYMENT, APMS_PAYMENT, REFUND, DISPUTE, VOID)
| Field | Type | Required | Description |
|---|---|---|---|
business_id | string | yes | Merchant identifier |
amount | number | yes | Transaction amount |
fee_type | string[] | yes | Always ["IN", "OUT"] |
detail_type | DetailTypeEnum | yes | Event type |
transaction_status | TransactionStatusEnum | yes | Success, Won, etc. |
currency_code | string | yes | ISO currency code |
acquirer | string | yes | Acquirer identifier |
payment_method_id | number | yes | Payment method ID |
issuing_country_id | number | no | Issuing country (for international detection) |
card_brand | string | no | Visa, Mastercard, etc. |
IFeeCalculatorWithdrawalRequest (WITHDRAWAL, TOPUP)
| Field | Type | Required | Description |
|---|---|---|---|
business_id | string | yes | Merchant identifier |
amount | number | yes | Transaction amount |
fee_type | string[] | yes | Always ["IN", "OUT"] |
detail_type | DetailTypeEnum | yes | WITHDRAWAL or TOPUP |
transaction_status | TransactionStatusEnum | yes | |
currency_code | string | yes | |
acquirer | string | yes | |
method | string | yes | Transfer method |
issuing_country_id | number | no |
Fee Rule Matching
Pipeline (per fee_type)
buildMatchPipelineRule() constructs a MongoDB aggregation pipeline against financesFeeRules:
PayIn match conditions:
business_id ∈ [rule.business_id] OR rule.business_id is null
status = active
acquirer = acquirer
payment_method_id = payment_method_id
fee_type = fee_type (IN or OUT)
currency_code = currency_code
country_code ∈ business.country_code OR null
risk_level ∈ [rule.risk_level] OR null
card_brand = card_brand OR rule.card_brand missing/nullWithdrawal match conditions:
business_id ∈ [rule.business_id] OR null
status = active
acquirer = acquirer
transaction_type = extract(detail_type) e.g. WITHDRAWAL, TOPUP
method = method
fee_type = fee_type
currency_code = currency_code
country_code ∈ business.country_code OR null
risk_level ∈ [rule.risk_level] OR nullRanking (specificity)
Rules are ranked by a _rank score — lower score = higher priority (more specific):
| Condition | Score contribution |
|---|---|
business_id matches (not null) | 0 |
business_id is null (generic rule) | 1 |
risk_level matches | 0 |
risk_level is null | 1 |
card_brand matches | 0 |
card_brand is null | 1 |
The pipeline sorts ascending on _rank and takes $limit: 1. The most specific rule wins.
NULL field handling
Fields like business_id and risk_level can be null in MongoDB (generic rules). The pipeline uses $or with $exists checks to handle missing or null values without filtering them out:
json
{ "$or": [
{ "business_id": { "$in": [businessId] } },
{ "business_id": null },
{ "business_id": { "$exists": false } }
]}Fee Calculation Formulas
PAYMENT / APMS_PAYMENT — calculatePaymentFees()
International detection:
APMS_PAYMENT→ alwaysisInternational = false- Others →
isInternational = issuingCountryId !== business.country_id
Rate selection:
isInternational = true→ useinter_transaction_rate,inter_transaction_feeisInternational = false→ useintra_transaction_rate,intra_transaction_fee
Per side (tonder / acquirer):
transaction_rate = amount × rate / 100
transaction_fee = fixed_fee
fee_amount = max(transaction_rate + transaction_fee, minimum_fee)
iva_amount = fee_amount × iva_rate / 100
rolling_reserve = amount × hold_reserve_percentage / 100
net_amount = amount - fee_amount - iva_amount - rolling_reserve_amountREFUND — calculateRefundFees()
transaction_rate = 0
fee_amount = refund_fee (fixed)
iva_amount = fee_amount × iva_rate / 100
rolling_reserve = 0
net_amount = amount + fee_amount + iva_amount ← refund added backDISPUTE — calculateDisputeFees()
transaction_rate = 0
fee_amount = chargeback_fee (fixed)
iva_amount = fee_amount × iva_rate / 100
rolling_reserve = 0
if transactionStatus = "Won":
net_amount = fee_amount + iva_amount ← only fees charged (merchant won)
else:
net_amount = amount + fee_amount + iva_amount ← full amount + fees (merchant lost)VOID — calculateVoidFees()
All rates and fees = 0
net_amount = amount ← void has no chargesWITHDRAWAL / TOPUP — calculateWithdrawalFees()
Always intra (no international variant)
transaction_rate = amount × intra_transaction_rate / 100
fee_amount = max(transaction_rate + intra_transaction_fee, minimum_fee)
iva_amount = fee_amount × iva_rate / 100
rolling_reserve = 0
net_amount = amount ← withdrawal amount unchanged; fees deducted separatelyTOPUP — default rules
TOPUP uses hardcoded default rules (no MongoDB lookup):
All rates = 0, all fees = 0
settlement_policy = fixed_days_delayOutput Structure
IFeesCalculatorResponse:
| Field | Description |
|---|---|
tonder | Tonder-side fee breakdown |
tonder.fee_amount | Tonder fee |
tonder.iva_amount | IVA on tonder fee |
tonder.net_amount | Net after tonder fees |
tonder.rolling_reserve_amount | Reserve held |
acquirer | Acquirer-side fee breakdown (same structure) |
acquirer.fee_amount | Acquirer fee |
acquirer.iva_amount | IVA on acquirer fee |
acquirer.net_amount | Net after acquirer fees |
fee_rules | The matched IN and OUT fee rule records |
isInternational | Whether the transaction was classified as international |
Error Codes
| Code | Condition |
|---|---|
E0012 | Business not found in MongoDB |
E0013 | Fee rule not found for the requested fee_type combination |
Non-Obvious Behaviors
| Behavior | Detail |
|---|---|
| APMS_PAYMENT always intra | bitso, oxxopay, mercadopago — detail_type mapped to APMS_PAYMENT. These are always isInternational = false regardless of issuing country. |
| TOPUP skips rule matching | TOPUP gets a hardcoded default rule (0% rates, fixed_days_delay settlement). No MongoDB query for fee rules. |
| minimum_fee clamp | fee_amount = max(rate + fixed, minimum_fee). If computed fee is below minimum, minimum applies. |
| Null fields use $or/$exists | MongoDB fee rules can have null business_id (generic rules). Pipeline uses $or to match both null and the actual value. |
| fee_type: ["IN", "OUT"] | Always requests both sides. The engine runs two separate MongoDB aggregations and must find rules for both. Missing either throws E0013. |
| Refund net is positive | net_amount = amount + fee + iva. Refunds add fees on top of the refunded amount (customer gets full amount back; merchant absorbs fees). |
| Dispute WON | When merchant wins, net_amount = fee + iva only — transaction amount not recovered in this step (handled separately). |
| Void zeroes everything | No rates, no fees, no IVA, no rolling reserve. net_amount = amount. |
| Withdrawal net = amount | For withdrawals, fees are tracked but not deducted from net_amount at this stage — they are handled as separate debit entries in accounting. |