Introduction
Money bugs are expensive and embarrassing. A rounding error that seems trivial in isolation—like a single cent lost on one transaction—can silently compound across millions of payments until you've leaked thousands of dollars or owe customers refunds. Storing money correctly for Payments & Stripe developers isn't optional; it's a foundational requirement that prevents data corruption, reconciliation nightmares, and regulatory headaches.
In this guide, we'll walk through the concrete rules for representing monetary values in your payment systems. You'll learn why certain approaches fail, how Stripe itself handles money, and the exact patterns we've used to keep payment systems bug-free across trading platforms, e-commerce, and SaaS.
Integers in minor units
The golden rule: always store monetary amounts as integers in the smallest currency unit. For USD, GBP, EUR, and most currencies, that's cents. For JPY, it's yen (which has no fractional unit). For BTC, it might be satoshis.
Why integers? Because integers are exact. Floating-point numbers introduce rounding errors that are impossible to track and debug reliably. When you store 19.99 USD as a float, you're not actually storing 19.99—you're storing something like 19.989999999999997. Multiply that imprecision across thousands of transactions and reconciliation breaks.
Stripe itself operates entirely in minor units. When you create a charge or payment intent, the amount parameter is always an integer in cents (for USD) or the equivalent minor unit for the currency.
Here's how to structure this in TypeScript:
// ❌ Wrong: floating-point storage
interface Order {
totalPrice: number; // 19.99 as a float—dangerous
}
// ✅ Correct: integer in minor units
interface Order {
totalPriceMinorUnits: number; // 1999 cents
currency: string; // "USD"
}
// When creating a Stripe charge
const charge = await stripe.charges.create({
amount: order.totalPriceMinorUnits, // Already in cents
currency: order.currency,
source: "tok_visa"
});In PostgreSQL, store this as a BIGINT (or NUMERIC for future-proofing against extreme edge cases):
// SQL schema
CREATE TABLE orders (
id UUID PRIMARY KEY,
total_price_minor_units BIGINT NOT NULL,
currency TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
// Query example
const order = await db.query(
'SELECT * FROM orders WHERE id = $1',
[orderId]
);
console.log(order.total_price_minor_units); // 1999 (cents)
When displaying to users, convert back to major units only at the UI layer:
// Convert minor units to display format (major units)
function formatPrice(minorUnits: number, currency: string): string {
const majorUnits = minorUnits / 100; // Assumes 2 decimal places
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency
}).format(majorUnits);
}
console.log(formatPrice(1999, 'USD')); // $19.99
Store currency with every amount
Never store an amount without its currency. A number like 1999 is meaningless on its own—is it 19.99 USD, 19.99 EUR, or something else entirely? When you later query that value without currency context, you'll make wrong decisions.
Currency context is especially critical in multi-currency systems (e-commerce, travel, SaaS with global customers). A stored amount must always be a tuple: (amount, currency).
Structure your data models to enforce this:
// ❌ Wrong: amount without currency
interface Payment {
amount: number;
}
// ✅ Correct: amount and currency as a pair
interface Payment {
amountMinorUnits: number;
currency: string; // ISO 4217 code: "USD", "EUR", "JPY"
}
// Or use a dedicated type
type Money = {
amountMinorUnits: number;
currency: string;
};
interface Payment {
money: Money;
}When you retrieve payment data, include both fields in every query. Create database views or ORM models that enforce this pairing:
// NestJS + TypeORM example
@Entity()
export class Payment {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column('bigint')
amountMinorUnits: number;
@Column('varchar', { length: 3 })
currency: string; // Always a 3-letter ISO code
@BeforeInsert()
validateCurrency() {
if (!this.currency || this.currency.length !== 3) {
throw new Error('Currency must be a 3-letter ISO 4217 code');
}
}
}
Validate that currency codes are real. Use a whitelist of valid ISO 4217 codes or a library that maintains them:
const VALID_CURRENCIES = new Set([
'USD', 'EUR', 'GBP', 'JPY', 'AUD', 'CAD', 'CHF'
// ... full list
]);
function validateMoney(money: Money): boolean {
return (
money.amountMinorUnits >= 0 &&
VALID_CURRENCIES.has(money.currency)
);
}
Zero-decimal currencies like JPY
Some currencies don't have fractional units. Japanese Yen (JPY), Korean Won (KRW), and a few others are "zero-decimal" in Stripe's terminology. An amount of 1000 JPY is stored as 1000, not 100000.
This means the conversion formula from minor units to major units varies by currency. You can't blindly divide by 100—you need to know the decimal places for each currency.
Create a mapping of currencies to their decimal places:
const CURRENCY_DECIMAL_PLACES: Record = {
// Standard: 2 decimal places
'USD': 2,
'EUR': 2,
'GBP': 2,
'AUD': 2,
// Zero-decimal: 0 decimal places
'JPY': 0,
'KRW': 0,
'VND': 0,
// 3 decimal places (rare)
'BHD': 3,
'KWD': 3,
};
function minorUnitsToMajor(
minorUnits: number,
currency: string
): number {
const decimalPlaces = CURRENCY_DECIMAL_PLACES[currency];
if (decimalPlaces === undefined) {
throw new Error(`Unknown currency: ${currency}`);
}
return minorUnits / Math.pow(10, decimalPlaces);
}
// Examples:
console.log(minorUnitsToMajor(1999, 'USD')); // 19.99
console.log(minorUnitsToMajor(1999, 'JPY')); // 1999 (not 19.99)
When integrating with Stripe, always use Stripe's decimal places specification. Stripe documents which currencies are zero-decimal; use that as your source of truth:
// Stripe's zero-decimal currencies (as of recent API versions)
const STRIPE_ZERO_DECIMAL_CURRENCIES = new Set([
'BIF', 'CLP', 'DJF', 'GNF', 'JPY', 'KMF', 'KRW',
'MGA', 'PYG', 'RWF', 'VND', 'VUV', 'XAF', 'XOF', 'XPF'
]);
function getDecimalPlaces(currency: string): number {
return STRIPE_ZERO_DECIMAL_CURRENCIES.has(currency) ? 0 : 2;
}
Never use floating point
Floating-point arithmetic is fundamentally unsuitable for money. Every operation introduces rounding errors. Even a simple calculation like adding two prices can lose precision:
// ❌ Don't do this
const price1 = 10.05;
const price2 = 10.10;
const total = price1 + price2;
console.log(total); // 20.150000000000002, not 20.15
When you use floating-point, you're committed to rounding errors at every step. By the time you sum 10,000 transactions, the accumulated error is unpredictable and unreliable.
Stick to integers throughout your payment logic. Convert to floats only when displaying to users (and even then, use a formatted string library, not raw floats):
// ✅ Correct: integer arithmetic until display time
const price1MinorUnits = 1005; // $10.05
const price2MinorUnits = 1010; // $10.10
const totalMinorUnits = price1MinorUnits + price2MinorUnits; // 2015 cents
console.log(totalMinorUnits); // 2015 (exact)
// Only convert to string for display
const displayPrice = (totalMinorUnits / 100).toFixed(2); // "20.15"
If you're using a language or framework that doesn't have native big-integer support, use a library. JavaScript has BigInt for large integers; Python has native arbitrary-precision integers; most languages have a standard library option.
If you're working with a backend that must handle money calculations, use a decimal or fixed-point library only if you're certain you'll never trust the system of record. Stripe and payment processors are your system of record—your database should mirror their values exactly. Match them with integers in minor units.
Common mistakes
- Storing amounts as floats and hoping rounding works out: It won't. The moment you store 19.99 as a float, you've lost precision. Always use integers.
- Forgetting currency on amounts: You'll retrieve a number like 1500 weeks later and have no idea if it's USD, EUR, or JPY. Store currency with every amount.
- Assuming all currencies have 2 decimal places: JPY, KRW, and others don't. Build currency-aware decimal-place logic from day one, not as an afterthought.
- Converting floats to integers via rounding instead of validation: If you receive a float from a client, validate it and reject it or convert it safely. Don't round silently; that masks data quality issues.
- Trusting Stripe's API response amounts as floats: Stripe returns amounts as integers in JSON. Treat them as integers in your code. If a library tries to auto-convert to float, disable it.
- Comparing amounts with floating-point equality: Never do
if (amount === 19.99)with floats. Use integer comparisons or libraries designed for decimal comparison.
Key takeaways
- Store all monetary amounts as integers in the smallest currency unit (cents for USD, yen for JPY, etc.).
- Always pair every amount with its currency code in your data model. Never store an amount without currency context.
- Account for zero-decimal currencies (JPY, KRW, etc.). Use a decimal-places mapping to convert between minor and major units correctly.
- Never use floating-point for money calculations. Use integer arithmetic for all payment logic; convert to floats only for display.
- Validate currency codes and amount ranges at entry points. Reject invalid data early rather than letting corruption spread through your system.
- Match Stripe's representation exactly. Stripe uses integers in minor units; your database should too. This makes reconciliation and audits straightforward.






Comments
Loading comments…