A $2,000 invoice becomes a ₹1,62,327.91 bank credit. Using a reference rate of ₹83.62, a quick script reports a 2.94% FX cost.
The arithmetic can be correct while the label is wrong.
That percentage includes every difference between the invoice's reference value and the final credit. It does not isolate conversion. In this fictional payment, $25 disappeared before conversion and ₹826.84 was deducted locally afterwards. Calling the whole gap FX would hide both.
Keep the evidence separate
The example has five records: a $2,000 invoice, a $1,975 amount converted, a bank rate of ₹82.61, a ₹1,62,327.91 credit, and ₹826.84 of separately identified local deductions. The reference rate, ₹83.62, is an invented comparison point, not a live market quote.
For a real comparison, save the reference-rate source and timestamp alongside the conversion time. A rate from the wrong time can make ordinary market movement look like a fee. This script does not fetch market rates or choose a benchmark for you.
The model covers one fully settled USD invoice converted to INR. It excludes partial settlements, refunds, multiple conversions and charges debited outside the credit. Those need a wider reconciliation model.
A small calculation with an explicit unknown
Python's Decimal documentation describes decimal arithmetic and rounding controls. Use decimal strings for these inputs, retain precision inside the calculation, and round only the displayed amounts.
from decimal import Decimal, ROUND_HALF_UP def amount(value, positive=False): n = Decimal(str(value)) if not n.is_finite() or n < 0 or (positive and n == 0): raise ValueError("Use a finite, non-negative number; rates must be positive") return n def reconcile(invoice_usd, credit_inr, reference_rate, local_deductions_inr, converted_usd=None, bank_rate=None): invoice = amount(invoice_usd, positive=True) credit = amount(credit_inr) reference = amount(reference_rate, positive=True) local = amount(local_deductions_inr) benchmark = invoice * reference result = { "reference_value_inr": benchmark, "total_shortfall_inr": benchmark - credit, "known_local_deductions_inr": local, "pre_conversion_gap_inr": None, "estimated_conversion_gap_inr": None, "unallocated_inr": benchmark - credit - local, } converted = None if converted_usd is None else amount(converted_usd, True) rate = None if bank_rate is None else amount(bank_rate, True) if converted is not None and converted > invoice: raise ValueError("This model excludes conversions above the invoice amount") if converted is None or rate is None: return result before = (invoice - converted) * reference conversion = converted * (reference - rate) result.update( pre_conversion_gap_inr=before, estimated_conversion_gap_inr=conversion, unallocated_inr=converted * rate - credit - local, ) return result if __name__ == "__main__": report = reconcile("2000", "162327.91", "83.62", "826.84", converted_usd="1975", bank_rate="82.61") for label, value in report.items(): display = "unknown" if value is None else str( value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) ) print(f"{label}: {display}") Save it as payment_shortfall.py and run python3 payment_shortfall.py. It uses the standard library and makes no network requests.
The reference value is ₹1,67,240.00. The total shortfall is ₹4,912.09. The pre-conversion gap is ₹2,090.50, and the estimated conversion gap is ₹1,994.75. Known local deductions are ₹826.84. The unallocated amount is ₹0.00.
Those labels are deliberate. The script can measure the pre-conversion gap, but the transfer record has to explain whether it was a bank charge or something else. The conversion gap is against your chosen benchmark; it is not proof of the provider's profit.
Missing data stays missing
Remove converted_usd and bank_rate from the example call. The script still calculates the overall shortfall. It subtracts the known local deductions, then leaves ₹4,085.25 unallocated. Both conversion-related fields say unknown.
That is a useful result. It tells you which evidence to request next.
Do not fill a missing deduction with zero just to make the report finish. A zero says you checked and there was no deduction. An unknown says you have not established it. This difference matters when comparing providers. One statement may itemise several charges while another shows only a final credit. The second route should not win because its missing fields were treated as free.
There is no way for this calculation to identify the actual conversion cost from an invoice and final credit alone.
Read a non-zero remainder before changing the formula
If the credit were ₹1,62,300.00 with all other inputs unchanged, ₹27.91 would remain unallocated. That might be an omitted charge, a different input, or the wrong settlement matched to the invoice. The script should flag the difference rather than force a balancing fee into the data.
A negative conversion difference is also possible if the bank rate is better than your reference. Keep the sign; do not relabel it as a positive cost. Invalid numeric input, including infinity and NaN, is rejected. A conversion above the invoice is rejected because it falls outside this small model.
Once you have consistent inputs, Gruv's side-by-side payment-cost assumptions calculator offers a separate way to explore percentage fees, fixed charges and FX assumptions. It is a planning tool, not evidence of what happened to a past transfer.
This tutorial is produced by Gruv and links to a Gruv tool. All example amounts and rates are synthetic. Documentation was checked on 29 October 2026.
Run the example, then repeat it with the amount converted omitted and check that your report leaves the unexplained part visible.