Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
333 changes: 333 additions & 0 deletions src/lib/feeCalculations.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,333 @@
/**
* Fee, commission, and net-proceeds calculation tests.
*
* Covers every financial formula that is shown to the investor in the Deposit
* screen and that could silently regress:
*
* • DEPOSIT_FEE_USDC constant (the platform fee shown in the UI)
* • Net proceeds: amount − fee (what the investor actually puts to work)
* • projectedReturn: simple-interest growth over 1 / 5 / 10 years
* • roundToCents / formatDecimal: the shared precision used on all money fields
* • Deposit fee is sub-cent (< $0.01) — the promise made in the UI copy
* • Fee is applied once, not per year (integration sanity check)
* • Edge cases: zero amount, minimum deposit, large amounts, floating-point traps
*/

import { describe, it, expect } from 'vitest'
import { roundToCents, formatDecimal, roundToDecimals } from '../lib/format'
import { projectedReturn } from '../lib/bondUtils'

// ─── Constants mirrored from Deposit.tsx ────────────────────────────────────
// These are kept as plain numbers here so that if Deposit.tsx ever changes them
// the test will fail loudly, alerting the developer to update the UI copy.
const DEPOSIT_FEE_USDC = 0.01
const MIN_DEPOSIT_USDC = 1

// ─── Helper: net proceeds formula used in the Deposit preview ───────────────
/**
* Net proceeds = amount − fee, rounded to cents.
* Mirrors the inline expression in Deposit.tsx:
* `roundToCents(n - DEPOSIT_FEE_USDC)`
*/
function netProceeds(amount: number, fee = DEPOSIT_FEE_USDC): number {
return roundToCents(amount - fee)
}

// ────────────────────────────────────────────────────────────────────────────

describe('DEPOSIT_FEE_USDC constant', () => {
it('equals exactly $0.01', () => {
expect(DEPOSIT_FEE_USDC).toBe(0.01)
})

it('is a sub-cent fee (< $0.01 is the UI promise, $0.01 is the cap)', () => {
// The Deposit screen shows "Fee: < $0.01" — the fee must be ≤ $0.01
expect(DEPOSIT_FEE_USDC).toBeLessThanOrEqual(0.01)
})

it('is positive and finite', () => {
expect(DEPOSIT_FEE_USDC).toBeGreaterThan(0)
expect(Number.isFinite(DEPOSIT_FEE_USDC)).toBe(true)
})

it('is smaller than the minimum deposit amount', () => {
// A fee larger than the min deposit would make the net proceeds negative
expect(DEPOSIT_FEE_USDC).toBeLessThan(MIN_DEPOSIT_USDC)
})
})

// ────────────────────────────────────────────────────────────────────────────

describe('Net proceeds (amount − fee)', () => {
it('subtracts the fee from a standard 100 USDC deposit', () => {
expect(netProceeds(100)).toBe(99.99)
})

it('subtracts the fee from the minimum deposit of 1 USDC', () => {
expect(netProceeds(1)).toBe(0.99)
})

it('returns zero for a fee-sized deposit (amount === fee)', () => {
expect(netProceeds(DEPOSIT_FEE_USDC)).toBe(0)
})

it('returns zero or negative when amount is at or below the fee', () => {
// 0.005 - 0.01 = -0.005 → roundToCents rounds to 0 due to epsilon handling.
// This should not be reachable in the UI (button is disabled below MIN),
// but the formula must not return a positive value at this boundary.
expect(netProceeds(0.005)).toBeLessThanOrEqual(0)
})

it('handles large deposit amounts without precision loss', () => {
// 100,000 USDC − $0.01 fee
expect(netProceeds(100_000)).toBe(99_999.99)
})

it('rounds to exactly 2 decimal places (cents)', () => {
// 50.005 has no exact binary float representation; roundToCents must
// handle the epsilon correctly
const result = netProceeds(50.015)
const digits = result.toString().split('.')[1] ?? ''
expect(digits.length).toBeLessThanOrEqual(2)
})

it('uses roundToCents so the result equals roundToCents(amount - fee)', () => {
const amount = 123.45
expect(netProceeds(amount)).toBe(roundToCents(amount - DEPOSIT_FEE_USDC))
})

it('is consistent: applying the fee twice is not the same as once', () => {
const once = netProceeds(100)
const twice = netProceeds(netProceeds(100))
expect(once).not.toBe(twice)
expect(once).toBeGreaterThan(twice)
})
})

