Taking a payment
A cart, a discount, tax, a charge, a receipt — start to finish, with
CkMoney doing every step that touches an amount. CommonKit does not
talk to a payment processor. It prepares the values you hand to one and reads the values it
hands back, so that what you charge is what you meant to charge.
Three rules carry the whole page:
- Never do arithmetic on money yourself. No
*, no/, no rounding by hand. Every calculation below is oneCkMoneycall, exact and rounded once. - Ask for exceptions wherever a wrong number costs money. By default a value that cannot be used comes back as a fallback, and the fallback for an amount is zero. On a form that is a convenience. In a payment it is an order for nothing.
- Say the currency every time.
currencydefaults to"USD". If you sell in anything else, a call without it is a bug waiting for its first customer.
The silent zero
Read this before anything else, because it is the mistake that ships:
CkMoney.Multiply("19.99", "8.25%"); // -> 0
The factor must be a plain number, "8.25%" is not one, so the call is rejected — and a
rejected calculation answers its fallback, 0. No error, no log line, a total of nothing.
Pass throwIfInvalid: true and the same call tells you what is wrong:
CkMoney.Multiply(
input: "19.99",
factor: "8.25%",
throwIfInvalid: true
);
// throws CkMoneyException:
// CkMoney.Multiply: cannot use "8.25%" (String) as a factor — not a number.
Every calculating call on this page passes throwIfInvalid: true. A percentage, by the way,
is read by CkNumber.Percent, which turns "8.25%" into the rate
0.0825:
CkMoney.Multiply("19.99", CkNumber.Percent("8.25%")); // -> 1.65
1. Check what came in
Prices from your own database are yours to trust. An amount typed into a form, read from a spreadsheet or sent by another system is not. Ask before you calculate, and show the reason when the answer is no:
CkMoney.IsAmount("ten dollars"); // -> false
CkMoney.Explain("ten dollars"); // -> "letters in the amount"
2. Calculate
Three lines at 19.99, 5.01 and 12.50; ten percent off; 8.25% sales tax on what is left.
string[] prices = { "$19.99", "$5.01", "12.50" };
string currency = "USD";
decimal subtotal = CkMoney.Total(
values: prices,
throwIfInvalid: true
); // -> 37.50
decimal discount = CkMoney.Multiply(
input: subtotal,
factor: 0.10m,
currency: currency,
throwIfInvalid: true
); // -> 3.75
decimal net = CkMoney.Subtract(
input: subtotal,
amount: discount,
currency: currency,
throwIfInvalid: true
); // -> 33.75
decimal tax = CkMoney.Multiply(
input: net,
factor: 0.0825m,
currency: currency,
throwIfInvalid: true
); // -> 2.78 (2.784375, rounded once, away from zero)
decimal total = CkMoney.Total(
values: new[] { net, tax },
throwIfInvalid: true
); // -> 36.53
Which method for which sum:
| You want | Call | Not |
|---|---|---|
| Several amounts added | Total |
+ in a loop |
| One amount less another | Subtract |
- |
| Tax, a discount, a quantity | Multiply |
* |
| A unit price from a total | Divide |
/ |
| An amount shared equally | Split |
Divide, whose three thirds make 99.99 |
| An amount shared by ratio — a discount over the lines, a revenue share | Allocate |
Multiply per share, which loses or invents cents |
| Another currency, at your rate | Convert |
Multiply, which rounds for the wrong currency |
To show the customer how the discount falls on each line, share it out by the lines' value. The parts always add back to the discount:
CkMoney.Allocate(
input: discount,
ratios: prices.Select(price => CkMoney.Amount(price))
); // -> [2.00, 0.50, 1.25]
3. Check what goes out
Before the amount leaves your code:
CkMoney.IsPositive(total); // -> true a charge is above zero
CkMoney.IsRounded(total, currency); // -> true no more decimals than the currency has
CkMoney.IsAtLeast(total, "0.50"); // -> true your processor's minimum charge
IsRounded is the one people skip. An amount with three decimals in a two-decimal currency
is refused by some processors and quietly rounded by others, and either way your records
and theirs now disagree.
4. Hand it to the processor
Processors take an amount in one of two shapes. Know which one yours wants.
Whole minor units — an integer count of the smallest unit, cents for the dollar. US$36.53
is 3653; ¥1099 is 1099, the yen having no smaller unit:
long amount = CkMoney.MinorUnits(
input: total,
currency: currency,
throwIfInvalid: true
); // -> 3653
Decimal text — the amount with exactly the currency's decimals, "10.00" and never "10":
string amount = CkMoney.Format(total, currency); // -> "36.53"
The currency code goes along in the casing the processor asks for. ISO writes codes in upper case; some processors want lower:
CkMoney.Currency(currency); // -> "USD"
CkMoney.Currency(currency, textCase: TextCase.Lowercase); // -> "usd"
A card network, an acquirer's settlement file or a bank message carries the currency as
three digits instead, and NumericCode writes those:
"840" for the dollar, "978" for the euro.
5. Read what comes back
The processor answers in the shape it was sent. Turn it back into an amount, and into text a person can read:
decimal charged = CkMoney.FromMinorUnits(3653, currency); // -> 36.53
CkMoney.Display(charged, currency); // -> "36.53 USD"
Then check it against what you asked for. The two amounts must be the same, and
SameAmount refuses to call them the same if they name different currencies:
CkMoney.SameAmount(charged, total); // -> true
A refund is never more than the charge:
CkMoney.IsAtMost("10.00", total); // -> true
CkMoney.IsAtMost("100.00", total); // -> false
Where a processor disagrees with ISO 4217
CommonKit counts a currency's decimals the way ISO 4217 does, and almost always your
processor does too. Where it does not, the library does not know — no processor is
written into it, because their rules are theirs to change. You say it per call, with
decimals (how many decimals the amount may carry) or scale (the power of ten the whole
units are written at).
The cases below are from each processor's own documentation, read on 2026-09-20. Treat the table as a pointer, not as the truth: check your processor's currency page before you go live in any currency on it, and check it again when you add one.
| Processor | Currency | Their rule | Say it with |
|---|---|---|---|
| Stripe | ISK, UGX | No decimals, yet written as if there were two: 5 ISK is 500, and the last two digits are always 00 |
MinorUnits(5, "ISK", scale: 2) → 500; FromMinorUnits(500, "ISK", scale: 2) → 5 |
| Stripe | HUF, TWD | Charges take two decimals; payouts must be whole units | IsRounded(amount, "HUF", decimals: 0) before a payout |
| Stripe | every currency | The code in lower case | Currency(code, textCase: TextCase.Lowercase) |
| PayPal | HUF, TWD | No decimals: "1000.50" is an error |
Format("1000.50", "HUF", decimals: 0) → "1001"; IsRounded("1000.50", "HUF", decimals: 0) → false |
| PayPal | — | Around 25 currencies in all; most of the world's are not accepted | Check their list; InCurrencies(code, "USD", "EUR", …) keeps a form to the ones you take |
Get the scale wrong and the damage is a factor of a hundred, in either direction, on a real card. That is the reason to read the processor's page and not only this one.
What CommonKit does not do
Everything that is the payment and not the amount: talking to the processor, idempotency
keys so a retry does not charge twice, webhooks, refunds and disputes, storing card data
(do not), exchange rates. Convert takes the rate from you; where it comes from and how
fresh it is are your decisions.