zfin/src/Money.zig

445 lines
16 KiB
Zig

//! Money: a thin wrapper around `f64` representing a dollar amount,
//! with format methods that integrate with `std.fmt`'s `{f}`
//! specifier.
//!
//! Storage stays `f64` (matches existing zfin convention; no decimal
//! arithmetic). The wrapper exists for two reasons:
//!
//! 1. **Format-method dispatch.** Zig 0.15+'s format-method
//! protocol (`pub fn format(self, w: *Writer) !void`) lets
//! `try writer.print("{f}", .{m})` render a Money value
//! directly with no buffer ceremony. Distinct rendering
//! modes — whole-dollar, trim-trailing-`.00`, signed — are
//! exposed as wrapper-returning methods on `Money`:
//! `m.whole()`, `m.trim()`, `m.signed()`. Each returns a
//! small wrapper struct whose own `format` method emits the
//! variant.
//!
//! 2. **Single home for money formatting.** Replaces the
//! previous scatter across `src/format.zig` (fmtMoneyAbs,
//! fmtMoneyAbsWhole, fmtMoneyAbsTrim) and
//! `src/views/history.zig` (fmtSignedMoneyBuf).
//!
//! ## Usage
//!
//! ```zig
//! const Money = @import("Money.zig");
//!
//! // Default {f}: "$1,234.56" with always-present cents.
//! try writer.print("{f}\n", .{Money.from(1234.56)});
//!
//! // Whole-dollar: "$1,235" rounded.
//! try writer.print("{f}\n", .{Money.from(1234.56).whole()});
//!
//! // Elide trailing .00: "$1,234" if whole, "$1,234.56" if fractional.
//! try writer.print("{f}\n", .{Money.from(1234.00).trim()});
//!
//! // Signed: "+$1,234.56" / "-$1,234.56" / "$0.00".
//! try writer.print("{f}\n", .{Money.from(-1234.56).signed()});
//! ```
//!
//! ## On `f64` precision
//!
//! For zfin's use case (personal-finance, totals up to ~$100M,
//! display rounded to cents), f64 has plenty of headroom. f64
//! starts losing cent precision around $10^15 ($1 quadrillion).
//! See AGENTS.md "Time and money helpers" for the full discussion.
//! When this stops being true, change the underlying storage in
//! ONE place (this file) and the format methods get redone — no
//! call-site churn.
const std = @import("std");
amount: f64,
const Money = @This();
/// Construct a Money from a raw f64 dollar amount.
pub fn from(amount: f64) Money {
return .{ .amount = amount };
}
/// Zero dollars. Convenience for explicit-init sites.
pub const zero: Money = .{ .amount = 0 };
// ── Standard format method (called via `{f}`) ──────────────────
/// Default format: `$1,234.56`. Always positive (sign is the
/// caller's job). 2 decimal places.
pub fn format(self: Money, w: *std.Io.Writer) std.Io.Writer.Error!void {
try writeAbsCents(w, self.amount);
}
// ── Variant wrappers ───────────────────────────────────────────
/// Wrapper whose `{f}` emits whole dollars rounded: `$1,234`.
/// Distinct from `trim()` — `whole()` rounds 1234.56 to `$1,235`.
pub fn whole(self: Money) Whole {
return .{ .amount = self.amount };
}
/// Wrapper whose `{f}` elides a trailing `.00` but preserves
/// non-zero cents: `$1,234` for 1234.00, `$1,234.56` for 1234.56.
/// Use for cosmetic displays where cents-when-present are still
/// informative.
pub fn trim(self: Money) Trim {
return .{ .amount = self.amount };
}
/// Wrapper whose `{f}` emits a leading sign: `+$1,234.56`,
/// `-$1,234.56`, or `$0.00` for zero.
pub fn signed(self: Money) Signed {
return .{ .amount = self.amount };
}
/// Pad the default-formatted Money to `width` columns, right-aligned.
/// For column-aligned tabular output: `try out.print("{f}", .{Money.from(x).padRight(10)})`.
///
/// Composes with the variant wrappers — call `.padRight(N)` on a
/// `Whole`, `Trim`, or `Signed` directly via the same generic
/// helper. See `Padded` below.
pub fn padRight(self: Money, width: usize) Padded(Money) {
return .{ .inner = self, .width = width, .alignment = .right };
}
/// Pad the default-formatted Money to `width` columns, left-aligned.
pub fn padLeft(self: Money, width: usize) Padded(Money) {
return .{ .inner = self, .width = width, .alignment = .left };
}
pub const Whole = struct {
amount: f64,
pub fn format(self: Whole, w: *std.Io.Writer) std.Io.Writer.Error!void {
try writeAbsWhole(w, self.amount);
}
pub fn padRight(self: Whole, width: usize) Padded(Whole) {
return .{ .inner = self, .width = width, .alignment = .right };
}
pub fn padLeft(self: Whole, width: usize) Padded(Whole) {
return .{ .inner = self, .width = width, .alignment = .left };
}
};
pub const Trim = struct {
amount: f64,
pub fn format(self: Trim, w: *std.Io.Writer) std.Io.Writer.Error!void {
// Render via a fixed buffer, then trim the .00 if present.
// The buffer is sized for the worst case (negative billions
// with cents, ~$1,234,567,890.12 = 14 chars); 24 bytes is
// generous.
var tmp: [24]u8 = undefined;
var fixed = std.Io.Writer.fixed(&tmp);
writeAbsCents(&fixed, self.amount) catch unreachable;
const written = fixed.buffered();
const out = if (std.mem.endsWith(u8, written, ".00"))
written[0 .. written.len - 3]
else
written;
try w.writeAll(out);
}
pub fn padRight(self: Trim, width: usize) Padded(Trim) {
return .{ .inner = self, .width = width, .alignment = .right };
}
pub fn padLeft(self: Trim, width: usize) Padded(Trim) {
return .{ .inner = self, .width = width, .alignment = .left };
}
};
pub const Signed = struct {
amount: f64,
pub fn format(self: Signed, w: *std.Io.Writer) std.Io.Writer.Error!void {
if (self.amount > 0) try w.writeByte('+');
if (self.amount < 0) try w.writeByte('-');
try writeAbsCents(w, self.amount);
}
pub fn padRight(self: Signed, width: usize) Padded(Signed) {
return .{ .inner = self, .width = width, .alignment = .right };
}
pub fn padLeft(self: Signed, width: usize) Padded(Signed) {
return .{ .inner = self, .width = width, .alignment = .left };
}
};
/// Generic alignment wrapper. Wraps any `T` whose `format` method
/// emits some text, then pads the result to `width` columns with
/// spaces on the requested side. Composes with `Money` and all of
/// its variant wrappers — and with anything else that exposes a
/// `format(self, *Writer) !void`.
///
/// Comptime instantiation per inner type is essentially free: the
/// compiler emits one body per `Padded(T)` actually used, with the
/// inner `format` call inlined.
pub fn Padded(comptime T: type) type {
return struct {
inner: T,
width: usize,
alignment: enum { left, right },
const Self = @This();
pub fn format(self: Self, w: *std.Io.Writer) std.Io.Writer.Error!void {
// Render the inner value into a stack buffer first so
// we can measure its length and pad. 64 bytes is more
// than enough for any realistic Money render
// (~14 chars worst case for $X,XXX,XXX,XXX.XX with sign).
var tmp: [64]u8 = undefined;
var fixed = std.Io.Writer.fixed(&tmp);
self.inner.format(&fixed) catch unreachable;
const text = fixed.buffered();
const pad = if (text.len >= self.width) 0 else self.width - text.len;
switch (self.alignment) {
.right => {
try w.splatByteAll(' ', pad);
try w.writeAll(text);
},
.left => {
try w.writeAll(text);
try w.splatByteAll(' ', pad);
},
}
}
};
}
// ── Internal: byte-emission shared by all variants ─────────────
/// Write the absolute value of `amount` as `$X,XXX.XX` directly to
/// `w`. Same algorithm as the original `fmt.fmtMoneyAbs` — produces
/// byte-identical output, just streams to a writer instead of
/// returning a slice.
fn writeAbsCents(w: *std.Io.Writer, amount: f64) std.Io.Writer.Error!void {
const cents = @as(i64, @intFromFloat(@round(amount * 100.0)));
const abs_cents = if (cents < 0) @as(u64, @intCast(-cents)) else @as(u64, @intCast(cents));
const dollars = abs_cents / 100;
const rem = abs_cents % 100;
// Build into a stack buffer, then write all at once. The
// buffer is sized for the worst case (24-char negative
// billions); using a fixed buffer keeps the per-digit
// append-and-shift logic identical to the original.
var tmp: [24]u8 = undefined;
var pos: usize = tmp.len;
// Cents
pos -= 1;
tmp[pos] = '0' + @as(u8, @intCast(rem % 10));
pos -= 1;
tmp[pos] = '0' + @as(u8, @intCast(rem / 10));
pos -= 1;
tmp[pos] = '.';
// Dollars with commas
var d = dollars;
var digit_count: usize = 0;
if (d == 0) {
pos -= 1;
tmp[pos] = '0';
} else {
while (d > 0) {
if (digit_count > 0 and digit_count % 3 == 0) {
pos -= 1;
tmp[pos] = ',';
}
pos -= 1;
tmp[pos] = '0' + @as(u8, @intCast(d % 10));
d /= 10;
digit_count += 1;
}
}
pos -= 1;
tmp[pos] = '$';
try w.writeAll(tmp[pos..]);
}
/// Write the absolute value of `amount` as `$X,XXX` (rounded to
/// whole dollars, no decimal portion) directly to `w`.
fn writeAbsWhole(w: *std.Io.Writer, amount: f64) std.Io.Writer.Error!void {
const dollars_signed = @as(i64, @intFromFloat(@round(amount)));
const dollars: u64 = if (dollars_signed < 0) @intCast(-dollars_signed) else @intCast(dollars_signed);
var tmp: [24]u8 = undefined;
var pos: usize = tmp.len;
var d = dollars;
var digit_count: usize = 0;
if (d == 0) {
pos -= 1;
tmp[pos] = '0';
} else {
while (d > 0) {
if (digit_count > 0 and digit_count % 3 == 0) {
pos -= 1;
tmp[pos] = ',';
}
pos -= 1;
tmp[pos] = '0' + @as(u8, @intCast(d % 10));
d /= 10;
digit_count += 1;
}
}
pos -= 1;
tmp[pos] = '$';
try w.writeAll(tmp[pos..]);
}
// ── Tests ──────────────────────────────────────────────────────
const testing = std.testing;
/// Render a value via the `{f}` format spec into an arena-allocated
/// string. Used by tests to verify format-method output matches the
/// expected string exactly.
fn renderToString(allocator: std.mem.Allocator, value: anytype) ![]u8 {
var aw: std.Io.Writer.Allocating = .init(allocator);
defer aw.deinit();
try aw.writer.print("{f}", .{value});
return aw.toOwnedSlice();
}
test "Money default {f}: $X,XXX.XX with cents" {
const allocator = testing.allocator;
const cases = [_]struct { amount: f64, expected: []const u8 }{
.{ .amount = 0, .expected = "$0.00" },
.{ .amount = 1, .expected = "$1.00" },
.{ .amount = 1.23, .expected = "$1.23" },
.{ .amount = 1234.56, .expected = "$1,234.56" },
.{ .amount = 1_234_567.89, .expected = "$1,234,567.89" },
// Negative amounts emit absolute value — sign is caller's job
// unless they use `.signed()`.
.{ .amount = -1234.56, .expected = "$1,234.56" },
};
for (cases) |c| {
const s = try renderToString(allocator, Money.from(c.amount));
defer allocator.free(s);
try testing.expectEqualStrings(c.expected, s);
}
}
test "Money.whole(): rounds to dollars, no decimals" {
const allocator = testing.allocator;
const cases = [_]struct { amount: f64, expected: []const u8 }{
.{ .amount = 0, .expected = "$0" },
.{ .amount = 1, .expected = "$1" },
.{ .amount = 1234, .expected = "$1,234" },
.{ .amount = 1_234_567, .expected = "$1,234,567" },
// @round is half-away-from-zero.
.{ .amount = 1.5, .expected = "$2" },
.{ .amount = 1.49, .expected = "$1" },
.{ .amount = 0.4, .expected = "$0" },
// Negative magnitude rounds: |-1234.56| = 1234.56 → 1235.
.{ .amount = -1234.56, .expected = "$1,235" },
};
for (cases) |c| {
const s = try renderToString(allocator, Money.from(c.amount).whole());
defer allocator.free(s);
try testing.expectEqualStrings(c.expected, s);
}
}
test "Money.trim(): elide .00 only" {
const allocator = testing.allocator;
const cases = [_]struct { amount: f64, expected: []const u8 }{
// Whole values elide .00.
.{ .amount = 0, .expected = "$0" },
.{ .amount = 1234, .expected = "$1,234" },
.{ .amount = 1_234_567, .expected = "$1,234,567" },
// Non-zero cents preserved.
.{ .amount = 1234.56, .expected = "$1,234.56" },
.{ .amount = 0.01, .expected = "$0.01" },
.{ .amount = 0.5, .expected = "$0.50" },
// Sub-cent rounds-to-zero-cents elides too.
.{ .amount = 10.001, .expected = "$10" },
// Negative magnitude.
.{ .amount = -1234, .expected = "$1,234" },
.{ .amount = -1234.56, .expected = "$1,234.56" },
};
for (cases) |c| {
const s = try renderToString(allocator, Money.from(c.amount).trim());
defer allocator.free(s);
try testing.expectEqualStrings(c.expected, s);
}
}
test "Money.signed(): leading sign for non-zero, none for zero" {
const allocator = testing.allocator;
const cases = [_]struct { amount: f64, expected: []const u8 }{
.{ .amount = 1234.56, .expected = "+$1,234.56" },
.{ .amount = -1234.56, .expected = "-$1,234.56" },
// Zero gets no sign — purely cosmetic, matches the prior
// `fmtSignedMoneyBuf` convention.
.{ .amount = 0, .expected = "$0.00" },
};
for (cases) |c| {
const s = try renderToString(allocator, Money.from(c.amount).signed());
defer allocator.free(s);
try testing.expectEqualStrings(c.expected, s);
}
}
test "Money.zero is zero" {
try testing.expectEqual(@as(f64, 0), Money.zero.amount);
}
test "Money.from preserves the underlying f64" {
try testing.expectEqual(@as(f64, 1234.56), Money.from(1234.56).amount);
try testing.expectEqual(@as(f64, -42), Money.from(-42).amount);
}
test "Money.padRight pads with leading spaces" {
const allocator = testing.allocator;
// "$1,234.56" is 9 chars; pad to 12 → 3 leading spaces.
const s = try renderToString(allocator, Money.from(1234.56).padRight(12));
defer allocator.free(s);
try testing.expectEqualStrings(" $1,234.56", s);
}
test "Money.padLeft pads with trailing spaces" {
const allocator = testing.allocator;
const s = try renderToString(allocator, Money.from(1234.56).padLeft(12));
defer allocator.free(s);
try testing.expectEqualStrings("$1,234.56 ", s);
}
test "padRight: text wider than width emits unchanged (no truncation)" {
const allocator = testing.allocator;
// "$1,234,567.89" is 13 chars; padding to 5 → unchanged.
const s = try renderToString(allocator, Money.from(1_234_567.89).padRight(5));
defer allocator.free(s);
try testing.expectEqualStrings("$1,234,567.89", s);
}
test "padRight composes with whole/trim/signed variants" {
const allocator = testing.allocator;
// Whole + padRight: "$1,234" is 6 chars; pad to 10 → 4 spaces.
const s_whole = try renderToString(allocator, Money.from(1234).whole().padRight(10));
defer allocator.free(s_whole);
try testing.expectEqualStrings(" $1,234", s_whole);
// Trim + padRight: "$1,234.56" is 9 chars; pad to 12 → 3 spaces.
const s_trim = try renderToString(allocator, Money.from(1234.56).trim().padRight(12));
defer allocator.free(s_trim);
try testing.expectEqualStrings(" $1,234.56", s_trim);
// Signed + padRight: "+$1,234.56" is 10 chars; pad to 14 → 4 spaces.
const s_signed = try renderToString(allocator, Money.from(1234.56).signed().padRight(14));
defer allocator.free(s_signed);
try testing.expectEqualStrings(" +$1,234.56", s_signed);
}