const std = @import("std"); const builtin = @import("builtin"); const zpdf = @import("zpdf"); const String = @import("string").String; const osdialog = @import("osdialog.zig"); const Expense = struct { month: u8, day: u8, desc: []u8, dollars: u16, cents: u8, is_credit: bool }; var da: std.heap.DebugAllocator(.{}) = .init; const allocator = da.allocator(); pub fn main() !void { defer _ = da.deinit(); const path = osdialog.pick_file(allocator, home_path()) orelse return; defer allocator.free(path); const doc = try zpdf.Document.open(allocator, path); defer doc.close(); var aw: std.Io.Writer.Allocating = .init(allocator); defer aw.deinit(); const writer = &aw.writer; try doc.extractAllText(writer); var line: [4096]u8 = undefined; var line_index: u16 = 0; var has_amounts: bool = false; var lines_count: u8 = 0; const pdf_content = try aw.toOwnedSlice(); defer allocator.free(pdf_content); var expenses: std.ArrayList(Expense) = .empty; defer expenses.deinit(allocator); var year: ?u16 = null; for (pdf_content) |character| { line[line_index] = character; line_index += 1; if (character == '%') { has_amounts = true; } if (character == '\n') { if (std.mem.indexOf(u8, &line, "DATE DU RELEVÉ")) |date_releve_index| { year = try std.fmt.parseInt(u16, line[(date_releve_index + 19)..(date_releve_index + 23)], 10); } if (has_amounts) { lines_count += 1; if (lines_count >= 4) { var expense_index: u16 = 0; while (true) { const expense, const consumed_chars = parse_item(line[expense_index..]) catch break; try expenses.append(allocator, expense); expense_index += consumed_chars; } } } line = undefined; line_index = 0; has_amounts = false; } } if (year == null) { std.log.err("Could not find year of statement", .{}); unreachable; } var accounts = try initialize_accounts_map(); defer accounts.deinit(); var recurring_transactions = try initialize_recurring_transactions(); defer recurring_transactions.deinit(); var csv_list: std.ArrayList(u8) = .empty; defer csv_list.deinit(allocator); for (expenses.items) |expense| { defer allocator.free(expense.desc); const trimmed_desc = std.mem.trim(u8, expense.desc, " "); if (recurring_transactions.contains(trimmed_desc)) { continue; } var date: [10]u8 = undefined; _ = try std.fmt.bufPrint(&date, "{d}-{d:0>2}-{d:0>2}", .{ year.?, expense.month, expense.day }); var amount: [8]u8 = undefined; _ = try std.fmt.bufPrint(&amount, "{d:0>5}.{d:0>2}", .{ expense.dollars, expense.cents }); try csv_list.appendSlice(allocator, &date); try csv_list.append(allocator, ','); if (expense.is_credit) { try csv_list.append(allocator, '-'); } try csv_list.appendSlice(allocator, &amount); try csv_list.append(allocator, ','); try csv_list.appendSlice(allocator, expense.desc); try csv_list.append(allocator, ','); try csv_list.appendSlice(allocator, accounts.get(std.mem.trim(u8, trimmed_desc, " ")) orelse ""); try csv_list.append(allocator, '\n'); } const csv_path = osdialog.save_file(allocator, home_path(), "statement.csv") orelse return; defer allocator.free(csv_path); const file = try std.fs.createFileAbsolute(csv_path, .{}); defer file.close(); _ = try file.write(csv_list.items); } fn parse_item(raw_item: []u8) !struct { Expense, u8 } { if (raw_item[35] == 'C' and raw_item[36] == 'R') { return parse_credit(raw_item); } else { return parse_expense(raw_item); } } fn parse_expense(raw_expense: []u8) !struct { Expense, u8 } { const day = try std.fmt.parseInt(u8, raw_expense[0..2], 10); const month = try std.fmt.parseInt(u8, raw_expense[2..4], 10); const description = try allocator.dupe(u8, try parse_description(raw_expense[8..], 22)); errdefer allocator.free(description); var location_start = 10 + @as(u8, @intCast(description.len)); // Location column starts one character earlier for international transactions if (raw_expense[location_start - 1] != ' ') { location_start -= 1; } const maybe_internet = raw_expense[location_start..(location_start + 8)]; var maybe_internet_string = String.init(allocator); defer maybe_internet_string.deinit(); try maybe_internet_string.concat(maybe_internet); const dollars_start: u8 = if (std.mem.eql(u8, maybe_internet, "Internet")) location_start + 18 else @intCast(location_start + 25 + (maybe_internet.len - maybe_internet_string.len())); const raw_cents = raw_expense[(dollars_start + 7)..(dollars_start + 9)]; const cents = try std.fmt.parseInt(u8, raw_cents, 10); const raw_dollars = raw_expense[dollars_start..(dollars_start + 6)]; const dollars = try parse_dollar_amount(raw_dollars); const expense: Expense = .{ .month = month, .day = day, .desc = description, .dollars = dollars, .cents = cents, .is_credit = false }; return .{ expense, dollars_start + 9 }; } fn parse_credit(raw_expense: []u8) !struct { Expense, u8 } { const day = try std.fmt.parseInt(u8, raw_expense[0..2], 10); const month = try std.fmt.parseInt(u8, raw_expense[2..4], 10); const description = try allocator.dupe(u8, raw_expense[8..23]); errdefer allocator.free(description); const cents = try std.fmt.parseInt(u8, raw_expense[33..35], 10); const dollars = try parse_dollar_amount(raw_expense[26..32]); const expense: Expense = .{ .month = month, .day = day, .desc = description, .dollars = dollars, .cents = cents, .is_credit = true }; return .{ expense, 37 }; } fn parse_description(raw_expense: []u8, length: u8) ![]u8 { var bytes: u8 = 0; var chars: u8 = 0; while (chars <= length) : (chars += 1) { bytes += try std.unicode.utf8ByteSequenceLength(raw_expense[bytes]); } return raw_expense[0..bytes]; } fn parse_dollar_amount(raw_amount: []u8) !u16 { var index: u8 = 5; while (index >= 1) { if (raw_amount[index] == ' ' and raw_amount[index - 1] == ' ') { raw_amount[index] = '0'; index -= 1; continue; } else if (raw_amount[index] == ' ') { raw_amount[index] = raw_amount[index - 1]; raw_amount[index - 1] = ' '; } else { index -= 1; } } raw_amount[0] = '0'; return std.fmt.parseInt(u16, raw_amount, 10); } fn initialize_accounts_map() !std.StringHashMap([]const u8) { var accounts: std.StringHashMap([]const u8) = .init(allocator); try accounts.put("SUPER C - ST-NICOLAS 2", "Expenses:Groceries"); try accounts.put("HF", "Expenses:Groceries"); try accounts.put("HELLOFRESH", "Expenses:Groceries"); try accounts.put("MARCHE ST NICOLAS 2636", "Expenses:Groceries"); try accounts.put("J.B. ALLARD EPICIER GO", "Expenses:Groceries"); try accounts.put("ALIMENTATION PREMIERE", "Expenses:Groceries"); try accounts.put("PAIEMENT CAISSE", "Assets:Current Assets:DJ:Checking"); try accounts.put("CRÉDIT REMISES", "Income:Remises Desjardins World Elite"); try accounts.put("CLINIQUE VETERINAIRE S", "Expenses:Pets:Food"); try accounts.put("Patreon* Membership", "Expenses:Subscriptions"); try accounts.put("BOSTON PIZZA #612 OLM", "Expenses:Dining"); try accounts.put("ES CAFE ST-NICOLAS", "Expenses:Dining"); try accounts.put("FROMAGERIE VIC STNICOL", "Expenses:Dining"); try accounts.put("RESTAURANT MUSTANG", "Expenses:Dining"); try accounts.put("BOITE A MALT", "Expenses:Dining"); try accounts.put("CASSE-CROUTE DU HANGAR", "Expenses:Dining"); try accounts.put("PIAZZETTA SAINT-NICOLA", "Expenses:Dining"); try accounts.put("SUSHI D'ELI - SAINT-NI", "Expenses:Dining"); try accounts.put("BEN AND FLORENTINE - 2", "Expenses:Dining"); try accounts.put("Cremerie Chez Mamie St", "Expenses:Dining"); try accounts.put("PFK 265-001", "Expenses:Dining"); try accounts.put("PIZZA SALVATORE ST-NIC", "Expenses:Dining"); try accounts.put("VIA RAIL CANADA INC", "Expenses:Dining"); try accounts.put("POULET ROUGE - ROC POI", "Expenses:Dining"); try accounts.put("LA CHOPE GOBELINE", "Expenses:Dining"); try accounts.put("YUZU SUSHI ST-NICOLAS", "Expenses:Dining"); try accounts.put("HTSP-* COPILOTE+", "Expenses:Auto:Parking"); try accounts.put("VIA RAIL CANADA INC./A", "Expenses:Transport:Train"); try accounts.put("VIA RAIL CANADA INC./H", "Expenses:Transport:Train"); try accounts.put("TAXI QUEBEC", "Expenses:Transport:Cab:Traditional"); try accounts.put("NETFLIX.COM", "Expenses:Entertainment:Music/Movies"); try accounts.put("RTC", "Expenses:Transport:Public transit"); try accounts.put("PRIMEAU VELO (LEVIS)", "Expenses:Transport:Bike"); try accounts.put("BIXI MONTREAL", "Expenses:Transport:Vélopartage"); try accounts.put("RAKUTEN KOBO INC.", "Expenses:Books"); try accounts.put("LES LIBRAIRES", "Expenses:Books"); try accounts.put("BOUTIQUE ANIMALI", "Expenses:Pets:Food"); try accounts.put("STEAM PURCHASE", "Expenses:Entertainment:Videogames"); try accounts.put("Netflix.com", "Expenses:Entertainment:Music/Movies"); return accounts; } fn initialize_recurring_transactions() !std.StringHashMap(void) { var transactions: std.StringHashMap(void) = .init(allocator); try transactions.put("COMBINED INSURANCE COM", {}); try transactions.put("HF", {}); try transactions.put("COOP INFORMATION", {}); try transactions.put("FIZZ (TX. INCL.)", {}); try transactions.put("FIZZ (TX. INCL.)/FIZZ", {}); try transactions.put("HELLOFRESH", {}); try transactions.put("Patreon* Membership", {}); try transactions.put("NETFLIX.COM", {}); try transactions.put("TRUPANION", {}); return transactions; } fn home_path() [:0]const u8 { return switch (builtin.os.tag) { .linux => "/mnt/c/Users/Blond", .windows => "C:\\Users\\Blond", else => unreachable, }; }