A checkout page can make wildly different payment methods feel oddly similar. Cash, card installments, BNPL, even a personal loan all get compressed into some friendly-looking monthly number, and if you're moving fast it's easy to compare them as if they're just variations of the same thing.
When I put them into one component, that illusion fell apart immediately. The interesting part wasn't the UI. It was that each method needed a different cost model: card installments are treated like add-on interest plus a flat fee, BNPL is basically free until you miss, and loans are the only case that actually behave like an amortizing debt. Even the input field changes meaning depending on locale.
The first problem is linguistic, not financial
One small detail I liked in the component is that it doesn't treat localization as pure text replacement. For four locales — Traditional Chinese, Simplified Chinese, Japanese, and Korean — the amount field uses a ten-thousand-based unit instead of plain thousands:
const myriadLocales = ["tw", "cn", "jp", "kr"];
const isMyriad = computed(() => myriadLocales.includes(locale.value));
const initialAmount = isMyriad.value ? 30000 : 1000;
const state = reactive({
amount: initialAmount,
bnplLateFee: isMyriad.value ? 300 : 10,
});
const amountDisplay = computed({
get: () =>
isMyriad.value
? Math.round((state.amount / 10000) * 100) / 100
: state.amount,
set: (val) => {
const num = Number(val) || 0;
state.amount = isMyriad.value ? Math.round(num * 10000) : Math.round(num);
},
});
So if a Taiwanese or Japanese user types 5, the component doesn't store 5. It stores 50000. Internally, state.amount stays in the raw currency amount, while the UI presents a culturally normal unit. That's a much better choice than forcing every locale into the same numeric shape and hoping the labels explain it away.
It also means the defaults aren't arbitrary. The initial purchase amount is 30000 in the ten-thousand locales and 1000 elsewhere, and the default BNPL late fee jumps from 10 to 300 on the same split. In other words, the component quietly assumes that "reasonable demo values" are locale-dependent too.
That sounds obvious in hindsight, but a lot of calculators don't do it. They translate the headline, leave the number model untouched, and end up with a tool that's technically internationalized but still mentally off for half its audience.
Card installments, BNPL, and loans are not the same kind of debt
The core comparison lives in one computed block, and the most useful thing about it is that it doesn't try to unify the formulas too aggressively:
const P = Math.max(0, state.amount || 0);
const cash = {
key: "cash",
totalCost: P,
extraCost: 0,
monthlyPayment: null,
};
const instMonths = Math.max(1, state.installmentMonths || 1);
const instInterest = P * (state.installmentAPR / 100) * (instMonths / 12);
const instFee = P * (state.installmentFeeRate / 100);
const instTotal = P + instInterest + instFee;
const bnplN = Math.max(1, state.bnplInstallments || 1);
const missed = Math.min(state.bnplMissedPayments || 0, bnplN - 1);
const lateCost = missed * (state.bnplLateFee || 0);
const bnplTotal = P + lateCost;
Cash is the baseline: total cost equals principal, no monthly payment, no extra cost. That monthlyPayment: null is a nice touch because the template uses it to switch the label from "monthly payment" to "one-time payment" instead of pretending everything belongs in the same monthly bucket.
The installment branch is where the tool gets opinionated in a useful way. It uses:
- add-on interest:
P * APR * (months / 12) - plus a one-time fee:
P * feeRate
That means a "0% installment" plan is only free if the fee rate is also zero. If the merchant or issuer quietly charges 3% handling, the calculator surfaces that immediately. That's probably the most practical thing the whole tool does, because a lot of real checkout pages encourage people to fixate on the monthly number and ignore the one-off fee.
BNPL is modeled very differently. There's no interest curve here at all. The monthly payment is just principal divided by installment count, and the only penalty comes from missed payments:
const bnpl = {
key: "bnpl",
totalCost: bnplTotal,
extraCost: lateCost,
monthlyPayment: P / bnplN,
};
I think that's the correct bias for a consumer-facing comparison tool. BNPL usually is close to free if you pay on time. The risk is nonlinear: it stays boring right up until you miss, then suddenly the deal is no longer "free money," it's "a flat fee that may be absurd relative to the amount financed." Modeling that as explicit late-fee accumulation makes that risk visible without pretending BNPL behaves like a traditional loan.
The loan branch is the only place the code uses real amortization
Personal loans get their own formula, and it's the classic amortizing-payment equation rather than the simpler installment math:
const n = Math.max(1, state.loanMonths || 1);
const r = state.loanAPR / 100 / 12;
let M;
if (r === 0) {
M = P / n;
} else {
M = (P * r * Math.pow(1 + r, n)) / (Math.pow(1 + r, n) - 1);
}
const loanTotal = M * n;
const list = [cash, installment, bnpl, loan];
const minCost = Math.min(...list.map((i) => i.totalCost));
list.forEach((i) => {
i.isBest = Math.abs(i.totalCost - minCost) < 0.5;
});
Two things here are worth calling out.
First, the r === 0 branch matters. Without it, a zero-rate loan would hit the (Math.pow(1 + r, n) - 1) denominator and turn into a divide-by-zero problem. The code explicitly falls back to P / n, which is exactly what you'd want from a no-interest financing scenario.
Second, this is why installments and loans shouldn't be collapsed into one generic "APR calculator." Even if the APR numbers look similar in the UI, the economics are different. The installment branch is using add-on interest plus fees; the loan branch is using declining-balance amortization. Those can produce very different total costs for what sounds, in marketing copy, like the same annual rate.
I also like that the "best" result is derived once and reused everywhere. Any method within 50 cents of the minimum gets isBest = true, so the UI can honestly show ties instead of inventing fake precision. The chart then colors winners green and everything else with a method-specific color, using the exact same results array the cards use. That's a good pattern for finance-ish UI: one source of truth, then multiple views of it.
The honest gotchas in this implementation
There are a few limitations here that are actually worth saying out loud.
-
Everything gets rounded to whole units.
formatNumber()doesMath.round(num).toLocaleString(), and the chart also feeds onMath.round(i.totalCost). So if two methods differ by cents, the component intentionally smooths that away. That's probably fine for consumer comparisons, but it is absolutely not a cents-accurate calculator. -
BNPL missed payments are capped at
installments - 1. The slider max isstate.bnplInstallments - 1, and the math mirrors that withMath.min(...). So a 4-installment BNPL plan can only simulate 0 through 3 missed payments. If you wanted to model being late on every single installment, this UI won't let you. - The installment model is simplified on purpose. It assumes add-on interest plus one flat fee. Real issuer programs can be weirder: subsidized merchant financing, fee baked into sticker price, statement-cycle quirks, or penalties that don't look like APR at all. So the comparison is useful, but it's still a model, not a contract parser.
-
Some locale-sensitive defaults are decided only once.
initialAmountand the startingbnplLateFeeare derived fromisMyriad.valueduring setup. If the app switches locale live without remounting this component, those initial defaults won't recalculate themselves. That's not catastrophic, but it is the kind of subtle state behavior that shows up once you start testing language switches.
That mix of pragmatism and imperfection is honestly why the component feels believable to me. It isn't pretending to solve all consumer finance math. It's drawing a clean comparison between four payment stories that websites love to blur together.
I mostly built this because I got tired of checkout pages making "monthly payment" do all the rhetorical work, so I turned it into a small free tool: Payment Method Comparator.
Available in other languages
- 怎麼付款最划算比較器 — 繁體中文
- 怎么付款最划算比较器 — 简体中文
- Payment Method Comparator — English
- 支払い方法比較シミュレーター — 日本語
- 결제 방법 비교 계산기 — 한국어
- Comparateur de moyens de paiement — Français
- Сравнение способов оплаты — Русский
- Zahlungsmethoden-Vergleich — Deutsch
- Kalkulator Perbandingan Metode Pembayaran — Bahasa Indonesia
- Comparador de métodos de pago — Español
- Công cụ so sánh phương thức thanh toán — Tiếng Việt
- เครื่องมือเปรียบเทียบวิธีชำระเงิน — ไทย
- Porównanie metod płatności — Polski
- Ödeme Yöntemi Karşılaştırma Aracı — Türkçe
- Comparatore di metodi di pagamento — Italiano
- Comparador de Métodos de Pagamento — Português
- Betaalmethode Vergelijker — Nederlands
- Порівняння способів оплати — Українська
Top comments (0)