//! 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(); /// Generic alignment wrapper, shared with `Date` and any other /// type that exposes a `format(self, *Writer)` method. pub const Padded = @import("padded.zig").Padded; /// 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 }; } }; // ── 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); }