// ────────────────────────────────────────────────────────────────────────────

describe('projectedReturn — simple interest', () => {
it('returns zero for zero amount', () => {
expect(projectedReturn(0, 6, 1)).toBe(0)
})

it('returns zero for negative amount', () => {
expect(projectedReturn(-100, 6, 1)).toBe(0)
})

it('returns zero for non-finite amount (NaN)', () => {
expect(projectedReturn(NaN, 6, 1)).toBe(0)
})

it('returns zero for non-finite amount (Infinity)', () => {
expect(projectedReturn(Infinity, 6, 1)).toBe(0)
})

it('calculates 1-year return: 100 USDC at 6% = 6 USDC', () => {
expect(projectedReturn(100, 6, 1)).toBe(6)
})

it('calculates 5-year return: 100 USDC at 6% = 30 USDC', () => {
expect(projectedReturn(100, 6, 5)).toBe(30)
})

it('calculates 10-year return: 100 USDC at 6% = 60 USDC', () => {
expect(projectedReturn(100, 6, 10)).toBe(60)
})

it('scales linearly with years (simple, non-compounding interest)', () => {
const base = projectedReturn(1000, 5, 1)
expect(projectedReturn(1000, 5, 2)).toBe(base * 2)
expect(projectedReturn(1000, 5, 10)).toBe(base * 10)
})

it('scales linearly with amount', () => {
const unit = projectedReturn(1, 7, 1)
expect(projectedReturn(1000, 7, 1)).toBeCloseTo(unit * 1000)
})

it('handles fractional yield rates (e.g. 4.75%)', () => {
// 200 USDC × 4.75% × 3 years = 28.5
expect(projectedReturn(200, 4.75, 3)).toBeCloseTo(28.5)
})

it('defaults years to 1 when not provided', () => {
expect(projectedReturn(500, 8)).toBe(projectedReturn(500, 8, 1))
})

it('handles zero yield rate (no return)', () => {
expect(projectedReturn(1000, 0, 5)).toBe(0)
})

it('handles very small amounts without returning negative', () => {
expect(projectedReturn(0.01, 6, 1)).toBeGreaterThan(0)
})

it('matches the Deposit screen formula for the three displayed projections', () => {
// The Deposit screen shows returns at 1, 5, and 10 years using roundToCents
const amount = 100
const rate = 6.5 // example pool rate

const y1 = roundToCents(projectedReturn(amount, rate, 1))
const y5 = roundToCents(projectedReturn(amount, rate, 5))
const y10 = roundToCents(projectedReturn(amount, rate, 10))

expect(y1).toBe(6.50)
expect(y5).toBe(32.50)
expect(y10).toBe(65.00)
})
})

// ────────────────────────────────────────────────────────────────────────────

describe('roundToCents — monetary precision', () => {
it('rounds 1.005 to 1.01 (not 1.00 — the float trap)', () => {
// Native (1.005).toFixed(2) returns "1.00" due to binary float
expect(roundToCents(1.005)).toBe(1.01)
})

it('rounds 2.355 to 2.36', () => {
expect(roundToCents(2.355)).toBe(2.36)
})

it('rounds down correctly: 1.004 → 1.00', () => {
expect(roundToCents(1.004)).toBe(1.00)
})

it('handles zero', () => {
expect(roundToCents(0)).toBe(0)
})

it('handles negative amounts', () => {
expect(roundToCents(-2.355)).toBe(-2.35)
})

it('is idempotent: rounding twice gives the same result', () => {
const v = 12.345
expect(roundToCents(roundToCents(v))).toBe(roundToCents(v))
})

it('returns a number with at most 2 decimal digits', () => {
const result = roundToCents(99.999)
const parts = result.toString().split('.')
if (parts[1]) expect(parts[1].length).toBeLessThanOrEqual(2)
})
})

// ────────────────────────────────────────────────────────────────────────────

describe('formatDecimal — display precision', () => {
it('formats 0 USDC to "0.00" at 2 decimals', () => {
expect(formatDecimal(0, 2)).toBe('0.00')
})

it('formats 100 USDC to "100.00" at 2 decimals', () => {
expect(formatDecimal(100, 2)).toBe('100.00')
})

it('formats a share count to 4 decimal places', () => {
// 100 USDC / 1.0234 share price
const shares = 100 / 1.0234
const formatted = formatDecimal(shares, 4)
expect(formatted).toMatch(/^\d+\.\d{4}$/)
})

it('rounds before formatting so display matches calculation', () => {
// 1.005 should display as "1.01", not "1.00"
expect(formatDecimal(1.005, 2)).toBe('1.01')
})

it('returns a string', () => {
expect(typeof formatDecimal(42, 2)).toBe('string')
})

it('preserves trailing zeros for consistent column alignment', () => {
expect(formatDecimal(5, 2)).toBe('5.00')
expect(formatDecimal(5.1, 2)).toBe('5.10')
})
})

// ────────────────────────────────────────────────────────────────────────────

describe('roundToDecimals — generic precision helper', () => {
it('rounds to 0 decimals (integer)', () => {
expect(roundToDecimals(2.6, 0)).toBe(3)
expect(roundToDecimals(2.4, 0)).toBe(2)
})

it('rounds to 3 decimals', () => {
expect(roundToDecimals(1.2345, 3)).toBe(1.235)
})

it('rounds to 7 decimals (share price precision)', () => {
const precise = 1.12345678
const result = roundToDecimals(precise, 7)
expect(result.toString().split('.')[1]?.length ?? 0).toBeLessThanOrEqual(7)
})

it('handles exact values without drift', () => {
expect(roundToDecimals(0.1 + 0.2, 1)).toBe(0.3)
})
})

// ────────────────────────────────────────────────────────────────────────────

describe('Integration: full deposit preview calculation', () => {
/**
* Reproduces the complete preview shown in the Deposit screen for a given
* input amount. This integration check ensures the individual pieces compose
* correctly with no unexpected rounding gaps.
*/
function depositPreview(
amount: number,
sharePrice: number,
projectedRate: number,
) {
const fee = DEPOSIT_FEE_USDC
const proceeds = roundToCents(amount - fee)
const shares = amount / sharePrice
const return1y = roundToCents(projectedReturn(amount, projectedRate, 1))
const return5y = roundToCents(projectedReturn(amount, projectedRate, 5))
const return10y = roundToCents(projectedReturn(amount, projectedRate, 10))
return { fee, proceeds, shares, return1y, return5y, return10y }
}

it('100 USDC at 1.0 share price, 6% rate', () => {
const preview = depositPreview(100, 1.0, 6)
expect(preview.fee).toBe(0.01)
expect(preview.proceeds).toBe(99.99)
expect(preview.shares).toBe(100)
expect(preview.return1y).toBe(6.00)
expect(preview.return5y).toBe(30.00)
expect(preview.return10y).toBe(60.00)
})

it('250 USDC at 1.05 share price, 5.5% rate', () => {
const preview = depositPreview(250, 1.05, 5.5)
expect(preview.fee).toBe(0.01)
expect(preview.proceeds).toBe(249.99)
expect(preview.shares).toBeCloseTo(250 / 1.05, 6)
expect(preview.return1y).toBeCloseTo(13.75, 2)
expect(preview.return5y).toBeCloseTo(68.75, 2)
})

it('minimum deposit (1 USDC) produces positive net proceeds', () => {
const preview = depositPreview(MIN_DEPOSIT_USDC, 1.0, 6)
expect(preview.proceeds).toBeGreaterThan(0)
expect(preview.proceeds).toBe(0.99)
})

it('fee does not change with deposit size (flat fee model)', () => {
const small = depositPreview(10, 1.0, 6)
const large = depositPreview(10_000, 1.0, 6)
expect(small.fee).toBe(large.fee)
})

it('proceeds scale with amount while fee stays fixed', () => {
const p100 = depositPreview(100, 1.0, 6)
const p200 = depositPreview(200, 1.0, 6)
// Both proceeds are already rounded to cents; their difference may carry a
// sub-cent float epsilon, so we use toBeCloseTo rather than toBe.
expect(p200.proceeds - p100.proceeds).toBeCloseTo(100, 10)
})
})
Loading