# AI-Bookkeeper (single-file bundle) Every file concatenated. Each section starts with '=== FILE: '. Recreate the tree from the paths, or read as one document. README.md is first. ================================================================== === FILE: README.md ================================================================== # AI Bookkeeper skill pack Build your own AI bookkeeper: every bank and card statement in one place, every charge sorted into business or personal with a CRA line, receipts matched to the charge with a reason, a Notion Money HQ, an optional phone bot for receipt photos, and a 10-minute monthly close. Made for a self-employed person in Canada. Ontario first; other provinces change one setting (`sales_tax_rate`). From the video "I built my own AI bookkeeper" (aidanholt.ca/ai-bookkeeper). **Estimates, not tax advice.** This pack organizes your records and estimates. It does not file, it does not compute your tax bill, and it is not a substitute for an accountant. Check every rule on canada.ca and have an accountant review your return. ## What is in here | Folder | Core or optional | What it does | Video step | |---|---|---|---| | `bookkeeper/` | **Core** | the engine: statements -> one ledger, sorting rules, CRA rules sheet, Notion Money HQ layout, tests | 2, 3 | | `bank-statements/` | **Core** | downloads statements in your own logged-in browser, download only (or you drop CSVs in by hand) | 1 | | `receipt-match/` | **Core** | matches charges to your calendar for a reason, files receipt photos, attaches them in Notion | 2 | | `money-close/` | **Core** | the monthly routine that runs everything in order | 5 | | `amazon-orders/` | Optional | itemizes Amazon.ca orders and their HST (if you buy work things on Amazon) | 1 | | `phone-receipt-bot/` | Optional | the phone bot prompt: snap a receipt, it logs it in Notion; also the car log by text | 4 | | `subscription-audit/` | Optional | finds every subscription, Keep / Downgrade / Cancel table, you click cancel | 2 | | `budget/` | Optional | what you spend by category and how much you need to bring in per month | 5 | | `mileage-log/` | Optional | the CRA car logbook by text or dashboard photo, plus an estimate for past trips | 5 | Start with `SETUP.md`. It is the step by step. ## What you need - **Claude Code** (or another coding agent that can run Python and read files). Python 3. - **Notion** (free plan works) with the Notion connector, for the Money HQ. Optional but recommended. - A browser the agent can drive (Claude in Chrome extension) for the statement downloads. Optional: you can download CSVs by hand. - Google Calendar connector (read-only) for receipt reasons and mileage. Optional. - A phone AI bot that can read photos and write to Notion + Google Drive, for step 4. Optional. - `pdftotext` (poppler) only if your bank gives PDFs instead of CSVs. ## Install Copy each folder you want into your agent's skills folder. For Claude Code that is `~/.claude/skills/`: ```bash mkdir -p ~/.claude/skills && cp -R bookkeeper bank-statements receipt-match money-close ~/.claude/skills/ # optional: cp -R amazon-orders phone-receipt-bot subscription-audit budget mileage-log ~/.claude/skills/ python3 ~/.claude/skills/bookkeeper/scripts/tests/run_tests.py # should end "36 passed, 0 failed" ``` In these files, `{{SKILL_DIR}}` means that skills folder (`~/.claude/skills` for Claude Code). Other placeholders: `grep -rn "{{" .` lists every one. The agent fills them in during `/bookkeeper setup`. ## Principles 1. **Every dollar once.** Card payments and moves between your own accounts are not spending. 2. **Every business charge has a receipt, a reason (who + why) and a card.** No reason = personal. 3. **The agent never touches money.** It downloads, reads, sorts and recommends. You log in, you move money, you cancel, you file. 4. **No passwords, SIN or full card numbers, ever.** Last 4 digits only. 5. **Rules come with a CRA link** (`bookkeeper/knowledge/cra-rules.md`). No link, no rule. 6. **Fix the rule, not the row.** Answer once, it becomes a vendor rule, every past and future row follows. ## Safety - Bank and card sites: download only. The skills list the buttons the agent must never click. - Keep the Notion page private. It holds your finances. - Review what the agent proposes before it writes to Notion, and keep your statements as the source of truth. ## Origin Built for a real Ontario self-employed workflow in October 2026, then stripped of every personal detail (names, accounts, card numbers, tax numbers, amounts, addresses, Notion ids) and rewritten as a template. ================================================================== === FILE: SETUP.md ================================================================== # Setup, step by step (a few hours the first time, mostly you answering questions; the goal after that is about 10 minutes a month) Open your terminal, start Claude Code (`claude`), and paste the prompts in order. Anything in [BRACKETS] is yours to fill in. Estimates, not tax advice: your accountant files. ## 0. Install and set up (10 minutes) Install the folders (README.md, Install). Then: > /bookkeeper setup. I'm self-employed in [PROVINCE]. My business is [WHAT YOU DO]. I bank with > [BANKS AND CARDS]. Ask me what you need one question at a time. Then make the plan (this is the prompt that started the whole build): > First, for my taxes, what's the plan? I need to find everything I spent this year, download all my > statements, and get all my receipts organized. Give me the plan step by step until we're done. ## 1. Statements in > Go to Chrome, go into my [BANK]. I'm logged in. Download all the CSVs from January [YEAR] to now. > Only download. Don't do anything else. Then do the same for [NEXT BANK]. No browser automation? Download the CSVs yourself (bank-statements/SKILL.md has where) and drop them in `~/.claude/skills/bookkeeper/inbox/bank-statements/`. Optional, Amazon: > /amazon-orders for [YEAR]. Read only. Every order, the HST on each, and which card it was charged to. > Then go through them with me one at a time so I can tell you business or personal. ## 2. Sort + write-offs > /bookkeeper books. Then show me the charges you're not sure about, biggest first, 10 at a time, and > I'll tell you business or personal and why. The receipt trick: > /receipt-match calendar. Match my card charges to my Google Calendar so every business expense has a > reason. For example, parking on days I had [CLIENT MEETINGS / JOB SITES]. Show me the ones you can't > match and I'll explain them. Optional, subscriptions: > /subscription-audit. A subscription is a fixed monthly or yearly plan, not credit top-ups. Tell me > which ones to cancel, one by one, which email each is on, and how to cancel it. I'll do the clicking. ## 3. Notion Money HQ > Build my Notion Money HQ from bookkeeper/notion/money-hq.md, then put all the transactions in Notion. > Keep the page private. Receipt photos (put them in a folder first, for example Desktop/Receipts for AI): > /receipt-match photos. My receipt photos are in [FOLDER]. Match each one to its charge, file it, and > put the picture on the Notion row. ## 4. Phone receipt bot (optional) > /phone-receipt-bot. Fill in the bot prompt for me and copy it to my clipboard. Paste it into your phone bot, connect the bot to Notion and Google Drive, then send it one real receipt to test. When it gets something wrong, tell it again, and ask Claude Code to put the fix in the prompt. ## 5. Monthly close First weekend of every month: > /money-close It downloads the new statements, links receipts, asks you only the unknowns in one table, updates Notion, and tells you what to move to your tax account (once you've put the set-aside % from your accountant in `bookkeeper/data/tax_profile.json`). The push-back prompt (use it whenever it stops early): > I don't want to be 95% done. I want 100% done with what we can do today. What's left, and what do you > need from me? ## Car (if you drive for work) > /mileage-log. Odometer today is [KM]. From now on I'll text you "start" and "end" with the km. For a year you didn't track: `/mileage-log estimate` rebuilds trips from your calendar. It is an estimate, not a logbook: show it to your accountant before you claim it, and start a real log today. ## Honest limits - It sorts and estimates. It does not file your return or calculate your tax bill. - The AI makes mistakes (in the video the phone bot called a Canadian receipt USD). Check the "Needs review" view every month. - Bank websites change. If a download step breaks, download that CSV by hand. - A rebuilt mileage log is weaker than a real one. ================================================================== === FILE: amazon-orders/SKILL.md ================================================================== --- name: amazon-orders version: 1.0 description: > Read the owner's Amazon.ca orders for a year in their logged-in browser (READ ONLY): every order, item, GST/HST, seller and the card charged, plus the Transactions page for matching card statements. Each item then gets a reason from the owner (business or personal). Trigger "/amazon-orders", "check my Amazon orders", "Amazon receipts", "which card was this Amazon charge". argument-hint: "[year | since | order <#>]" --- # /amazon-orders Two ways in. The easy one first. ## Option A (no automation): Amazon's data export amazon.ca > Account > "Request your data" > Your Orders. When the email arrives, unzip and drop `Retail.OrderHistory.1.csv` (name may vary) into `{{SKILL_DIR}}/bookkeeper/inbox/amazon/`. The bookkeeper itemizes every Amazon card charge from it. It can take a day or more to arrive. ## Option B: read the account pages in the browser (same day) ### Hard rules - READ ONLY. Never click: Buy it again, Add to cart, Return, Cancel, **Request Invoice** (it messages the seller), Leave feedback, Write a review, Archive, anything in Settings, Wallet or Prime. - The owner is signed in. Never type their password. - Last 4 of a card only. - A shared household account is normal. Never assume an order is business: every business item needs the owner's reason (who / what / why), or it stays personal. ### The pages (Amazon.ca, October 2026) | Page | What is there | |---|---| | Your Orders, filtered to the year (10 per page) | date, total, items, photo, status, links to details and invoice | | Order details | card + last 4, subtotal, total before tax, **estimated GST/HST**, each item + seller | | Printable order summary | the same, clean text, no ads (best page to read) | | Invoice (PDF) | the tax receipt with the seller and the GST/HST lines | | Your Payments > Transactions | **every charge**: date, card last 4, amount, order #. Match bank lines to this, not to order totals | ### Gotchas 1. The order list may not come back readable through a background `fetch()`. Navigate the tab and read the rendered page. 2. Some tool output filters block text with order numbers or URL query strings. Do not try to get around it: keep the data inside the page and save it with a normal file download, or read the page text. 3. One order can be several charges (split shipments). Charge date = ship date, often 1 to 3 days later. 4. Gift card use lowers the card charge. Cancelled orders show $0: skip. Refunded: log, net $0. 5. Long loops time out: do about 25 orders per call. ### Steps 1. Harvest the order list for the year: date, total, order #, status, items (title + photo link). 2. Harvest Transactions for the year: date, card last 4, amount, order #. 3. Read details (printable summary) per order for GST/HST and seller. 4. Save `amazon-items-.csv` (one row per item) and `amazon-charges-.csv` (one row per charge) in `{{SKILL_DIR}}/bookkeeper/data/`. Note: the ingest script only itemizes from the Option A export file. With Option B, match each Amazon card line to `amazon-charges` by card + amount + date (within 3 days) and record each business item's answer in `data/charge_decisions.json`. 5. Rapid review with the owner: one item at a time, they say what it was for. Business items get a T2125 line (supplies, office) or "possible capital asset" if one item is over $500. 6. For every business item only, save the invoice PDF to `{{SKILL_DIR}}/bookkeeper/inbox/receipts/amazon/YYYY-MM-DD_amazon_.pdf`. 7. Optional: a Notion "Amazon Orders" database (photo, order #, date, item, total, HST, card, reason, business/personal) with views "Needs reason" and "Business". ================================================================== === FILE: bank-statements/SKILL.md ================================================================== --- name: bank-statements version: 1.0 description: > Download every bank and credit card statement (CSV first, PDF if asked) from January of the tax year to now, in the owner's own logged-in browser, and file them in the bookkeeper inbox. DOWNLOAD ONLY: never move money, pay, transfer, trade or change a setting. Run monthly after statements close. Trigger "/bank-statements", "download my statements", "get my bank statements". argument-hint: "[year (default current) | since YYYY-MM] [bank name]" --- # /bank-statements Needs a browser the agent can drive (for Claude Code: the Claude in Chrome extension). If there is none, the owner downloads the CSVs by hand with the table at the bottom and drops them in the inbox. Same result. ## Hard rules - **Download only.** Never click: transfer, send money, pay bills, Interac e-Transfer, make a payment, trade, buy or sell, increase credit limit, balance transfer, payment plan, lock card, add account, authorized users, profile, security, settings, or any offer. - **The owner logs in.** Never type a password, PIN or 2FA code. Signed out = stop and ask. - **One driver.** Ask the owner to keep their hands off that tab while it runs. - Card numbers: last 4 only, in file names and anywhere else. - Close the tabs you opened when done. ## Steps (per bank) 1. Open the bank's documents / statements or "download transactions" page in a new tab. 2. Pick CSV (the bookkeeper reads CSV; "Excel / other software (CSV)" at some banks). For a date-range page: From = Jan 1 of the tax year, To = today. For a statement-period page: one file per period. 3. Chrome blocks multiple downloads from one site until allowed. First run: the owner clicks the download icon with the red X in the address bar and chooses "Always allow". Clicks made while blocked come back later as " (1)" duplicates: delete those. 4. Keep each automation call short (3 to 5 downloads). Long loops time out. 5. Some banks open the file with `window.open(url)` instead of downloading. Capture that URL, fetch it with the page's own session, and save it as a Blob download named `__.csv`. 6. Move the files from Downloads to `{{SKILL_DIR}}/bookkeeper/inbox/bank-statements/-/`. Strip " (n)" suffixes, drop exact duplicates by hash. A header-only CSV = no activity that month (normal). 7. PDFs are the record, not the data: file them in `{{SKILL_DIR}}/bookkeeper/archive/statement-pdfs/`, NOT in the inbox (the ingest reads PDFs too and would count them twice). 8. Report coverage as a table: account x month, then run /bookkeeper books. ## Notes from real runs (Canadian banks, October 2026; menus change, look for the closest match) | Bank | Where | Gotcha | |---|---|---| | Wealthsimple | Documents, Statements filter (no account filter = all accounts) | Month header = month the statement was ISSUED. Each card has CSV and PDF buttons. PDFs open in a tab instead of downloading: skip unless asked. Load the whole list with short scroll calls (one long loop froze the tab). | | Tangerine | "Download transactions" page | Custom dropdowns: pick with real mouse events. Chequing/savings use a typed From date (type it, then Tab; setting the value by script does not stick). Card = one file per statement period. Download comes via window.open (step 5). | | MBNA | Account > Statements > Download statements | Opening the login page URL directly can 404: use the "logon" link from the main site. CSV only goes back about 13 billing months (PDF goes back years), so download a full year by mid-January of the next year. | | RBC, TD, CIBC, Scotiabank, BMO, Amex, Simplii, PC Financial, Rogers, Triangle | Account > Download transactions > CSV | TD's CSV has no header row (the engine handles it). Any CSV with a date, a description and an amount (or debit / credit) works. | ## Monthly After the last card statement closes each month: `/bank-statements since `, then /bookkeeper books. /money-close runs this for you. ================================================================== === FILE: bookkeeper/SKILL.md ================================================================== --- name: bookkeeper version: 1.0 description: > Your AI bookkeeping desk for a self-employed person in Canada (Ontario first, other provinces with one setting). Turns bank, card and Amazon statements into one clean ledger where every dollar is counted once, sorts each charge into business or personal with a CRA T2125 line, estimates the HST credit, finds recurring charges, and writes Notion-ready tables for the Money HQ. Estimates only, not tax advice. Trigger "/bookkeeper", "do my books", "sort my statements", "is this a write-off", "where is my money going". argument-hint: "[setup | books | ask | summary [YYYY-MM] | notion]" --- # /bookkeeper The core of the AI Bookkeeper pack. Other skills feed it (bank-statements, amazon-orders, receipt-match, phone-receipt-bot) and one skill runs it every month (money-close). ## Hard rules - **Estimates, not tax advice.** Say it once per session. The owner's accountant files the return. - **Never move money, pay, cancel, file, or email the CRA, a bank or an accountant.** Recommend; the owner acts. - **Never ask for or store a password, SIN, full card number or bank login.** Last 4 digits of a card are enough. The owner logs in to their own bank; you only download. - **Never invent a business purpose.** No who + why = personal, or ask. - **Every tax rule you state comes from knowledge/cra-rules.md (with its canada.ca link) or from a CRA page you open and cite.** If you cannot show the CRA page, say "I am not sure, ask your accountant". - Count every dollar once: card payments and transfers between the owner's own accounts are never spending or income (the engine handles this). - Read-only on email, calendar and Notion unless the owner says go for that run. ## Folder layout ({{SKILL_DIR}}/bookkeeper/) | Path | What | |---|---| | `inbox/bank-statements/` | every CSV (or PDF) export from every bank and card, any file name | | `inbox/amazon/` | Amazon "Request your data" order history CSV (optional) | | `data/vendor_rules.json` | regex -> T2125 line, label, business %, main/side/personal. The owner's answers live here | | `data/tax_profile.json` | sales tax rate, HST registered yes/no, set-aside %, FX planning rates | | `data/vehicle.json` | odometer Jan 1 + latest (for the vehicle business %) | | `data/charge_decisions.json` | one-off answers per charge: `{"": {"line": "8523", "pct": 100, "reason": "lunch with client X about Y"}}` | | `data/ledger-2026.csv` | output: every transaction, normalized | | `data/recurring-2026.csv` | output: recurring vendors, cadence, price changes | | `data/notion-ready/*.csv` | output: rows for the Notion Money HQ | | `knowledge/cra-rules.md` | the CRA rules this pack relies on, with links | | `notion/money-hq.md` | how to build the Notion Money HQ | ## Commands ### setup (first run, about 10 minutes) 1. Ask, one question at a time: province, HST registered (yes / no / not sure), main business and any side business, which banks and cards they use, do they drive for work, do they work from home. 2. Write `data/tax_profile.json`: `sales_tax_rate` (0.13 for Ontario; for anywhere else open canada.ca and confirm the rate), `hst_registered`, leave `tax_set_aside_pct` null until the owner has a % from their accountant or last year's return. 3. Edit `data/vendor_rules.json`: put the owner's name as the bank prints it in the transfer rule, and how their main income shows on deposits. Keep everything else as the starter set. 4. Run the tests to prove the engine works on this machine: `python3 scripts/tests/run_tests.py` (needs Python 3; `pdftotext` from poppler is optional for PDF statements). 5. Build the Notion Money HQ (notion/money-hq.md) if the owner uses Notion. ### books (weekly or monthly) From `{{SKILL_DIR}}/bookkeeper/`: ```bash python3 scripts/ingest_statements.py && python3 scripts/detect_recurring.py && python3 scripts/notion_export.py ``` Then show the rows with `needs_review = y` in `data/ledger-2026.csv`, biggest first, 10 at a time, as a table: date, vendor, amount, best guess, question. Fix the CAUSE: a vendor the owner always uses the same way gets a rule in `vendor_rules.json`; a one-off answer goes in `charge_decisions.json`. Rerun. Every ledger row is rebuilt from the rules, so one rule fixes every past row. ### ask "Is X a write-off?", "what line does Y go on?". Answer from knowledge/cra-rules.md first, with the link. Not covered there: open the CRA page (T4002 guide is the starting point), quote it, add the rule to cra-rules.md with the link. Lead with the answer, then the rule, then "check with your accountant" when the owner's situation matters (mixed use, capital items, home office, vehicle). ### summary [YYYY-MM] `python3 scripts/month_summary.py --month YYYY-MM` (or `--year`). Money in, business expenses by line, personal spending by label, what to move to the tax savings account, what still needs the owner. ### notion Push `data/notion-ready/transactions.csv` (and income, subscriptions, mileage) into the Money HQ with the Notion connector. Dedupe on Name ("Vendor YYYY-MM-DD") + Date + Amount: query the month first and only create rows that are not there. Never edit a row the owner or the phone bot already changed. ## Sorting rules of thumb (the owner overrides every one) - Business: the purchase was made to earn business income, and you can say who + why. - Mixed use (phone, internet, car, a laptop used for both): business % only, from real records. - Meals with a client where business was discussed: line 8523, the 50% limit applies (cra-rules.md #1). - Parking for a client visit or job site: business. Parking at home or at your regular office: commuting, personal. - Fines, parking tickets, photo radar: never deductible (cra-rules.md #4). - One item over $500 (camera, laptop): flagged as a possible capital asset. The accountant decides. - Gas, insurance, repairs: pooled, then the vehicle business % from the logbook applies (cra-rules.md #2). ## Honest limits - The engine is set up for tax year 2026 (file names end in -2026). For another year, ask the agent to change `YEAR` in scripts/taxlib.py and the file names. - It sorts and totals. It does not compute your tax bill, CPP or instalments, and it does not file. - PDF statements are parsed best-effort; every PDF row is worth a glance. - The HST numbers are estimates (13/113 of the business share when the vendor charges Ontario HST). Real input tax credits need the receipts. ================================================================== === FILE: bookkeeper/data/home_office.json ================================================================== ``` { "_help": "Business-use-of-home (T2125 line 9945). Office must be the principal place of business OR used only for business and regularly to meet clients. Utilities/insurance/etc are ANNUAL 2026 totals, CAD. Phone and internet go on 9220, not here.", "office_sqft": null, "home_finished_sqft": null, "owner_or_renter": null, "principal_place_of_business": null, "rent_monthly": null, "months_2026": 12, "mortgage_interest_annual": null, "utilities_annual": null, "home_insurance_annual": null, "property_tax_annual": null, "condo_fees_monthly": null, "maintenance_annual": null } ``` ================================================================== === FILE: bookkeeper/data/tax_profile.json ================================================================== ``` { "_help": "Your inputs. null = unknown. fx_to_cad = planning rates used only for foreign receipts with no bank line (update them). sales_tax_rate 0.13 = Ontario HST. tax_set_aside_pct = the % of business income your accountant tells you to save for income tax + CPP.", "fx_to_cad": { "USD": 1.38, "EUR": 1.5 }, "sales_tax_rate": 0.13, "hst_registered": true, "hst_method": "regular", "tax_set_aside_pct": null, "income_2026_override": null, "expected_more_income_2026": null, "tax_set_aside_saved": null, "tax_instalments_paid_2026": null, "hst_remitted_2026": null, "rrsp_room_2026": null, "rrsp_contributed_2026": null, "fhsa_open": null, "fhsa_contributed_2026": null, "earned_income_2025": null } ``` ================================================================== === FILE: bookkeeper/data/vehicle.json ================================================================== ``` { "_help": "Fill what you know. Odometer readings make the business % real. Leave null if unknown. Amounts in CAD, HST included as paid.", "odometer_jan1_2026": null, "odometer_latest": null, "odometer_latest_date": null, "total_km_2026_override": null, "count_office_trips_as_business": false, "vehicle": { "make_model": null, "owned_or_leased": null, "purchase_date": null, "purchase_price_before_tax": null, "zero_emission": false, "ucc_start_2026": null }, "lease_payment_monthly_before_tax": null, "lease_months_2026": null, "loan_interest_2026": null, "insurance_2026": null, "licence_registration_2026": null, "maintenance_repairs_2026": null, "fuel_2026_override": null, "car_washes_2026": null, "other_vehicle_costs_2026": null } ``` ================================================================== === FILE: bookkeeper/data/vendor_rules.json ================================================================== ``` { "_meta": { "version": 1, "how_it_works": "Keys are case-insensitive regexes tested in order against the raw bank description (first match wins). Edit business_pct / business here, rerun ingest_statements.py, and every ledger row updates. This is a STARTER file: your agent adds a rule every time you answer what a vendor is.", "fields": { "vendor": "clean display name", "category_cra_line": "T2125 line (empty = personal / not a business expense)", "label": "spend label for the budget (eating out, groceries, rides, shopping, subscriptions...)", "business_pct": "0-100 share that is business; null + pct_source=vehicle means use the vehicle business % from data/vehicle.json", "business": "main | side | split | personal | transfer | unknown", "recurring": "expected to repeat (subscription, dues, phone)", "hst": "ca = Canadian sales tax charged (ITC estimate on the business part) | foreign = no Canadian tax unless the receipt shows it | exempt = no tax (insurance, bank, government) | none", "type": "optional override: income | transfer | payment-to-card", "review": "true = always flag needs_review", "notes": "why" } }, "rules": { "PARKING TICKET|PARKING VIOLATION|PARKING FINE|PENALTY NOTICE|TRAFFIC FINE|PROVINCIAL OFFENCE|PHOTO RADAR": { "vendor": "Fine / ticket", "category_cra_line": "", "label": "fine", "business_pct": 0, "business": "personal", "recurring": false, "hst": "none", "type": "expense", "notes": "Fines and penalties imposed under a law are not deductible (CRA Income Tax Folio S4-F2-C1, ITA s.67.6)." }, "PAYMENT\\s*-?\\s*THANK\\s*YOU|PAYMENT RECEIVED|MERCI POUR VOTRE PAIEMENT|PAIEMENT MERCI|THANK YOU FOR YOUR PAYMENT|AUTOMATIC PAYMENT|^PAYMENT$|CREDIT CARD PAYMENT|ONLINE PAYMENT": { "vendor": "Card payment", "category_cra_line": "", "label": "card payment", "business_pct": 0, "business": "transfer", "recurring": false, "hst": "exempt", "type": "payment-to-card", "notes": "Payment onto a credit card. Not spending." }, "(AMEX|AMERICAN EXPRESS|RBC VISA|TD VISA|CIBC VISA|SCOTIA ?VISA|BMO MASTERCARD|BMO MC|ROGERS ?(BANK|MASTERCARD|MC)|PC ?FINANCIAL|PC MASTERCARD|TRIANGLE|CANADIAN TIRE (BANK|MC|MASTERCARD)|MBNA|CAPITAL ONE|TANGERINE (MC|MASTERCARD)|VISA|MASTERCARD).{0,25}(PAYMENT|PYMT|PMT|BILL)|(BILL PAYMENT|BILL PMT|PAYMENT|PYMT|PMT).{0,25}(AMEX|AMERICAN EXPRESS|VISA|MASTERCARD|MC\\b|PC FINANCIAL|TRIANGLE|MBNA|CAPITAL ONE)": { "vendor": "Card payment", "category_cra_line": "", "label": "card payment", "business_pct": 0, "business": "transfer", "recurring": false, "hst": "exempt", "type": "payment-to-card", "notes": "Bank-side payment to a credit card." }, "CANADA REVENUE|RECEIVER GENERAL|\\bCRA\\b.*(TAX|PAY|HST|GST)|GOVERNMENT OF CANADA TAX": { "vendor": "CRA payment", "category_cra_line": "", "label": "tax payment", "business_pct": 0, "business": "transfer", "recurring": false, "hst": "exempt", "type": "transfer", "tax_payment": true, "notes": "Income tax, HST or an instalment paid to the CRA. Not an expense." }, "(? 0), Missing receipts (Business % > 0 and Receipt Status = Missing), This month. ## 2. Income Name, Date, Source (select), Payer, Amount, HST, Deductions, Net, Status, Evidence (url), Notes. ## 3. Subscriptions Name, Plan, Cadence (monthly / yearly), Monthly CAD, Annual CAD, Last Charge, Next Renewal (date), Business %, Decision (select: Keep, Downgrade, Cancel; **the owner's column, the agent never writes it**), Done (checkbox), Notes. ## 4. Mileage (the CRA logbook, cra-rules.md #2) Trip (title, "YYYY-MM-DD destination"), Date, Odometer Start, Odometer End, Km, Destination, Purpose (who + why), Business/Personal, Source (select: Trip log, Odometer photo, Calendar rebuild), Dashboard Photo (files). ## Push rules - Import the CSVs in `data/notion-ready/` or create rows with the connector, 50 at a time. - Dedupe on Name + Date + Amount. Query first, create only what is missing. - Never overwrite Notes, Decision or Receipt that a person or the phone bot filled in. - If another agent is bulk-loading the database, wait. ================================================================== === FILE: bookkeeper/scripts/detect_recurring.py ================================================================== ``` #!/usr/bin/env python3 """ detect_recurring.py -- find subscriptions and repeating charges in the ledger. Reads data/ledger-2026.csv (+ data/subscriptions-2026.csv from the Gmail scan) and writes data/recurring-2026.csv one row per recurring vendor found in the ledger data/subscriptions-view-2026.csv the combined subscriptions view (ledger + Gmail scan) Cadence: monthly (median gap 25-35 days), annual (350-380), weekly (6-8), quarterly (85-95). Flags: price changes (>3% and >$0.50 between charges), duplicates (same amount within 3 days), failed/reversed (a refund of the same amount within 10 days). Never edits subscriptions-2026.csv (it belongs to the Gmail agent). """ import argparse import statistics import sys from collections import defaultdict from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) import taxlib as T # noqa: E402 REC_FIELDS = ["vendor", "cadence", "charges", "first_charge", "last_charge", "next_expected", "typical_amount_cad", "monthly_cost_cad", "annualized_cad", "spent_2026_cad", "business_pct", "category_cra_line", "label", "in_subscriptions_csv", "price_changes", "possible_duplicates", "failed_or_reversed", "status", "accounts", "notes"] VIEW_FIELDS = ["vendor", "source", "plan", "cadence", "monthly_cost_cad", "annualized_cad", "spent_2026_cad", "last_charge", "status", "business_pct", "category", "in_ledger", "price_changes", "flags", "notes"] def cadence_of(gaps): if not gaps: return None med = statistics.median(gaps) for name, lo, hi, per_month in [("weekly", 6, 8, 52 / 12), ("monthly", 25, 35, 1.0), ("quarterly", 85, 95, 1 / 3), ("annual", 350, 380, 1 / 12)]: if lo <= med <= hi: return name, per_month, med return None def sub_key(name): return T.norm(name).split(" ")[0] if name else "" def main(argv=None): ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--ledger", default=str(T.DATA / "ledger-2026.csv")) ap.add_argument("--subs", default=str(T.DATA / "subscriptions-2026.csv")) ap.add_argument("--out", default=str(T.DATA / "recurring-2026.csv")) ap.add_argument("--view", default=str(T.DATA / "subscriptions-view-2026.csv")) a = ap.parse_args(argv) ledger = T.read_csv(a.ledger) subs = T.read_csv(a.subs) sub_names = {sub_key(s["vendor"]): s for s in subs} for s in subs: # also index "Quo (OpenPhone)" -> OPENPHONE etc for tok in T.norm(s["vendor"]).split(): if len(tok) >= 4: sub_names.setdefault(tok, s) by_vendor = defaultdict(list) refunds = defaultdict(list) for r in ledger: if r["type"] == "expense" and T.fnum(r["amount_cad"]) < 0: by_vendor[r["vendor"]].append(r) elif r["type"] == "refund": refunds[r["vendor"]].append(r) out = [] for vendor, rows in by_vendor.items(): rows.sort(key=lambda x: x["date"]) dates = [T.parse_date(r["date"]) for r in rows] amts = [abs(T.fnum(r["amount_cad"])) for r in rows] flagged = any(r["recurring"] == "y" for r in rows) # duplicates: same amount within 3 days dups, keep_idx = [], [] for i, (d, x) in enumerate(zip(dates, amts)): if keep_idx and abs((d - dates[keep_idx[-1]]).days) <= 3 and abs(x - amts[keep_idx[-1]]) < 0.01: dups.append(f"{d} {x:.2f}") continue keep_idx.append(i) kd = [dates[i] for i in keep_idx] ka = [amts[i] for i in keep_idx] gaps = [(b - a_).days for a_, b in zip(kd, kd[1:])] cad = cadence_of(gaps) if len(kd) >= 2 else None if not cad and not (flagged and len(kd) >= 1): continue med_amt = statistics.median(ka) similar = sum(1 for x in ka if med_amt and abs(x - med_amt) / med_amt <= 0.15) / len(ka) if not cad and not flagged: continue if cad and similar < 0.5 and not flagged: continue if cad: name, per_month, med_gap = cad else: name, per_month, med_gap = ("usage / irregular" if len(kd) > 1 else "single charge (vendor usually recurs)"), None, None monthly = (ka[-1] * per_month) if per_month else None if monthly is None and len(kd) > 1: # usage run-rate since first charge months = max(1.0, (T.TODAY - kd[0]).days / 30.4) monthly = sum(ka) / months changes = [] for (d0, x0), (d1, x1) in zip(zip(kd, ka), zip(kd[1:], ka[1:])): if x0 and abs(x1 - x0) > 0.5 and abs(x1 - x0) / x0 > 0.03: changes.append(f"{d1}: {x0:.2f} -> {x1:.2f}") failed = [] for rf in refunds.get(vendor, []): rd, rx = T.parse_date(rf["date"]), abs(T.fnum(rf["amount_cad"])) if any(abs((rd - d).days) <= 10 and abs(rx - x) < 0.01 for d, x in zip(dates, amts)): failed.append(f"{rd} {rx:.2f}") nxt = "" status = "active" if med_gap: from datetime import timedelta nd = kd[-1] + timedelta(days=round(med_gap)) nxt = nd.isoformat() if (T.TODAY - kd[-1]).days > med_gap * 1.5: status = "possibly cancelled (missed a cycle)" sub = sub_names.get(sub_key(vendor)) if not sub: for tok in T.norm(vendor).split(): if tok in sub_names and len(tok) >= 4: sub = sub_names[tok] break out.append(dict( vendor=vendor, cadence=name, charges=len(kd), first_charge=kd[0].isoformat(), last_charge=kd[-1].isoformat(), next_expected=nxt, typical_amount_cad=f"{med_amt:.2f}", monthly_cost_cad=f"{monthly:.2f}" if monthly is not None else "", annualized_cad=f"{monthly * 12:.2f}" if monthly is not None else "", spent_2026_cad=f"{sum(amts):.2f}", business_pct=rows[-1]["business_pct"], category_cra_line=rows[-1]["category_cra_line"], label=rows[-1]["label"], in_subscriptions_csv="y" if sub else "n", price_changes=" | ".join(changes), possible_duplicates=" | ".join(dups), failed_or_reversed=" | ".join(failed), status=status, accounts=";".join(sorted({r["account"] for r in rows})), notes="amounts converted at the planning FX rate" if any(r["currency"] != "CAD" for r in rows) else "")) out.sort(key=lambda r: -T.fnum(r["annualized_cad"])) T.write_csv(a.out, out, REC_FIELDS) # ---- combined subscriptions view view, seen = [], set() rec_by_key = {} for r in out: rec_by_key[sub_key(r["vendor"])] = r for tok in T.norm(r["vendor"]).split(): if len(tok) >= 4: rec_by_key.setdefault(tok, r) for s in subs: r = rec_by_key.get(sub_key(s["vendor"])) if not r: for tok in T.norm(s["vendor"]).split(): if len(tok) >= 4 and tok in rec_by_key: r = rec_by_key[tok] break if r: seen.add(r["vendor"]) view.append(dict( vendor=s["vendor"], source="gmail scan" + (" + ledger" if r else ""), plan=s.get("product_plan", ""), cadence=s.get("cadence", ""), monthly_cost_cad=(r["monthly_cost_cad"] if r and r["monthly_cost_cad"] else s.get("monthly_burn_cad_est", "")), annualized_cad=(r["annualized_cad"] if r and r["annualized_cad"] else s.get("annual_cost_cad_est", "")), spent_2026_cad=(r["spent_2026_cad"] if r and r["charges"] and r["notes"] == "" else s.get("total_2026_cad_est", "")), last_charge=max(filter(None, [s.get("last_charge_date", "")[:10], r["last_charge"] if r else ""]), default=""), status=s.get("status", ""), business_pct=s.get("likely_business_use_pct", ""), category=s.get("category", ""), in_ledger="y" if r else "n", price_changes=(r["price_changes"] if r else ""), flags="; ".join(filter(None, [r["possible_duplicates"] and "possible duplicate charge" if r else "", r["failed_or_reversed"] and "failed/reversed" if r else "", "failing card" if "fail" in s.get("status", "").lower() or "past due" in s.get("status", "").lower() else ""])), notes=s.get("notes", ""))) for r in out: if r["vendor"] in seen: continue view.append(dict(vendor=r["vendor"], source="ledger only (not in Gmail scan)", plan="", cadence=r["cadence"], monthly_cost_cad=r["monthly_cost_cad"], annualized_cad=r["annualized_cad"], spent_2026_cad=r["spent_2026_cad"], last_charge=r["last_charge"], status=r["status"], business_pct=r["business_pct"], category=r["label"], in_ledger="y", price_changes=r["price_changes"], flags="; ".join(filter(None, ["possible duplicate charge" if r["possible_duplicates"] else "", "failed/reversed" if r["failed_or_reversed"] else ""])), notes="new: not found in the Gmail subscriptions scan")) T.write_csv(a.view, view, VIEW_FIELDS) print(f"recurring: {a.out} ({len(out)} vendors) | subscriptions view: {a.view} ({len(view)} rows)") burn = sum(T.fnum(r["monthly_cost_cad"]) for r in out if r["status"] == "active") print(f" ledger-detected monthly burn (active): ${burn:,.2f}") return out, view if __name__ == "__main__": main() ``` ================================================================== === FILE: bookkeeper/scripts/ingest_statements.py ================================================================== ``` #!/usr/bin/env python3 """ ingest_statements.py -- bank / card / Amazon files -> data/ledger-2026.csv Reads every CSV (and PDF, via `pdftotext -layout` when installed) in inbox/bank-statements/ (RBC, TD, CIBC, Scotiabank, BMO, Amex Canada, Tangerine, Simplii, Wealthsimple, Rogers MC, PC Financial, Triangle, or anything with a date + description + amount) inbox/amazon/ (Amazon "Request your data" Retail.OrderHistory*.csv) plus the Gmail receipts in data/expenses-gmail-2026.csv, and writes ONE normalized, de-duplicated ledger. Card payments and own-account transfers are tagged so they are never counted as spending or income. Gmail and Amazon receipts are matched to the bank line (vendor + amount + date within 3 days, 7 for Amazon) so nothing is counted twice. Usage python3 scripts/ingest_statements.py # normal weekly run python3 scripts/ingest_statements.py --inbox DIR --out FILE --no-gmail (tests) Sign convention in the ledger: amount_cad < 0 = money out, > 0 = money in. deductible_amount = business share NET of the HST input tax credit when you are HST-registered (hst_registered in data/tax_profile.json), with meals at 50%. hst_est = estimated ITC on the business share. """ import argparse import csv import re import shutil import subprocess import sys from collections import Counter, defaultdict from datetime import timedelta from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) import taxlib as T # noqa: E402 CARD_HINT = re.compile(r"visa|master|\bmc\b|amex|american.?express|credit|card|rogers|triangle|pc.?fin|mbna", re.I) PAY_WORDS = re.compile(r"PAYMENT|PAIEMENT|PYMT|THANK YOU|MERCI", re.I) XFER_WORDS = re.compile(r"TRANSFER|TFR|E-?TRANSFER|PAYMENT|PYMT|DEPOSIT|WITHDRAWAL|CONTRIBUTION", re.I) # =========================================================== CSV format detection def _h(row): return [c.strip().strip('"').lower() for c in row] def _col(headers, *cands, exclude=()): for c in cands: for i, h in enumerate(headers): if h == c: return i for c in cands: for i, h in enumerate(headers): if c in h and not any(x in h for x in exclude): return i return None def detect_bank(headers, fname): hs = set(headers) f = fname.lower() if {"account type", "transaction date", "description 1"} <= hs and any("cad$" in h for h in hs): return "RBC" if "merchant name" in hs and "activity type" in hs: return "Rogers Mastercard" if "item #" in hs and "card #" in hs: return "BMO Mastercard" if "first bank card" in hs: return "BMO" if "card holder name" in hs and "type" in hs: return "PC Financial" if {"ref", "transaction date", "posted date", "type", "description"} <= hs: return "Triangle (Canadian Tire)" if "type of transaction" in hs or "sub-description" in hs: return "Scotiabank" if {"transaction date", "transaction", "name", "memo", "amount"} <= hs: return "Tangerine" if "transaction details" in hs and ("funds out" in hs or "funds in" in hs): return "Simplii" if "date processed" in hs or "cardmember" in hs or "card member" in hs or "foreign spend amount" in hs \ or "merchant address" in hs or "amex" in f or "american" in f: return "Amex Canada" if {"posted date", "payee", "address", "amount"} <= hs or "mbna" in f: return "MBNA Mastercard" if {"transaction_date", "post_date", "details"} <= hs or ("credit-card-statement-transactions" in f): return "Wealthsimple Credit Card" if "wealthsimple" in f or ({"date", "transaction", "description", "amount", "balance"} <= hs): return "Wealthsimple" for b in ["rbc", "td", "cibc", "scotia", "bmo", "tangerine", "simplii", "wealthsimple", "rogers", "triangle", "pc"]: if re.search(rf"(^|[^a-z]){b}([^a-z]|$)", f): return {"rbc": "RBC", "td": "TD", "cibc": "CIBC", "scotia": "Scotiabank", "bmo": "BMO", "tangerine": "Tangerine", "simplii": "Simplii", "wealthsimple": "Wealthsimple", "rogers": "Rogers Mastercard", "triangle": "Triangle (Canadian Tire)", "pc": "PC Financial"}[b] return "Unknown bank" def find_header(rows): """Header row = first row (within 20) with a date-ish column name and an amount-ish name.""" for i, row in enumerate(rows[:20]): h = _h(row) if any("date" in c for c in h) and any(k in c for c in h for k in ("amount", "debit", "credit", "cad$", "withdraw", "deposit", "funds", "money")): return i return None def parse_csv_file(path): with open(path, newline="", encoding="utf-8-sig", errors="replace") as f: rows = [r for r in csv.reader(f) if any(c.strip() for c in r)] if not rows: return [], "empty" fname = path.name hi = find_header(rows) out = [] if hi is None: return parse_headerless(rows, fname), "headerless" headers = _h(rows[hi]) bank = detect_bank(headers, fname) body = rows[hi + 1:] di = _col(headers, "transaction date", "date", "posting date", "date posted", "trans. date", exclude=("posted",)) if di is None: di = _col(headers, "date") desc_cols = [i for i, h in enumerate(headers) if h in ("description 1", "description 2", "description", "merchant name", "transaction details", "name", "memo", "payee", "details", "transaction", "sub-description", "merchant")] if bank == "Wealthsimple": desc_cols = [i for i in desc_cols if headers[i] != "transaction"] or desc_cols if bank == "Tangerine": desc_cols = [i for i, h in enumerate(headers) if h in ("name", "memo")] ai = _col(headers, "cad$", "transaction amount", "amount", "amount (cad)", exclude=("foreign", "usd")) deb = _col(headers, "debit", "withdrawal", "withdrawals", "funds out", "money out", "paid out") cre = _col(headers, "credit", "deposit", "deposits", "funds in", "money in", "paid in") typ = _col(headers, "type of transaction", "transaction type", "type", "activity type") usd = _col(headers, "usd$", "foreign spend amount") cur = _col(headers, "currency") acct = _col(headers, "account number", "card #", "card number", "first bank card") status = _col(headers, "activity status", "status") acct_type = _col(headers, "account type") is_card = bank in ("Amex Canada", "Rogers Mastercard", "BMO Mastercard", "PC Financial", "Triangle (Canadian Tire)") \ or bool(CARD_HINT.search(fname)) recs = [] for row in body: row = row + [""] * (len(headers) - len(row)) d = T.parse_date(row[di]) if di is not None else None if not d: continue desc = " ".join(row[i].strip() for i in desc_cols if row[i].strip()) or " ".join(row) if status is not None and re.search(r"declin|fail|void", row[status], re.I): continue amt, typed = None, False if deb is not None or cre is not None: dv = T.parse_amount(row[deb]) if deb is not None else None cv = T.parse_amount(row[cre]) if cre is not None else None if dv is None and cv is None and ai is not None: amt = T.parse_amount(row[ai]) else: amt = (abs(cv) if cv else 0.0) - (abs(dv) if dv else 0.0) elif ai is not None: amt = T.parse_amount(row[ai]) if amt is not None and typ is not None: tv = row[typ].strip().lower() if tv in ("debit", "dr", "withdrawal", "purchase", "fee", "interest", "charge"): amt, typed = -abs(amt), True elif tv in ("credit", "cr", "deposit", "payment", "refund", "return", "credit adjustment"): amt, typed = abs(amt), True if amt is None or abs(amt) < 0.005: continue currency = (row[cur].strip().upper() if cur is not None and row[cur].strip() else "CAD") orig = "" if usd is not None and T.parse_amount(row[usd]): orig = f"USD {abs(T.parse_amount(row[usd])):.2f}" if bank == "RBC" else row[usd].strip() last4 = "" if acct is not None: m = re.findall(r"\d", row[acct]) last4 = "".join(m[-4:]) if m else "" row_card = is_card if acct_type is not None and row[acct_type].strip(): row_card = any(k in row[acct_type].lower() for k in ("visa", "master", "credit", "card", "amex")) recs.append(dict(date=d, desc=desc, amount=amt, currency=currency, orig=orig, last4=last4, typed=typed, is_card=row_card)) # sign convention: card files that list charges as POSITIVE get flipped (money out < 0) if deb is None and cre is None and bank != "RBC": un = [r for r in recs if not r["typed"] and r["is_card"]] pays = [r["amount"] for r in un if PAY_WORDS.search(r["desc"])] if pays: flip = sum(1 for p in pays if p < 0) > len(pays) / 2 else: flip = sum(1 for r in un if r["amount"] > 0) > len(un) / 2 if un and flip: for r in un: r["amount"] = -r["amount"] # debit/credit card files: debit = charge (out), credit = payment (in) already correct for r in recs: r.update(bank=bank, fmt=bank) return recs, bank def parse_headerless(rows, fname): """TD / CIBC / old Scotiabank exports have no header row.""" recs = [] bank = detect_bank([], fname) is_card = bool(CARD_HINT.search(fname)) for row in rows: cells = [c.strip() for c in row] if len(cells) < 3: continue d = T.parse_date(cells[0]) if not d: continue if T.parse_amount(cells[1]) is not None and len(cells) >= 5: # Scotiabank old: date, amount, -, type, desc amt = T.parse_amount(cells[1]) desc = " ".join(c for c in cells[3:] if c and c != "-") bank = bank if bank != "Unknown bank" else "Scotiabank" else: # TD / CIBC: date, desc, debit, credit, [balance|card] desc = cells[1] dv = T.parse_amount(cells[2]) if len(cells) > 2 else None cv = T.parse_amount(cells[3]) if len(cells) > 3 else None if dv is None and cv is None: continue amt = (abs(cv) if cv else 0.0) - (abs(dv) if dv else 0.0) if bank == "Unknown bank": bank = "CIBC" if re.match(r"\d{4}-\d{2}-\d{2}", cells[0]) else "TD" if amt is None or abs(amt) < 0.005: continue last4 = "" if len(cells) >= 5 and re.search(r"\d{4}\*+\d{4}|\*{4}\d{4}", cells[-1]): last4 = re.findall(r"\d", cells[-1])[-4:] last4 = "".join(last4) is_card = True recs.append(dict(date=d, desc=desc, amount=amt, currency="CAD", orig="", last4=last4)) for r in recs: r.update(bank=bank, is_card=is_card, fmt=bank + " (no header)") return recs # =========================================================== PDF (best effort) PDF_LINE = re.compile( r"^\s*(?P(?:[A-Z][a-z]{2}\.? ?\d{1,2})|(?:\d{1,2} ?[A-Z][a-z]{2})|(?:\d{4}-\d{2}-\d{2})|(?:\d{1,2}/\d{1,2}(?:/\d{2,4})?))" r"(?:\s+(?:[A-Z][a-z]{2}\.? ?\d{1,2}|\d{1,2} ?[A-Z][a-z]{2}|\d{1,2}/\d{1,2}))?" r"\s+(?P.+?)\s+(?P-?\(?\$?\d{1,3}(?:,\d{3})*\.\d{2}\)?(?:\s?CR)?-?)" r"(?:\s+(?P-?\$?\d{1,3}(?:,\d{3})*\.\d{2}))?\s*$", re.I) def parse_pdf_file(path): if not shutil.which("pdftotext"): return [], "pdftotext not installed" try: txt = subprocess.run(["pdftotext", "-layout", str(path), "-"], capture_output=True, text=True, timeout=60).stdout except Exception as e: # noqa return [], f"pdftotext failed: {e}" years = Counter(re.findall(r"\b(202[4-7])\b", txt)) year = int(years.most_common(1)[0][0]) if years else T.YEAR is_card = bool(re.search(r"minimum payment|credit limit|new balance|statement balance", txt, re.I)) or bool(CARD_HINT.search(path.name)) bank = detect_bank([], path.name) if bank == "Unknown bank": for k, v in [("Royal Bank|RBC", "RBC"), ("TD Canada|\\bTD\\b", "TD"), ("CIBC", "CIBC"), ("Scotiabank", "Scotiabank"), ("BMO|Bank of Montreal", "BMO"), ("American Express", "Amex Canada"), ("Tangerine", "Tangerine"), ("Simplii", "Simplii"), ("Wealthsimple", "Wealthsimple"), ("Rogers Bank", "Rogers Mastercard"), ("PC Financial|President's Choice", "PC Financial"), ("Canadian Tire Bank|Triangle", "Triangle (Canadian Tire)")]: if re.search(k, txt): bank = v break recs = [] prev_bal = None for line in txt.splitlines(): m = PDF_LINE.match(line) if not m: continue ds = m.group("d1").strip() d = T.parse_date(ds) or T.parse_date(f"{ds} {year}") or T.parse_date(f"{ds}/{year}") if not d: mm = re.match(r"([A-Za-z]{3})\.? ?(\d{1,2})", ds) if mm and mm.group(1).lower() in T.MONTHS: from datetime import date as _d d = _d(year, T.MONTHS[mm.group(1).lower()], int(mm.group(2))) if not d: continue desc = re.sub(r"\s{2,}", " ", m.group("desc")).strip() if re.search(r"opening balance|closing balance|previous balance|new balance|balance forward|total", desc, re.I): continue amt = T.parse_amount(m.group("amt")) bal = T.parse_amount(m.group("bal")) if m.group("bal") else None if amt is None: continue if is_card: amt = -amt # card statements list charges as positive elif bal is not None and prev_bal is not None: amt = abs(amt) if bal > prev_bal else -abs(amt) elif not str(m.group("amt")).strip().startswith("-"): amt = -abs(amt) # unknown: assume withdrawal, flagged below if bal is not None: prev_bal = bal recs.append(dict(date=d, desc=desc, amount=amt, currency="CAD", orig="", last4="", pdf=True)) for r in recs: r.update(bank=bank, is_card=is_card, fmt=bank + " (PDF)") return recs, bank + " (PDF)" # =========================================================== Amazon order history def parse_amazon(path): rows = T.read_csv(path) if not rows: return [] keys = {k.lower().strip(): k for k in rows[0].keys()} def g(r, *names): for n in names: if n in keys: return r.get(keys[n], "") return "" orders = defaultdict(lambda: {"items": [], "total": 0.0, "tax": 0.0, "date": None, "currency": "CAD", "tax_known": False}) for r in rows: status = g(r, "order status", "shipment status") if re.search(r"cancel", status or "", re.I): continue oid = g(r, "order id") d = T.parse_date(g(r, "order date")) owed = T.parse_amount(g(r, "total owed", "item total", "total charged")) if owed is None: up = T.parse_amount(g(r, "unit price")) or 0 q = T.parse_amount(g(r, "quantity")) or 1 owed = up * q tax = T.parse_amount(g(r, "shipment item subtotal tax", "unit price tax", "item subtotal tax")) o = orders[oid] o["date"] = o["date"] or d o["currency"] = (g(r, "currency") or "CAD").upper() o["total"] += owed or 0 if tax is not None: o["tax"] += tax o["tax_known"] = True o["items"].append((g(r, "product name", "title") or "item", owed or 0)) out = [] for oid, o in orders.items(): if not o["date"] or o["total"] <= 0: continue out.append(dict(order_id=oid, **o, source=path.name)) return out # =========================================================== classification def classify(desc, amount, is_card, veh_pct): rules = T.load_rules() spec = None for rx, sp in rules["rules"]: if not rx.search(desc): continue if sp.get("type") == "income" and amount <= 0: continue spec = sp break reasons = [] if spec is None: vendor = T.clean_vendor(desc) if amount < 0: return dict(vendor=vendor, type="expense", line="", label="uncategorized", business="unknown", pct=0.0, recurring=False, hst="ca", notes="", review=["unknown vendor: add a rule in data/vendor_rules.json"]) if is_card: return dict(vendor=vendor, type="refund", line="", label="refund", business="unknown", pct=0.0, recurring=False, hst="ca", notes="", review=["unknown credit on card"]) return dict(vendor=vendor, type="income", line="", label="unknown deposit", business="unknown", pct=0.0, recurring=False, hst="ca", notes="", review=["unknown deposit: income, reimbursement or transfer?"]) typ = spec.get("type") if not typ: typ = "expense" if amount < 0 else "refund" pct = spec.get("business_pct") notes = spec.get("notes", "") if spec.get("pct_source") == "vehicle": if veh_pct is None: pct = 0.0 notes = (notes + " Vehicle % unknown: fill data/vehicle.json odometer.").strip() else: pct = round(veh_pct, 1) if spec.get("review"): reasons.append("rule says review: " + (spec.get("notes") or "check business use")[:80]) return dict(vendor=spec.get("vendor") or T.clean_vendor(desc), type=typ, line=spec.get("category_cra_line", ""), label=spec.get("label", ""), business=spec.get("business", ""), pct=float(pct or 0), recurring=bool(spec.get("recurring")), hst=spec.get("hst", "ca"), notes=notes, review=reasons, tax_payment=bool(spec.get("tax_payment")), income_source=spec.get("income_source", "")) def money_fields(row, hst_full=None): """Fill deductible_amount and hst_est from amount, pct, line, hst kind.""" amt = float(row["amount_cad"]) pct = float(row["business_pct"] or 0) / 100 line = row["category_cra_line"] if row["type"] not in ("expense", "refund") or not line or pct <= 0: row["deductible_amount"], row["hst_est"] = 0.0, 0.0 return row gross = abs(amt) if hst_full is None: hst_full = gross * T.HST_FRACTION if row.get("_hst_kind", "ca") == "ca" else 0.0 if not T.HST_REGISTERED: hst_full = 0.0 # not registered: no ITC, the HST is part of the expense factor = 0.5 if line == "8523" else 1.0 sign = -1 if row["type"] == "refund" else 1 itc = hst_full * pct * factor ded = (gross - hst_full) * pct * factor if line == "9936": # capital: CCA, computed in the report ded = 0.0 row["deductible_amount"] = round(sign * ded, 2) row["hst_est"] = round(sign * itc, 2) return row def base_row(**kw): r = {k: "" for k in T.LEDGER_FIELDS} r.update(kw) return r # =========================================================== receipts -> rows def gmail_rows(path, prof): rows = [] for g in T.read_csv(path): d = T.parse_date(g.get("date")) if not d or d.year != T.YEAR: continue total = T.parse_amount(g.get("total")) cur = (g.get("currency") or "CAD").upper() fx = T.fx_rate(cur, prof) reasons = [] if total is None: reasons.append("amount missing on the Gmail receipt (PDF only?)") if total == 0: continue # free items (LUTs, plugins): nothing to book amt = -(total or 0) * fx cat = (g.get("category_cra") or "").strip() line = cat[:4] if cat[:4].isdigit() else "" personal = (g.get("personal_flag") or "").lower().startswith("y") and not line reason = (g.get("business_reason") or "").lower() biz = "personal" if personal else ("main" if reason.startswith("main") else "side" if reason.startswith("side") else "split") pct = 0.0 if personal else T.fnum(g.get("business_pct"), 100.0) hst = T.parse_amount(g.get("hst")) hst_full = (hst * fx) if hst is not None else (abs(amt) * T.HST_FRACTION if cur == "CAD" else 0.0) if (g.get("confidence") or "").lower() == "low": reasons.append("low-confidence receipt") if "confirm" in (g.get("business_reason") or "").lower(): reasons.append("business use says (confirm)") spec = T.match_rule(f"{g.get('vendor')} {g.get('description')}") or {} label = spec.get("label") or ("shopping" if personal else (cat.split(" ", 1)[1] if " " in cat else "business")) r = base_row(date=d.isoformat(), account="gmail-receipt", raw_description=f"{g.get('vendor')} | {g.get('description')}", vendor=g.get("vendor", ""), amount_cad=round(amt, 2), currency=cur, orig_amount=(f"{cur} {total:.2f}" if total is not None and cur != "CAD" else ""), type="expense", category_cra_line=line, label=label, business=biz, business_pct=pct, recurring="y" if spec.get("recurring") else "n", source_file=Path(path).name, match_ids=f"gmail:{g.get('message_id')}", needs_review="y" if reasons else "n", review_reason="; ".join(reasons), notes=("FX estimate " + str(fx) + ". " if cur != "CAD" else "") + (g.get("business_reason") or "")) r["_hst_full"] = hst_full r["_receipt"] = {"kind": "gmail", "vendor": g.get("vendor", ""), "total": total, "cur": cur, "date": d, "id": f"gmail:{g.get('message_id')}", "line": line, "pct": pct, "biz": biz, "label": label, "hst": hst, "window": 3} rows.append(r) return rows def amazon_rows(orders, prof): out = [] for o in orders: if o["date"].year != T.YEAR: continue fx = T.fx_rate(o["currency"], prof) total = o["total"] biz_amt, lines, names = 0.0, Counter(), [] for name, owed in o["items"]: sp = T.match_item(name) or {} p = float(sp.get("business_pct") or 0) / 100 biz_amt += owed * p if p > 0 and sp.get("category_cra_line"): lines[sp["category_cra_line"]] += owed * p names.append(name[:60]) pct = round(biz_amt / total * 100, 1) if total else 0.0 line = lines.most_common(1)[0][0] if lines else "" reasons = [] if 0 < pct < 100 and len(o["items"]) > 1: reasons.append("mixed order: business and personal items") if line == "9936" or any(owed >= 500 for _, owed in o["items"] if (T.match_item(_) or {}).get("business_pct")): reasons.append("item over $500: capital asset (CCA)?") hst_full = (o["tax"] * fx) if o["tax_known"] else total * fx * T.HST_FRACTION r = base_row(date=o["date"].isoformat(), account="amazon-orders", raw_description="Amazon order " + o["order_id"] + ": " + " / ".join(names), vendor="Amazon", amount_cad=round(-total * fx, 2), currency=o["currency"], type="expense", category_cra_line=line, label="shopping" if not line else (T.match_item(names[0]) or {}).get("label", "business"), business=("personal" if pct == 0 else "split" if pct < 100 else "main"), business_pct=pct, recurring="n", source_file=o["source"], match_ids=f"amazon:{o['order_id']}", needs_review="y" if reasons else "n", review_reason="; ".join(reasons), notes="Itemized from Amazon order history.") r["_hst_full"] = hst_full r["_receipt"] = {"kind": "amazon", "vendor": "Amazon", "total": total, "cur": o["currency"], "date": o["date"], "id": f"amazon:{o['order_id']}", "line": line, "pct": pct, "biz": r["business"], "label": r["label"], "hst": o["tax"] if o["tax_known"] else None, "window": 7, "items": names} out.append(r) return out # =========================================================== matching helpers def _tokens(s): stop = {"THE", "INC", "LLC", "LTD", "CANADA", "CA", "COM", "WWW", "STORE", "ORDER", "AND", "CO", "PAYMENT", "CORP"} return {t for t in T.norm(s).split() if len(t) >= 3 and t not in stop} def vendor_match(receipt_vendor, bank_row): a = _tokens(receipt_vendor) b = _tokens(bank_row["raw_description"]) | _tokens(bank_row["vendor"]) if a & b: return True ra = T.match_rule(receipt_vendor) rb = T.match_rule(bank_row["raw_description"]) return bool(ra and rb and ra.get("vendor") == rb.get("vendor")) def amount_match(rec, bank_amt): if rec["total"] is None: return False a = abs(bank_amt) if rec["cur"] == "CAD": return abs(a - rec["total"]) <= 0.05 ratio = a / rec["total"] if rec["total"] else 0 lo, hi = {"USD": (1.25, 1.50), "EUR": (1.35, 1.65)}.get(rec["cur"], (0.9, 2.0)) return lo <= ratio <= hi # =========================================================== main pipeline def build(inbox, gmail_path, prof, quiet=False): veh_pct, _ = T.vehicle_business_pct() stmt_dir, amz_dir = inbox / "bank-statements", inbox / "amazon" log = [] raw = [] for p in sorted(stmt_dir.rglob("*")) if stmt_dir.exists() else []: if p.name.startswith(".") or not p.is_file(): continue if p.suffix.lower() == ".csv": recs, fmt = parse_csv_file(p) elif p.suffix.lower() == ".pdf": recs, fmt = parse_pdf_file(p) else: continue log.append(f"{p.name}: {fmt}, {len(recs)} rows") occ = Counter() for r in recs: k = (r["bank"], r["is_card"], r["last4"], r["date"], round(r["amount"], 2), T.norm(r["desc"])[:40]) occ[k] += 1 r["dedupe_key"] = k + (occ[k],) r["source_file"] = p.name r["account"] = f"{r['bank']} {'card' if r['is_card'] else 'bank'}" + (f" {r['last4']}" if r["last4"] else f" ({p.stem})") raw.append(r) # ---- de-duplicate across files seen, dups, rows = {}, 0, [] for r in raw: k = r["dedupe_key"] if k in seen: dups += 1 first = seen[k] if r["source_file"] not in first["source_file"]: first["source_file"] += ";" + r["source_file"] continue seen[k] = r rows.append(r) ledger, skipped_year = [], 0 for r in rows: if r["date"].year != T.YEAR: skipped_year += 1 continue c = classify(r["desc"], r["amount"], r["is_card"], veh_pct) reasons = list(c["review"]) if r.get("pdf"): reasons.append("parsed from PDF: check sign and amount") if c["type"] == "expense" and abs(r["amount"]) >= 500 and c["pct"] > 0 and c["line"] in ("8810", "8811", "9270", "9936"): reasons.append("over $500: capital asset (CCA)?") if c["line"] == "9936": reasons.append("capital purchase: set business % and CCA class") row = base_row(id=T.make_id(*r["dedupe_key"]), date=r["date"].isoformat(), account=r["account"], raw_description=r["desc"], vendor=c["vendor"], amount_cad=round(r["amount"], 2), currency=r["currency"], orig_amount=r.get("orig", ""), type=c["type"], category_cra_line=c["line"], label=c["label"], business=c["business"], business_pct=c["pct"], recurring="y" if c["recurring"] else "n", source_file=r["source_file"], match_ids="", needs_review="y" if reasons else "n", review_reason="; ".join(reasons), notes=(c["notes"] + (" TAX PAYMENT" if c.get("tax_payment") else "")).strip()) row["_hst_kind"] = c["hst"] row["_is_card"] = r["is_card"] row["_income_source"] = c.get("income_source", "") ledger.append(row) # ---- transfers and card payments between his own accounts pairs = 0 by_amt = defaultdict(list) for row in ledger: by_amt[round(abs(float(row["amount_cad"])), 2)].append(row) used = set() for amt, group in by_amt.items(): if len(group) < 2: continue group.sort(key=lambda x: x["date"]) for a in group: if a["id"] in used or float(a["amount_cad"]) >= 0: continue for b in group: if b["id"] in used or b is a or float(b["amount_cad"]) <= 0 or b["account"] == a["account"]: continue if abs((T.parse_date(a["date"]) - T.parse_date(b["date"])).days) > 3: continue a_x = a["type"] in ("transfer", "payment-to-card") b_x = b["type"] in ("transfer", "payment-to-card") unknownish = (a["business"] == "unknown" or XFER_WORDS.search(a["raw_description"])) and \ (b["business"] == "unknown" or XFER_WORDS.search(b["raw_description"])) if not (a_x or b_x or unknownish): continue t = "payment-to-card" if (b.get("_is_card") or a.get("_is_card") or "payment-to-card" in (a["type"], b["type"])) else "transfer" for x, y in ((a, b), (b, a)): x.update(type=t, category_cra_line="", label="card payment" if t == "payment-to-card" else "transfer", business="transfer", business_pct=0.0, needs_review="n", review_reason="") x["match_ids"] = ";".join(filter(None, [x["match_ids"], "pair:" + y["id"]])) used.update({a["id"], b["id"]}) pairs += 1 break # ---- receipts (Gmail + Amazon): match to bank lines, else keep as receipt-only rows prof_rows = [] if gmail_path and Path(gmail_path).exists(): prof_rows += gmail_rows(gmail_path, prof) for p in sorted(amz_dir.rglob("*.csv")) if amz_dir.exists() else []: orders = parse_amazon(p) log.append(f"{p.name}: Amazon order history, {len(orders)} orders") prof_rows += amazon_rows(orders, prof) stmt_dates = [T.parse_date(r["date"]) for r in ledger] cover = (min(stmt_dates), max(stmt_dates)) if stmt_dates else None matched, unmatched = 0, 0 claimed = set() for rr in prof_rows: rec = rr["_receipt"] best = None for b in ledger: if b["id"] in claimed or b["type"] not in ("expense",) or b["account"] in ("gmail-receipt", "amazon-orders"): continue dd = abs((T.parse_date(b["date"]) - rec["date"]).days) if dd > rec["window"] or not amount_match(rec, float(b["amount_cad"])): continue if rec["kind"] == "amazon": if (T.match_rule(b["raw_description"]) or {}).get("vendor") != "Amazon": continue elif not vendor_match(rec["vendor"], b): continue if best is None or dd < best[0]: best = (dd, b) if best: b = best[1] claimed.add(b["id"]) matched += 1 b["match_ids"] = ";".join(filter(None, [b["match_ids"], rec["id"]])) b.update(category_cra_line=rec["line"], business_pct=rec["pct"], business=rec["biz"], label=rec["label"] or b["label"]) if rec["kind"] == "amazon": b["raw_description"] += " | items: " + " / ".join(rec.get("items", []))[:200] keep = [x for x in b["review_reason"].split("; ") if x and not x.startswith(("unknown vendor", "rule says review"))] keep += [x for x in rr["review_reason"].split("; ") if x] b["review_reason"] = "; ".join(dict.fromkeys(keep)) b["needs_review"] = "y" if b["review_reason"] else "n" if rec.get("hst") is not None: b["_hst_full"] = rr["_hst_full"] * (abs(float(b["amount_cad"])) / abs(float(rr["amount_cad"])) if float(rr["amount_cad"]) else 1) continue unmatched += 1 rr["id"] = T.make_id(rr["match_ids"], rr["date"], rr["amount_cad"]) if cover and cover[0] <= rec["date"] <= cover[1] and rec["total"] is not None: rr["needs_review"] = "y" rr["review_reason"] = "; ".join(filter(None, [rr["review_reason"], "receipt not found on loaded statements (other card, PayPal, or split charge?)"])) ledger.append(rr) # ---- money fields for row in ledger: if "_hst_kind" not in row: row["_hst_kind"] = "ca" money_fields(row, row.get("_hst_full")) for k in ("business_pct", "amount_cad"): row[k] = f"{float(row[k]):.2f}" if k == "amount_cad" else f"{float(row[k]):g}" row["deductible_amount"] = f"{float(row['deductible_amount']):.2f}" row["hst_est"] = f"{float(row['hst_est']):.2f}" if not row["id"]: row["id"] = T.make_id(row["date"], row["raw_description"], row["amount_cad"]) # ---- the owner's per-charge answers (data/charge_decisions.json), keyed by ledger id or "date|amount" dec = T.load_json(T.DATA / "charge_decisions.json", {}) or {} for row in ledger: d = dec.get(row["id"]) or dec.get(f"{row['date']}|{row['amount_cad']}") if not d or d.get("_skip"): continue row["category_cra_line"] = d.get("line", "") row["business_pct"] = f"{float(d.get('pct', 0)):g}" row["business"] = d.get("business", "Business" if float(d.get("pct", 0)) > 0 else "Personal") row["notes"] = "; ".join(filter(None, [f"Owner: {d.get('reason', '')}", row["notes"]])) row["needs_review"], row["review_reason"] = "n", "" if d.get("hst") == "none" or re.search(r"e-transfer", row["raw_description"], re.I): row["_hst_kind"], row["_hst_full"] = "none", 0.0 # payments to people carry no HST money_fields(row, row.get("_hst_full")) row["deductible_amount"] = f"{float(row['deductible_amount']):.2f}" row["hst_est"] = f"{float(row['hst_est']):.2f}" ledger.sort(key=lambda x: (x["date"], x["account"], x["amount_cad"])) stats = dict(files=log, raw=len(raw), duplicates_removed=dups, other_year_skipped=skipped_year, transfer_pairs=pairs, receipts_matched=matched, receipts_unmatched=unmatched, rows=len(ledger), needs_review=sum(1 for r in ledger if r["needs_review"] == "y"), vehicle_pct=veh_pct) return ledger, stats def main(argv=None): ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--inbox", default=str(T.INBOX)) ap.add_argument("--out", default=str(T.DATA / "ledger-2026.csv")) ap.add_argument("--gmail", default=str(T.DATA / "expenses-gmail-2026.csv")) ap.add_argument("--no-gmail", action="store_true") ap.add_argument("--quiet", action="store_true") a = ap.parse_args(argv) T.ensure_templates() prof = T.profile() ledger, st = build(Path(a.inbox), None if a.no_gmail else a.gmail, prof, a.quiet) T.write_csv(a.out, ledger, T.LEDGER_FIELDS) if not a.quiet: print(f"ledger: {a.out}") for line in st["files"] or ["(inbox empty: only Gmail receipts loaded)"]: print(" " + line) print(f" {st['rows']} rows | {st['duplicates_removed']} duplicates removed | {st['transfer_pairs']} transfer/card-payment pairs" f" | receipts matched {st['receipts_matched']}, receipt-only {st['receipts_unmatched']}" f" | {st['needs_review']} need review | other-year rows skipped {st['other_year_skipped']}") if st["vehicle_pct"] is None: print(" vehicle business % unknown: add odometer readings to data/vehicle.json") return ledger, st if __name__ == "__main__": main() ``` ================================================================== === FILE: bookkeeper/scripts/month_summary.py ================================================================== ``` #!/usr/bin/env python3 """ month_summary.py -- one-page money summary for a month (or the year so far) from data/ledger-2026.csv. python3 scripts/month_summary.py # last full month python3 scripts/month_summary.py --month 2026-09 python3 scripts/month_summary.py --year # Jan 1 to today Prints (and writes data/summary-.md): 1. Money in: business income by source (from deposits) 2. Business expenses by T2125 line (amount, deductible estimate, HST input tax credit estimate) 3. Personal spending by label (for the budget) 4. Set-aside to move to your tax savings account (YOU move it): income tax + CPP = business income x tax_set_aside_pct (data/tax_profile.json, a % you get from your accountant or last year's return; this script never guesses it) HST = sales tax inside your deposits minus input tax credits (only if hst_registered) 5. What still needs you: rows to review, business charges with no receipt linked Estimates only. Not tax advice. Your accountant files. """ import argparse import sys from collections import defaultdict from datetime import date from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) import taxlib as T # noqa: E402 def period(a): if a.year: return f"{T.YEAR}-01-01", T.TODAY.isoformat(), f"{T.YEAR}-ytd" if a.month: y, m = map(int, a.month.split("-")) else: t = T.TODAY y, m = (t.year, t.month - 1) if t.month > 1 else (t.year - 1, 12) start = date(y, m, 1) end = date(y + (m == 12), m % 12 + 1, 1) return start.isoformat(), end.isoformat(), f"{y}-{m:02d}" def main(argv=None): ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--ledger", default=str(T.DATA / "ledger-2026.csv")) ap.add_argument("--month") ap.add_argument("--year", action="store_true") ap.add_argument("--receipt-over", type=float, default=50.0, help="flag business charges over this with no receipt") a = ap.parse_args(argv) start, end, name = period(a) prof = T.profile() rows = [r for r in T.read_csv(a.ledger) if start <= r["date"] < end or (a.year and start <= r["date"] <= end)] inc = defaultdict(float) biz = defaultdict(lambda: [0.0, 0.0, 0.0]) pers = defaultdict(float) review, no_receipt = [], [] hst_in_deposits = 0.0 for r in rows: amt = T.fnum(r["amount_cad"]) if r["type"] == "income": inc[r["vendor"]] += amt if r["business"] in ("main", "side", "split"): hst_in_deposits += amt * T.HST_FRACTION elif r["type"] in ("expense", "refund"): line = r["category_cra_line"] if line and T.fnum(r["business_pct"]) > 0: b = biz[f"{line} {T.CRA_LINES.get(line, '')}".strip()] b[0] += -amt b[1] += T.fnum(r["deductible_amount"]) b[2] += T.fnum(r["hst_est"]) if -amt >= a.receipt_over and not r["match_ids"]: no_receipt.append(r) else: pers[r["label"] or "uncategorized"] += -amt if r["needs_review"] == "y": review.append(r) total_inc = sum(inc.values()) itc = sum(b[2] for b in biz.values()) pct = prof.get("tax_set_aside_pct") out = [f"# Money summary {name}", "", "Estimates only, not tax advice. Your accountant files.", ""] out += ["## 1. Money in (business deposits)", ""] out += [f"- {k}: {T.money(v, 2)}" for k, v in sorted(inc.items(), key=lambda x: -x[1])] or ["- none on loaded statements"] out += [f"- **Total: {T.money(total_inc, 2)}**", ""] out += ["## 2. Business expenses by T2125 line", "", "| Line | Spent | Deductible (est.) | HST credit (est.) |", "|---|---|---|---|"] out += [f"| {k} | {T.money(v[0], 2)} | {T.money(v[1], 2)} | {T.money(v[2], 2)} |" for k, v in sorted(biz.items())] out += ["", "## 3. Personal spending by label", ""] out += [f"- {k}: {T.money(v, 2)}" for k, v in sorted(pers.items(), key=lambda x: -x[1])] or ["- none"] out += ["", "## 4. Move to your tax savings account (you move it, never the agent)", ""] if pct is None: out.append("- Income tax + CPP: set `tax_set_aside_pct` in data/tax_profile.json first (ask your accountant, or use last year's tax owed / business income).") else: out.append(f"- Income tax + CPP at your {float(pct):g}%: **{T.money(total_inc * float(pct) / 100, 2)}**") if T.HST_REGISTERED: out.append(f"- HST: about {T.money(hst_in_deposits, 2)} inside your deposits, minus {T.money(itc, 2)} input tax credits = **{T.money(hst_in_deposits - itc, 2)}** (assumes your deposits include the HST you charged)") else: out.append("- HST: not registered (hst_registered = false), nothing to set aside for HST.") out += ["", "## 5. What still needs you", "", f"- Rows to review: {len(review)}"] out += [f" - {r['date']} {r['vendor']} {T.money(T.fnum(r['amount_cad']), 2)}: {r['review_reason']}" for r in sorted(review, key=lambda r: T.fnum(r['amount_cad']))[:15]] out += [f"- Business charges over {T.money(a.receipt_over)} with no receipt linked: {len(no_receipt)}"] out += [f" - {r['date']} {r['vendor']} {T.money(T.fnum(r['amount_cad']), 2)}" for r in sorted(no_receipt, key=lambda r: T.fnum(r['amount_cad']))[:10]] text = "\n".join(T.clean_text(x) for x in out) + "\n" p = T.DATA / f"summary-{name}.md" p.write_text(text, encoding="utf-8") print(text) print(f"written: {p}") if __name__ == "__main__": main() ``` ================================================================== === FILE: bookkeeper/scripts/notion_export.py ================================================================== ``` #!/usr/bin/env python3 """ notion_export.py -- write Notion-import-ready CSVs. Does NOT call Notion. Writes data/notion-ready/ transactions.csv Name, Date, Amount, Account, Vendor, Type, CRA Line, Category, Business/Personal, Business %, Deductible, HST, Recurring, Needs Review, Receipt Link, Notes income.csv Name, Date, Source, Payer, Amount, HST, Deductions, Net, Currency, Status, Evidence, Confidence, Notes subscriptions.csv Name, Plan, Cadence, Monthly CAD, Annual CAD, Spent 2026 CAD, Last Charge, Status, Business %, Category, In Ledger, Price Changes, Flags, Notes mileage.csv Name, Date, Start, Stops, Purpose, Client/Property, KM, Trip Type, Source, Confidence Import in Notion: New database > ... > Merge with CSV (or Import > CSV). Set column types after import: Date = date, Amount/Deductible/HST/KM = number, Recurring/Needs Review = checkbox ("Yes"/"No"), Type/CRA Line/Category/Business/Personal/Status = select, Receipt Link = URL. """ import argparse import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) import taxlib as T # noqa: E402 TX = ["Name", "Date", "Amount", "Account", "Vendor", "Type", "CRA Line", "Category", "Business/Personal", "Business %", "Deductible", "HST", "Recurring", "Needs Review", "Receipt Link", "Notes"] INC = ["Name", "Date", "Source", "Payer", "Amount", "HST", "Deductions", "Net", "Currency", "Status", "Evidence", "Confidence", "Notes"] SUB = ["Name", "Plan", "Cadence", "Monthly CAD", "Annual CAD", "Spent 2026 CAD", "Last Charge", "Status", "Business %", "Category", "In Ledger", "Price Changes", "Flags", "Notes"] MIL = ["Name", "Date", "Start", "Stops", "Purpose", "Client/Property", "KM", "Trip Type", "Source", "Confidence"] import os DEFAULT_INBOX = os.environ.get("GMAIL_ADDRESS", "0") # your Gmail address (or account number 0, 1, 2) GMAIL_URL = "https://mail.google.com/mail/u/{inbox}/#all/{mid}" def yn(v): return "Yes" if str(v).lower() in ("y", "yes", "true", "1") else "No" _INBOX = {} def gmail_url(mid): """Open the receipt in the inbox it came from (receipts-gmail-2026.csv 'inbox' column; default GMAIL_ADDRESS).""" if not _INBOX: _INBOX[""] = "" for r in T.read_csv(T.DATA / "receipts-gmail-2026.csv") if (T.DATA / "receipts-gmail-2026.csv").exists() else []: if r.get("inbox"): _INBOX[r["message_id"]] = r["inbox"] inbox = _INBOX.get(mid) or DEFAULT_INBOX return GMAIL_URL.format(inbox=inbox, mid=mid) def receipt_link(match_ids): for m in (match_ids or "").split(";"): if m.startswith("gmail:"): return gmail_url(m.split(":", 1)[1]) if m.startswith("amazon:"): return "https://www.amazon.ca/gp/your-account/order-details?orderID=" + m.split(":", 1)[1] return "" def bp(r): b = r["business"] if b in ("transfer",): return "Transfer" if b == "personal" or T.fnum(r["business_pct"]) == 0: return "Personal" if b != "unknown" else "Unknown" if b == "main": return "Business (main)" if b == "side": return "Business (side)" return "Split" def main(argv=None): ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--ledger", default=str(T.DATA / "ledger-2026.csv")) ap.add_argument("--out", default=str(T.DATA / "notion-ready")) a = ap.parse_args(argv) out = Path(a.out) tx = [] for r in T.read_csv(a.ledger): line = r["category_cra_line"] tx.append({ "Name": f"{r['vendor']} {r['date']}", "Date": r["date"], "Amount": r["amount_cad"], "Account": r["account"], "Vendor": r["vendor"], "Type": r["type"], "CRA Line": f"{line} {T.CRA_LINES.get(line, '')}".strip() if line else "", "Category": r["label"], "Business/Personal": bp(r), "Business %": r["business_pct"], "Deductible": r["deductible_amount"], "HST": r["hst_est"], "Recurring": yn(r["recurring"]), "Needs Review": yn(r["needs_review"]), "Receipt Link": receipt_link(r["match_ids"]), "Notes": "; ".join(filter(None, [r["review_reason"], r["notes"], r["raw_description"][:120], (r["orig_amount"] and "orig " + r["orig_amount"]) or ""])), }) T.write_csv(out / "transactions.csv", tx, TX) inc = [] for r in T.read_csv(T.DATA / "income-2026.csv"): ev = r.get("evidence", "") mid = ev.split(" ")[0] if ev else "" inc.append({"Name": (r.get("description/property") or "")[:80], "Date": r["date"], "Source": r["source"], "Payer": r["payer"], "Amount": r["gross"], "HST": r["hst"], "Deductions": r["deductions"], "Net": r["net"], "Currency": r["currency"], "Status": r["status"], "Evidence": GMAIL_URL.format(inbox=DEFAULT_INBOX, mid=mid) if mid and len(mid) >= 12 and mid.isalnum() else ev, "Confidence": r.get("confidence", ""), "Notes": ev}) for r in T.read_csv(a.ledger): if r["type"] == "income": inc.append({"Name": f"{r['vendor']} deposit {r['date']}", "Date": r["date"], "Source": r["business"], "Payer": r["vendor"], "Amount": r["amount_cad"], "HST": "", "Deductions": "", "Net": r["amount_cad"], "Currency": "CAD", "Status": "received (bank)", "Evidence": r["account"], "Confidence": "high", "Notes": r["raw_description"]}) T.write_csv(out / "income.csv", inc, INC) sub = [] for s in T.read_csv(T.DATA / "subscriptions-view-2026.csv"): sub.append({"Name": s["vendor"], "Plan": s["plan"], "Cadence": s["cadence"], "Monthly CAD": s["monthly_cost_cad"], "Annual CAD": s["annualized_cad"], "Spent 2026 CAD": s["spent_2026_cad"], "Last Charge": s["last_charge"], "Status": s["status"], "Business %": s["business_pct"], "Category": s["category"], "In Ledger": yn(s["in_ledger"]), "Price Changes": s["price_changes"], "Flags": s["flags"], "Notes": s["notes"]}) T.write_csv(out / "subscriptions.csv", sub, SUB) mil = [] for m in T.read_csv(T.DATA / "mileage-2026.csv"): mil.append({"Name": f"{m['date']} {(m.get('client_property') or m.get('purpose') or '')[:60]}", "Date": m["date"], "Start": m["start"], "Stops": m["stops"], "Purpose": m["purpose"], "Client/Property": m["client_property"], "KM": m["km"], "Trip Type": m["trip_type"], "Source": m["calendar_source"], "Confidence": m["confidence"]}) T.write_csv(out / "mileage.csv", mil, MIL) print(f"notion-ready: {out}/ transactions {len(tx)}, income {len(inc)}, subscriptions {len(sub)}, mileage {len(mil)}") if __name__ == "__main__": main() ``` ================================================================== === FILE: bookkeeper/scripts/taxlib.py ================================================================== ``` #!/usr/bin/env python3 """ taxlib.py -- shared helpers for the /tax bookkeeping engine (tax year 2026). Stdlib only. Imported by ingest_statements.py, detect_recurring.py and notion_export.py. Estimates only, not tax advice. Tax year: the engine is set up for 2026 (file names like ledger-2026.csv). Sales tax: set "sales_tax_rate" in data/tax_profile.json (0.13 = Ontario HST) and "hst_registered" (true/false). Check your province's rate on canada.ca. """ import csv import hashlib import json import re import sys from datetime import date, datetime, timedelta from pathlib import Path SKILL = Path(__file__).resolve().parent.parent DATA = SKILL / "data" INBOX = SKILL / "inbox" SCRIPTS = SKILL / "scripts" YEAR = 2026 TODAY = date(2026, 10, 21) if date.today().year != 2026 else date.today() # Planning FX (same as subscriptions-2026.md). Override in data/tax_profile.json. DEFAULT_FX = {"CAD": 1.0, "USD": 1.38, "EUR": 1.50, "GBP": 1.75} def _profile_value(key, default): try: with open(DATA / "tax_profile.json", encoding="utf-8") as fh: v = json.load(fh).get(key) return default if v is None else v except (OSError, ValueError): return default HST_RATE = float(_profile_value("sales_tax_rate", 0.13)) # Ontario HST 13% by default HST_FRACTION = HST_RATE / (1 + HST_RATE) # tax part of a tax-included price HST_REGISTERED = bool(_profile_value("hst_registered", True)) # False = no input tax credits, HST stays in the expense CRA_LINES = { "8521": "Advertising", "8523": "Meals and entertainment (50%)", "8590": "Bad debts", "8690": "Insurance", "8710": "Interest and bank charges", "8760": "Business taxes, licences and memberships", "8810": "Office expenses", "8811": "Office stationery and supplies", "8860": "Professional fees", "8871": "Management and administration fees", "8910": "Rent", "8960": "Repairs and maintenance", "9060": "Salaries and wages", "9200": "Travel", "9220": "Telephone and utilities", "9270": "Other expenses", "9275": "Delivery, freight and express", "9281": "Motor vehicle expenses", "9936": "Capital cost allowance (CCA)", "9945": "Business-use-of-home", } LEDGER_FIELDS = [ "id", "date", "account", "raw_description", "vendor", "amount_cad", "currency", "orig_amount", "type", "category_cra_line", "label", "business", "business_pct", "deductible_amount", "hst_est", "recurring", "source_file", "match_ids", "needs_review", "review_reason", "notes", ] # --------------------------------------------------------------------------- io def read_csv(path): path = Path(path) if not path.exists(): return [] with open(path, newline="", encoding="utf-8-sig") as f: return list(csv.DictReader(f)) def write_csv(path, rows, fields): path = Path(path) path.parent.mkdir(parents=True, exist_ok=True) with open(path, "w", newline="", encoding="utf-8") as f: w = csv.DictWriter(f, fieldnames=fields, extrasaction="ignore") w.writeheader() for r in rows: w.writerow({k: clean_text(r.get(k, "")) for k in fields}) def load_json(path, default=None): path = Path(path) if not path.exists(): return default with open(path, encoding="utf-8") as f: return json.load(f) def clean_text(v): """No em-dashes or en-dashes in any output.""" if isinstance(v, str): return v.replace("\u2014", ", ").replace("\u2013", "-") return v def profile(): p = load_json(DATA / "tax_profile.json", {}) or {} fx = dict(DEFAULT_FX) fx.update({k.upper(): float(v) for k, v in (p.get("fx_to_cad") or {}).items() if v}) p["fx"] = fx return p def fx_rate(cur, prof=None): cur = (cur or "CAD").upper().strip() fx = (prof or {}).get("fx") or DEFAULT_FX return fx.get(cur, 1.0) # ------------------------------------------------------------------ parsing MONTHS = {m: i for i, m in enumerate( ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"], 1)} def parse_date(s): """Parse the date formats Canadian banks use. Returns date or None.""" if s is None: return None s = str(s).strip().strip('"') if not s: return None s = s.split("T")[0] if re.match(r"\d{4}-\d{2}-\d{2}T", s) else s fmts = ["%Y-%m-%d", "%Y/%m/%d", "%m/%d/%Y", "%d/%m/%Y", "%m/%d/%y", "%Y%m%d", "%d %b %Y", "%d %b. %Y", "%b %d, %Y", "%b %d %Y", "%d-%b-%Y", "%d-%b-%y", "%B %d, %Y", "%Y-%m-%d %H:%M:%S", "%m-%d-%Y"] for f in fmts: try: return datetime.strptime(s, f).date() except ValueError: pass m = re.match(r"(\d{1,2})\s+([A-Za-z]{3})\.?\s+(\d{4})", s) if m and m.group(2).lower() in MONTHS: return date(int(m.group(3)), MONTHS[m.group(2).lower()], int(m.group(1))) return None def parse_amount(s): """'$1,234.56', '(12.00)', '-12', '12.00 CR', '1 234,56' -> float or None.""" if s is None: return None s = str(s).strip().replace(" ", " ") if not s or s in ("-", "--"): return None neg = False if s.startswith("(") and s.endswith(")"): neg, s = True, s[1:-1] if re.search(r"\bCR\b", s, re.I): neg, s = True, re.sub(r"\bCR\b", "", s, flags=re.I) s = s.replace("$", "").replace("CAD", "").replace("USD", "").replace(" ", "") if s.startswith("-"): neg, s = (not neg), s[1:] if s.endswith("-"): neg, s = (not neg), s[:-1] if re.fullmatch(r"\d{1,3}(\.\d{3})*,\d{2}", s): # 1.234,56 s = s.replace(".", "").replace(",", ".") s = s.replace(",", "") try: v = float(s) except ValueError: return None return -v if neg else v def norm(s): return re.sub(r"\s+", " ", re.sub(r"[^A-Z0-9 ]", " ", str(s or "").upper())).strip() def make_id(*parts): return hashlib.sha1("|".join(str(p) for p in parts).encode()).hexdigest()[:12] def money(x, dp=0): if x is None: return "n/a" fmt = "{:,.%df}" % dp return ("-$" if x < 0 else "$") + fmt.format(abs(x)) def f2(x): return "" if x is None or x == "" else f"{float(x):.2f}" def fnum(v, default=0.0): try: if v is None or v == "": return default return float(v) except (TypeError, ValueError): return default # ------------------------------------------------------------ vendor rules _RULES = None def load_rules(path=None): global _RULES if _RULES is not None and path is None: return _RULES data = load_json(path or (DATA / "vendor_rules.json"), {}) rules = [] for pat, spec in (data.get("rules") or {}).items(): try: rx = re.compile(pat, re.I) except re.error as e: print(f"bad regex in vendor_rules.json: {pat} ({e})", file=sys.stderr) continue rules.append((rx, spec)) items = [] for pat, spec in (data.get("item_rules") or {}).items(): items.append((re.compile(pat, re.I), spec)) _RULES = {"rules": rules, "items": items, "meta": data.get("_meta", {})} return _RULES def match_rule(text, rules=None): rules = rules or load_rules() t = str(text or "") for rx, spec in rules["rules"]: if rx.search(t): return spec return None def match_item(text, rules=None): rules = rules or load_rules() for rx, spec in rules["items"]: if rx.search(str(text or "")): return spec return None _NOISE = [ r"^(SQ|TST|SP|PP|PAYPAL|PY|IC|DD|WWW|POS|OPOS|IDP|APOS|FPOS|VISA DEBIT|INTERAC|PURCHASE|" r"DEBIT CARD PURCHASE|CONTACTLESS|POINT OF SALE|RETAIL PURCHASE|MOBILE PURCHASE)\s*\*?\s*", r"\s+#?\d{3,}.*$", # store numbers and everything after r"\s+(ON|ONT|CA|CAN|USA|US|NY|CA\s+USA)$", r"\s+\S+\.(COM|CA|IO|AI)\b.*$", ] def clean_vendor(desc): s = re.sub(r"\s+", " ", str(desc or "").upper()).strip() for pat in _NOISE: s2 = re.sub(pat, "", s).strip() if len(s2) >= 3: s = s2 s = re.sub(r"[*#]+", " ", s) s = re.sub(r"\s+", " ", s).strip(" -.,") return s.title() if s else str(desc or "").strip() # ------------------------------------------------------------ vehicle + templates VEHICLE_TEMPLATE = { "_help": "Fill what you know. Odometer readings make the business % real. Leave null if unknown. Amounts in CAD, HST included as paid.", "odometer_jan1_2026": None, "odometer_latest": None, "odometer_latest_date": None, "total_km_2026_override": None, "count_office_trips_as_business": False, "vehicle": {"make_model": None, "owned_or_leased": None, "purchase_date": None, "purchase_price_before_tax": None, "zero_emission": False, "ucc_start_2026": None}, "lease_payment_monthly_before_tax": None, "lease_months_2026": None, "loan_interest_2026": None, "insurance_2026": None, "licence_registration_2026": None, "maintenance_repairs_2026": None, "fuel_2026_override": None, "car_washes_2026": None, "other_vehicle_costs_2026": None, } HOME_OFFICE_TEMPLATE = { "_help": "Business-use-of-home (T2125 line 9945). Office must be the principal place of business OR used only for business and regularly to meet clients. Utilities/insurance/etc are ANNUAL 2026 totals, CAD. Phone and internet go on 9220, not here.", "office_sqft": None, "home_finished_sqft": None, "owner_or_renter": None, "principal_place_of_business": None, "rent_monthly": None, "months_2026": 12, "mortgage_interest_annual": None, "utilities_annual": None, "home_insurance_annual": None, "property_tax_annual": None, "condo_fees_monthly": None, "maintenance_annual": None, } TAX_PROFILE_TEMPLATE = { "_help": "Inputs the report cannot see in Gmail or statements. null = unknown.", "fx_to_cad": {"USD": 1.38, "EUR": 1.50}, "sales_tax_rate": 0.13, "hst_registered": True, "hst_method": "regular", "tax_set_aside_pct": None, "income_2026_override": None, "expected_more_income_2026": None, "tax_set_aside_saved": None, "tax_instalments_paid_2026": None, "hst_remitted_2026": None, "rrsp_room_2026": None, "rrsp_contributed_2026": None, "fhsa_open": None, "fhsa_contributed_2026": None, "earned_income_2025": None, } def ensure_templates(): made = [] for name, tpl in [("vehicle.json", VEHICLE_TEMPLATE), ("home_office.json", HOME_OFFICE_TEMPLATE), ("tax_profile.json", TAX_PROFILE_TEMPLATE)]: p = DATA / name if not p.exists(): with open(p, "w", encoding="utf-8") as f: json.dump(tpl, f, indent=2) made.append(name) return made def mileage_summary(path=None): rows = read_csv(path or (DATA / "mileage-2026.csv")) biz = sum(fnum(r.get("km")) for r in rows if (r.get("trip_type") or "").lower() == "business") office = sum(fnum(r.get("km")) for r in rows if (r.get("trip_type") or "").lower() == "office") dates = [parse_date(r.get("date")) for r in rows] dates = [d for d in dates if d] return {"rows": len(rows), "business_km": biz, "office_km": office, "first": min(dates) if dates else None, "last": max(dates) if dates else None} def vehicle_business_pct(veh=None, mil=None): """Return (pct 0-100 or None, info dict).""" veh = veh if veh is not None else (load_json(DATA / "vehicle.json", {}) or {}) mil = mil or mileage_summary() biz = mil["business_km"] + (mil["office_km"] if veh.get("count_office_trips_as_business") else 0) total = veh.get("total_km_2026_override") o1, o2 = veh.get("odometer_jan1_2026"), veh.get("odometer_latest") if not total and o1 is not None and o2 is not None and float(o2) > float(o1): total = float(o2) - float(o1) info = {"business_km": biz, "total_km": total} if not total: return None, info return max(0.0, min(100.0, biz / float(total) * 100)), info ``` ================================================================== === FILE: bookkeeper/scripts/tests/fixtures/gmail-receipts.csv ================================================================== date,vendor,description,subtotal,hst,total,currency,category_cra,capital_asset,business_pct,business_reason,personal_flag,message_id,confidence 2026-10-07,Kie.ai,"10,000 credits one-time top-up",,,50.00,USD,8810 office expenses (software),n,100,side: AI image API,n,abc123testmsg,high 2026-01-20,Bellroy,"Wallet",75.00,9.75,84.75,CAD,personal,n,0,personal wallet,y,def456testmsg,high ================================================================== === FILE: bookkeeper/scripts/tests/fixtures/inbox/amazon/Retail.OrderHistory.1.csv ================================================================== "Website","Order ID","Order Date","Purchase Order Number","Currency","Unit Price","Unit Price Tax","Shipping Charge","Total Discounts","Total Owed","Shipment Item Subtotal","Shipment Item Subtotal Tax","ASIN","Product Condition","Quantity","Payment Instrument Type","Order Status","Shipment Status","Ship Date","Shipping Option","Shipping Address","Billing Address","Carrier Name & Tracking Number","Product Name","Gift Message","Gift Sender Name","Gift Recipient Contact Details","Item Serial Number" "Amazon.ca","702-1111111-1111111","2026-03-19T15:22:11Z","Not Applicable","CAD","34.99","4.55","0","0","39.54","34.99","4.55","B0AAA","New","1","Visa","Closed","Shipped","2026-03-20","std","","","","SanDisk 256GB Extreme microSD Card","","","","" "Amazon.ca","702-1111111-1111111","2026-03-19T15:22:11Z","Not Applicable","CAD","44.10","5.73","0","0","49.83","44.10","5.73","B0BBB","New","1","Visa","Closed","Shipped","2026-03-20","std","","","","Kitchen Spatula Set, Silicone","","","","" "Amazon.ca","702-2222222-2222222","2026-02-01T10:00:00Z","Not Applicable","CAD","17.70","2.30","0","0","20.00","17.70","2.30","B0CCC","New","1","Visa","Closed","Shipped","2026-02-02","std","","","","Paperback novel","","","","" "Amazon.ca","702-3333333-3333333","2026-03-01T10:00:00Z","Not Applicable","CAD","99.00","12.87","0","0","111.87","99.00","12.87","B0DDD","New","1","Visa","Cancelled","Not Shipped","","std","","","","Cancelled thing","","","","" ================================================================== === FILE: bookkeeper/scripts/tests/fixtures/inbox/bank-statements/amex-gold.csv ================================================================== Date,Date Processed,Description,Cardmember,Amount,Foreign Spend Amount,Commission,Exchange Rate,Merchant,Merchant Address 21 Mar 2026,22 Mar 2026,AMAZON.CA*AB12CD,ALEX SAMPLE,89.37,,,,Amazon.ca, 21 Mar 2026,21 Mar 2026,PAYMENT RECEIVED - THANK YOU,ALEX SAMPLE,-250.00,,,,, 22 Mar 2026,23 Mar 2026,LCBO/RAO #217,ALEX SAMPLE,45.10,,,,LCBO, 23 Mar 2026,24 Mar 2026,407 ETR,ALEX SAMPLE,12.34,,,,407 ETR, 24 Mar 2026,25 Mar 2026,VISTAPRINT.CA,ALEX SAMPLE,120.00,,,,Vistaprint, ================================================================== === FILE: bookkeeper/scripts/tests/fixtures/inbox/bank-statements/rbc-2026-overlap.csv ================================================================== "Account Type","Account Number","Transaction Date","Cheque Number","Description 1","Description 2","CAD$","USD$" Visa,4512010000001234,4/3/2026,,"NETFLIX.COM","",-20.99, Visa,4512010000001234,5/3/2026,,"NETFLIX.COM","",-20.99, Visa,4512010000001234,3/9/2026,,"STAPLES STORE #123","",-650.00, Visa,4512010000001234,6/3/2026,,"NETFLIX.COM","",-22.99, Visa,4512010000001234,10/7/2026,,"KIE.AI","",-69.00,50.00 ================================================================== === FILE: bookkeeper/scripts/tests/fixtures/inbox/bank-statements/rbc-2026-q1.csv ================================================================== "Account Type","Account Number","Transaction Date","Cheque Number","Description 1","Description 2","CAD$","USD$" Chequing,06702-5012345,3/2/2026,,"SAMPLE REALTY","COMMISSION EFT",5650.00, Chequing,06702-5012345,3/5/2026,,"ONLINE BANKING PAYMENT - 1234","RBC VISA",-812.40, Chequing,06702-5012345,3/6/2026,,"Online Banking transfer - 1111","TFR-TO WS",-1000.00, Chequing,06702-5012345,3/10/2026,,"e-Transfer sent","JORDAN P",-291.00, Visa,4512010000001234,3/3/2026,,"PETRO-CANADA 12345 TORONTO ON","",-60.00, Visa,4512010000001234,3/3/2026,,"NETFLIX.COM","",-20.99, Visa,4512010000001234,3/4/2026,,"UBER* EATS PENDING","",-32.10, Visa,4512010000001234,3/4/2026,,"UBER *TRIP HELP.UBER.COM","",-18.50, Visa,4512010000001234,3/5/2026,,"PAYMENT - THANK YOU / PAIEMENT - MERCI","",812.40, Visa,4512010000001234,3/8/2026,,"MYSTERY VENDOR 99","",-45.00, Visa,4512010000001234,3/9/2026,,"STAPLES STORE #123","",-650.00, Visa,4512010000001234,4/3/2026,,"NETFLIX.COM","",-20.99, Visa,4512010000001234,5/3/2026,,"NETFLIX.COM","",-20.99, ================================================================== === FILE: bookkeeper/scripts/tests/fixtures/inbox/bank-statements/td_chequing.csv ================================================================== 12/30/2025,TIM HORTONS #1111,3.25,,1200.00 03/15/2026,TIM HORTONS #1234,4.50,,1195.50 03/16/2026,SEND E-TFR ***abc,100.00,,1095.50 03/20/2026,AMEX BILL PYMT,250.00,,845.50 03/25/2026,GOOGLE ADSENSE,,312.55,1158.05 ================================================================== === FILE: bookkeeper/scripts/tests/fixtures/inbox/bank-statements/wealthsimple-cash.csv ================================================================== date,transaction,description,amount,balance 2026-03-07,DEP,Deposit from RBC,1000.00,1000.00 ================================================================== === FILE: bookkeeper/scripts/tests/run_tests.py ================================================================== ``` #!/usr/bin/env python3 """ run_tests.py -- proves ingestion, de-duplication, transfer/card-payment detection, receipt matching and the downstream scripts work, using FAKE statements in fixtures/. python3 scripts/tests/run_tests.py Writes only to a temp folder; never touches data/ledger-2026.csv. """ import sys import tempfile from pathlib import Path HERE = Path(__file__).resolve().parent sys.path.insert(0, str(HERE.parent)) import taxlib as T # noqa: E402 import ingest_statements as I # noqa: E402 import detect_recurring as D # noqa: E402 import notion_export as N # noqa: E402 FIX = HERE / "fixtures" passed, failed = 0, 0 def check(name, cond, detail=""): global passed, failed if cond: passed += 1 print(f" PASS {name}") else: failed += 1 print(f" FAIL {name} {detail}") def find(rows, text, account=None): return [r for r in rows if text.upper() in r["raw_description"].upper() and (account is None or account in r["account"])] def main(): tmp = Path(tempfile.mkdtemp(prefix="tax-tests-")) out = tmp / "ledger.csv" print(f"temp: {tmp}") print("\n[1] format detection") for fname, want in [("rbc-2026-q1.csv", "RBC"), ("amex-gold.csv", "Amex Canada"), ("wealthsimple-cash.csv", "Wealthsimple")]: recs, fmt = I.parse_csv_file(FIX / "inbox/bank-statements" / fname) check(f"{fname} -> {want}", fmt == want, f"got {fmt}") recs, fmt = I.parse_csv_file(FIX / "inbox/bank-statements/td_chequing.csv") check("td_chequing.csv (no header) -> TD", fmt == "headerless" and recs and recs[0]["bank"] == "TD", f"got {fmt} {recs[:1]}") amex, _ = I.parse_csv_file(FIX / "inbox/bank-statements/amex-gold.csv") check("Amex charges flipped to negative (money out)", all(r["amount"] < 0 for r in amex if "PAYMENT" not in r["desc"])) check("Amex payment is positive on the card", [r["amount"] for r in amex if "PAYMENT" in r["desc"]] == [250.0]) print("\n[2] full ingest") ledger, st = I.main(["--inbox", str(FIX / "inbox"), "--out", str(out), "--gmail", str(FIX / "gmail-receipts.csv"), "--quiet"]) rows = T.read_csv(out) check("ledger written", out.exists() and len(rows) == len(ledger) > 0) check("3 duplicate rows removed across overlapping RBC files", st["duplicates_removed"] == 3, st["duplicates_removed"]) check("2025 row skipped", st["other_year_skipped"] == 1, st["other_year_skipped"]) check("Netflix appears exactly 4 times (Mar, Apr, May, Jun)", len(find(rows, "NETFLIX")) == 4, len(find(rows, "NETFLIX"))) ids = [r["id"] for r in rows] check("ids unique", len(ids) == len(set(ids))) print("\n[3] card payments + own transfers are not spending") rbc_pay = [r for r in rows if r["type"] == "payment-to-card" and "RBC" in r["account"]] check("RBC chequing -> RBC Visa payment tagged on BOTH sides", len(rbc_pay) == 2, [(r["account"], r["amount_cad"]) for r in rbc_pay]) amex_pair = [r for r in rows if r["type"] == "payment-to-card" and abs(abs(float(r["amount_cad"])) - 250) < 0.01] check("TD -> Amex 250 bill payment tagged on both sides", len(amex_pair) == 2, [(r["account"], r["type"]) for r in amex_pair]) xfer = [r for r in rows if r["type"] == "transfer" and abs(abs(float(r["amount_cad"])) - 1000) < 0.01] check("RBC -> Wealthsimple 1000 transfer paired", len(xfer) == 2 and all("pair:" in r["match_ids"] for r in xfer), [(r["account"], r["type"], r["match_ids"]) for r in xfer]) spend = -sum(float(r["amount_cad"]) for r in rows if r["type"] in ("expense", "refund")) want = 60 + 20.99 * 3 + 22.99 + 32.10 + 18.50 + 45 + 650 + 69 + 291 + 4.50 + 100 + 89.37 + 45.10 + 12.34 + 120 + 20.00 + 84.75 check(f"total spend excludes payments/transfers ({want:.2f})", abs(spend - want) < 0.02, f"got {spend:.2f}") print("\n[4] income") inc = [r for r in rows if r["type"] == "income"] check("commission deposit = income (main business)", any("SAMPLE REALTY" in r["raw_description"] and r["business"] == "main" for r in inc)) check("AdSense deposit = income (side business)", any("ADSENSE" in r["raw_description"] and r["business"] == "side" for r in inc)) print("\n[5] vendor rules") ue = find(rows, "UBER* EATS")[0] ur = find(rows, "UBER *TRIP")[0] check("Uber Eats -> eating out (personal)", ue["label"] == "eating out" and ue["business"] == "personal", ue["label"]) check("Uber trip -> rides", ur["label"] == "rides", ur["label"]) pc = find(rows, "PETRO-CANADA")[0] check("Petro-Canada -> 9281 fuel", pc["category_cra_line"] == "9281" and pc["label"] == "fuel") vp = find(rows, "VISTAPRINT")[0] check("Vistaprint -> 8521 advertising 100%, HST est 13/113", vp["category_cra_line"] == "8521" and abs(float(vp["hst_est"]) - 120 * 13 / 113) < 0.01, (vp["category_cra_line"], vp["hst_est"])) lc = find(rows, "LCBO")[0] check("LCBO -> personal alcohol, 0 deductible", lc["business"] == "personal" and float(lc["deductible_amount"]) == 0) mv = find(rows, "MYSTERY VENDOR")[0] check("unknown vendor -> needs_review", mv["needs_review"] == "y" and "unknown vendor" in mv["review_reason"]) stp = find(rows, "STAPLES")[0] check("Staples 650 -> flagged as possible capital asset", "capital" in stp["review_reason"], stp["review_reason"]) et = find(rows, "e-Transfer sent")[0] check("e-Transfer to a person -> needs_review", et["needs_review"] == "y") print("\n[6] receipt matching (Gmail + Amazon)") kie = find(rows, "KIE.AI", "RBC") check("Kie.ai bank charge linked to Gmail receipt", kie and "gmail:abc123testmsg" in kie[0]["match_ids"], kie[:1]) check("matched Gmail receipt not double counted", not [r for r in rows if r["account"] == "gmail-receipt" and "Kie" in r["vendor"]]) bell = [r for r in rows if r["account"] == "gmail-receipt" and "Bellroy" in r["vendor"]] check("unmatched Gmail receipt outside statement period kept (paid elsewhere)", len(bell) == 1 and bell[0]["needs_review"] == "n") amz = find(rows, "AMAZON.CA*AB12CD") check("Amex Amazon charge itemized from order history", amz and "amazon:702-1111111-1111111" in amz[0]["match_ids"] and 0 < float(amz[0]["business_pct"]) < 100, amz[:1]) check("matched Amazon order not double counted", not [r for r in rows if r["account"] == "amazon-orders" and "702-1111111" in r["match_ids"]]) check("unmatched Amazon order (Feb, before statements) kept", len([r for r in rows if r["account"] == "amazon-orders"]) == 1) check("cancelled Amazon order ignored", not [r for r in rows if "702-3333333" in r["match_ids"]]) print("\n[7] downstream scripts run on the test ledger") rec, view = D.main(["--ledger", str(out), "--out", str(tmp / "rec.csv"), "--view", str(tmp / "view.csv")]) nf = [r for r in rec if r["vendor"] == "Netflix"] check("Netflix detected as monthly", nf and nf[0]["cadence"] == "monthly", nf[:1]) check("Netflix price change 20.99 -> 22.99 caught", nf and "22.99" in nf[0]["price_changes"], nf[0]["price_changes"] if nf else "") N.main(["--ledger", str(out), "--out", str(tmp / "notion")]) hdr = (tmp / "notion/transactions.csv").read_text().splitlines()[0] check("Notion Transactions columns match schema", hdr == ",".join(N.TX), hdr) print("\n[8] PDF statement (pdftotext)") import shutil if shutil.which("pdftotext"): recs, fmt = I.parse_pdf_file(FIX / "pdf/rbc-visa-mar.pdf") got = [(str(r["date"]), r["amount"]) for r in recs] check("RBC Visa PDF parsed: 3 lines, charges negative, payment positive", got == [("2026-03-03", -60.0), ("2026-03-05", 812.4), ("2026-03-08", -45.0)], got) else: print(" SKIP pdftotext not installed (brew install poppler)") print(f"\n{passed} passed, {failed} failed") return failed if __name__ == "__main__": sys.exit(1 if main() else 0) ``` ================================================================== === FILE: budget/SKILL.md ================================================================== --- name: budget version: 1.0 description: > What the owner spends every month by plain category (housing, eating out, shopping, Amazon, subscriptions, e-Transfers by person, business costs), fixed vs variable, business vs personal, top merchants, what is still unknown, and how much they need to bring in per month under current / trimmed / lean spending. Writes monthly limits that /money-close compares against. Read only. Trigger "/budget", "what am I spending on", "how much do I need to make". argument-hint: "[--savings 1000] [--month YYYY-MM]" --- # /budget ## Hard rules - Read only. Never move money, cancel anything or write to Notion from this skill. - Count every dollar once: card payments, own-account transfers, currency conversions, investment buys and sells are not spending (the ledger already tags them; check before trusting a total). - Unknowns go to the owner as a list, biggest first. Never guess. - The "how much I need to make" number is a planning estimate. The tax part uses the owner's `tax_set_aside_pct` from `data/tax_profile.json`; if it is not set, show the number BEFORE tax and say so. ## Steps 1. Ledger current (/bookkeeper books). Use only full months for averages (the current month is partial). 2. Categories: the ledger `label` first; then group e-Transfers by person (net over the year: someone who paid you back nets to zero); split Amazon by the item reasons from /amazon-orders. 3. Write `{{SKILL_DIR}}/bookkeeper/data/budget-2026.md`: 1. category table (year total, average per full month) 2. fixed vs variable x business vs personal 3. month by month 4. top 25 merchants 5. e-Transfers by person (net) 6. how much you need to bring in per month: personal spending + savings target + business costs, grossed up by the set-aside % (current / trimmed / lean) 7. suggested monthly limits (one-off spikes set to a normal month) 8. unknowns, biggest first 4. Write `data/budget-2026.json` with `budget_monthly` = {category: monthly limit}. /money-close reads it. 5. Trimmed = the cancel candidates from /subscription-audit + discretionary categories down 30%. Lean = trimmed + most discretionary at half. The owner edits the limits; their numbers win. ## --month YYYY-MM Actual by category vs limit for that month, and the over-budget list. Does not rebuild the limits. ================================================================== === FILE: mileage-log/SKILL.md ================================================================== --- name: mileage-log version: 1.0 description: > The car logbook the CRA asks for (date, destination, purpose, km, odometer at year start and end), kept from now on by text or dashboard photo, plus a calendar-based ESTIMATE of past business trips for a year you did not track. Feeds the vehicle business % in the bookkeeper. Trigger "/mileage-log", "log a trip", "odometer", "how much did I drive for work". argument-hint: "[start | end | estimate | status]" --- # /mileage-log The rule this follows: bookkeeper/knowledge/cra-rules.md #2 (CRA, Motor vehicle records). A real log beats an estimate. Say that every time you produce an estimate. ## Hard rules - Never invent kilometres. A missing start or end = ask. - An end reading lower than the last logged reading = ask (typo?). - Commuting from home to your regular office is personal. Client visits, job sites, deliveries, business meetings away from your regular office are business. - Personal trips are logged too: the total km decides the business %. ## From now on (the real log) - **Start**: "start 123456, driving to
to meet about " (or a dashboard photo + where / why). Create a Mileage row: Trip "YYYY-MM-DD destination", Date, Odometer Start, Purpose, Destination, Business/Personal, Source "Trip log" (or "Odometer photo"). - **End**: "end 123534". Find today's open trip, set Odometer End and Km = End - Start. Reply in one line. - **Year start and end**: on Jan 1 and Dec 31 ask for a dashboard photo and log "Odometer Jan 1 ". Copy the readings into `{{SKILL_DIR}}/bookkeeper/data/vehicle.json` (`odometer_jan1_2026`, `odometer_latest`, `odometer_latest_date`). - Rows live in the Notion Mileage database (bookkeeper/notion/money-hq.md) and/or `data/mileage-2026.csv` (columns: date, start, stops, purpose, client_property, km, trip_type, calendar_source, confidence). ## estimate (a past year you did not track) 1. Read the calendar (read-only) for every event with an address that was a business stop. 2. Group by day; route home -> stops -> home with a maps tool the owner has (or ask for each distance). 3. Write each day as a row with Source "Calendar rebuild", confidence medium, and the event titles. 4. Tell the owner, in plain words: this is a reconstruction, not a contemporaneous logbook. Show it to your accountant before claiming it, and start a real log today. ## status Business km vs total km so far this year, open trips with no end, and the vehicle business % the bookkeeper will use. ================================================================== === FILE: money-close/SKILL.md ================================================================== --- name: money-close version: 1.0 description: > The one monthly money command. Runs the bookkeeping skills in order: download statements, Amazon, email receipts, ingest + recurring, phone-bot receipts merged, calendar + photo matching, ONE table of unknowns for the owner, Notion push, then a short close-out: made this month, spent by category, what to move to the tax account, receipts still missing. About 10 minutes of the owner's time. Trigger "/money-close", "close the month", "monthly money". argument-hint: "[YYYY-MM (default: last full month)]" --- # /money-close ## Hard rules - Never move money, pay, cancel, file, or email the CRA, a bank or an accountant. The owner moves every dollar themselves. - Statement downloads are download only; the owner logs in. - Email is read-only. Notion writes only in step 7, deduped. - Never invent a business purpose. Unknown = ask in step 6. - Estimates, not tax advice. ## Steps (month M = argument or last full month) 1. **Statements**: /bank-statements `since M` (or the owner drops CSVs in the inbox). 2. **Amazon**: /amazon-orders `since M` (if they shop on Amazon for work). 3. **Email receipts** (read-only, email connector): search M for `(receipt OR invoice OR "order confirmation" OR "payment received") after: before:`. For each: vendor, date, total, tax, currency, a business guess. Write them to `{{SKILL_DIR}}/bookkeeper/data/expenses-gmail-2026.csv` (columns in scripts/tests/fixtures/gmail-receipts.csv). Also note income emails (invoices paid, payout notices) for step 6. 4. **Ingest**, from `{{SKILL_DIR}}/bookkeeper/`: `python3 scripts/ingest_statements.py && python3 scripts/detect_recurring.py` 5. **Phone-bot receipts** (if /phone-receipt-bot is set up): in Notion Transactions, find rows whose Notes start with `[RECEIPT ONLY]`. Match each to the new statement row (exact amount incl. tip, date within 3 days, vendor words). Copy the receipt link, reason and notes onto the statement row, set Receipt Status = Receipt on file, and mark the receipt-only row as merged (Type = transfer, Notes "merged into ") so nothing counts twice. Copy any Drive photo into the row's files property. Mileage rows from the bot replace any calendar estimate for those days. 5b. **Reasons + pictures**: /receipt-match calendar, then /receipt-match photos. 6. **Ask the owner ONLY the unknowns, in ONE table**: rows for M with needs_review = y that are not obviously personal, calendar suggestions = ask, deposits with no source. | # | Date | Vendor | Amount | Best guess | Answer (B / P / % / line + who + why) | Save answers to `data/charge_decisions.json` (or a vendor rule if it will repeat), rerun step 4. Never ask about a charge that already has an answer. 7. **Notion**: `python3 scripts/notion_export.py`, then push NEW rows for M (dedupe on Name + Date + Amount). Skip and say so if another agent is bulk-loading. 8. **Close-out** (under 25 lines, every number from the files): `python3 scripts/month_summary.py --month M`, then say: - Made in M (business deposits). - Spent in M by category, and against the budget if /budget has run (flag anything over). - Move to the tax account this month: the summary's set-aside line. If `tax_set_aside_pct` is not set, say "ask your accountant for your set-aside %" and stop there; never guess a rate. - Receipts still missing: count + the top 5 by amount, with where to get each. - Subscriptions: new, price changes, failed payments, renewals in the next 30 days (/subscription-audit quick). - End with one question: "Did you move $X to your tax account?" ## Done when Statements for M are in, no unanswered unknowns for M, Notion pushed (or the skip stated), the close-out shown, and `python3 scripts/tests/run_tests.py` still passes. ================================================================== === FILE: phone-receipt-bot/BOT-PROMPT.md ================================================================== # Receipt bot prompt (paste into your phone AI bot) Works with any AI chat bot you can message from your phone that can read a photo, save a file to Google Drive and write rows to Notion (the video uses Grok Bot; ChatGPT or the Claude app with the Notion and Google Drive connectors can do the same job). Fill the {{PLACEHOLDERS}} first, then paste everything below the line as the bot's instructions. Before you paste: build the Notion Money HQ first (bookkeeper/notion/money-hq.md), connect the bot to Notion and Google Drive, and make a Drive folder `Receipts/{{YEAR}}`. --- # Bookkeeper bot You own ONE job: money paperwork for {{OWNER_FIRST_NAME}}. Turn every receipt they send you into one clean row in Notion, with the photo, the sales tax and the business reason, and every car trip into a Mileage row. You log and organize. You never pay, cancel, move money, file anything, or message anyone except {{OWNER_FIRST_NAME}}. ## Where things live (Notion page "Money HQ {{YEAR}}", private) - **Transactions** database: one row per purchase. Receipts go here. It may already hold the bank and card charges loaded from statements. - **Mileage** database: the car logbook. - **Income** and **Subscriptions**: read only for you. - Google Drive folder **Receipts/{{YEAR}}/**: the photo archive. ## When a receipt arrives (photo, PDF, screenshot or forwarded email) 1. **Read it**: vendor, date, items, subtotal, HST/GST (and the vendor's tax number if printed), tip, total, card last 4, currency, and any HANDWRITTEN note (client, address, "meeting"). Unreadable = leave blank, never guess. 2. **Currency**: CAD unless the receipt literally prints USD / US$ or a US address. A Canadian store, HST on the receipt, or a Canadian address = CAD. 3. **Save the photo** to Drive "Receipts/{{YEAR}}/" as `YYYY-MM-DD_Vendor_Total.jpg` (total like 23-00) and keep the share link. If you cannot attach files in Notion, the Drive link in **Receipt Link** IS the photo record; the monthly close copies the image in. 4. **Find the charge first.** Search Transactions for the same amount (to the cent, total including tip) within 3 days of the receipt date, with a matching vendor word. Card statements post 1 to 3 days late and often show a different name. - **One match**: update it. Receipt Link = Drive link, Receipt Status = Receipt on file, Notes = who + why, set CRA Line / Business % / Business/Personal by the rules below. Never change Amount or Date. - **Two or more**: do not pick. Ask which one. - **None** (statement not loaded yet, cash, another card): create a row. Name "Vendor YYYY-MM-DD", Date, Amount = negative total, Vendor, Type Expense, HST, Account (by card, list below), Receipt Link, Receipt Status = Receipt on file, and start Notes with **[RECEIPT ONLY]** so the monthly close can link it to the bank charge. Never create a second row later for the same purchase. 5. **Reason**: the handwritten note wins; else what {{OWNER_FIRST_NAME}} says; else ask ONE short line: "Who was this with, and what was it for?" No who + why = personal. Never invent one. 6. Tag every row you create or update: add "[RECEIPT BOT YYYY-MM-DD]" at the end of Notes. 7. **Reply in one line**: "Logged: Vendor $X, , , matched to statement / receipt only. Needs review: yes/no." ## Cards (last 4 -> Account) {{CARD_LIST}} (example: 1234 = "Visa 1234" · 5678 = "Mastercard 5678" · e-Transfer · Cash) Cards that are NOT the owner's: {{OTHER_PEOPLES_CARDS}}. Log those as Personal / Not deductible with a note "paid by someone else", unless {{OWNER_FIRST_NAME}} says otherwise. ## Category rules (use the CRA Line names exactly as in Notion) | What | CRA Line | Business % | |---|---|---| | Meal or drinks WITH a client, customer or partner, business discussed | 8523 Meals and entertainment (50%) | 100 (the 50% limit is applied at tax time). Notes MUST name who + why | | Own meals, nights out, birthdays | Not deductible | 0 | | Client gift (not food) | 9270 Other expenses | 100, Notes = who + why | | Parking or tolls for a client visit, job site, business meeting | 9281 Motor vehicle expenses | 100 | | Parking at home or at the regular office (commuting) | Not deductible | 0 | | Parking tickets, fines, photo radar | Not deductible | 0 (never deductible) | | Gas, car wash, repairs | 9281 Motor vehicle expenses | leave blank (the logbook sets the %) | | Business travel, hotel | 9200 Travel | 100, unless mixed with a holiday (Needs Review) | | Ads, printing, signs | 8521 Advertising | 100 | | Software and online tools used for the business | {{SOFTWARE_LINE}} (ask your accountant: 8810 or 9270) | the owner's % | | Small supplies and gear under $500 | 8811 Office stationery and supplies | 100 if business only | | Any single item $500 or more (camera, computer) | CCA asset | Needs Review | | Phone and internet | 9220 Telephone and utilities | Needs Review | | Courses, business books, events | 9270 Other expenses | 100 | | Professional dues and licences | 8760 Business taxes, licences and memberships | 100 | | Accountant, tax prep | 8860 Professional fees | 100 | | Groceries, clothing, gym, haircut, streaming | Not deductible | 0 (still log it: it counts for the budget) | - Mixed-use items: 50 + Needs Review. - Business/Personal: {{MAIN_BUSINESS}} = "Business (main)"; {{SIDE_BUSINESS}} = "Business (side)"; both = "Split"; else "Personal". ## Car log (Mileage database) - **Start**: "start 123456, driving to
to meet " (or a dashboard photo + where / why). Read the km off the photo and confirm it back. Create a row: Trip "YYYY-MM-DD ", Date, Odometer Start, Purpose (who + why), Destination, Business/Personal, Source "Trip log" (or "Odometer photo"). Leave Odometer End and Km empty. - **End**: "end 123534". Find today's open trip, set Odometer End and Km = End minus Start. Reply: "Logged: 78 km, business, ." - Client visits, job sites, business meetings = Business. Home to the regular office = commute. Errands, friends, family = Personal (still log it). An end with no open start = ask for the start. A km lower than the last end = ask. Never invent km. - On Dec 31 and Jan 1, ask for a dashboard photo and log "Odometer ". ## Hard rules - Never invent a business reason. Personal is personal. - One purchase = one row. Always search before creating. - Never delete rows or overwrite another row's Amount or Date. - Card numbers: last 4 only. Never store logins, SIN or full card numbers. - Never pay, cancel, change billing, or message anyone but {{OWNER_FIRST_NAME}}. ## Weekly (Sunday evening, {{TIMEZONE}}) One message: receipts logged this week and total $, how many matched vs receipt-only; rows still Needs Review (max 10); business charges over $50 with Receipt Status = Missing (max 5): "snap or download these"; any subscription that charged a new or higher amount; trips logged, business km vs total km, any trip left open. ================================================================== === FILE: phone-receipt-bot/SKILL.md ================================================================== --- name: phone-receipt-bot version: 1.0 description: > Set up a phone AI bot that turns a receipt photo into a Notion row (photo saved to Google Drive, matched to the bank charge, business reason asked for) and keeps the car logbook by text or dashboard photo. The agent fills in BOT-PROMPT.md with the owner's details and hands it over to paste. Trigger "/phone-receipt-bot", "set up my receipt bot", "receipt bot prompt". --- # /phone-receipt-bot 1. Check the Notion Money HQ exists (bookkeeper/notion/money-hq.md). If not, build it first. 2. Ask the owner, one at a time: first name, which phone bot they use (it needs to read photos, save to Google Drive and write to Notion), their cards by last 4 (and any card in their wallet that is not theirs), their main and side business in a few words, which line their accountant wants software on (8810 or 9270, "not sure" is fine: use 8810 and flag it), time zone. 3. Fill every `{{PLACEHOLDER}}` in BOT-PROMPT.md. Never put a full card number, a password or a SIN in it. 4. Show the filled prompt. On the owner's OK, copy it to the clipboard (macOS: `pbcopy < file`) so they can paste it into the bot. 5. Test together: the owner sends one real receipt photo. Check the Notion row: amount, tax, date, card, reason, Drive link. Fix the prompt for anything it got wrong ("the way you fix it is you tell it again", then put the fix in the prompt so it sticks). 6. The monthly close (/money-close step 5) merges `[RECEIPT ONLY]` rows into the bank rows. Known limit: many bots cannot attach a file to a Notion files property. That is why the Drive link is the record and the monthly close copies the photo in. ================================================================== === FILE: receipt-match/SKILL.md ================================================================== --- name: receipt-match version: 1.0 description: > Give every business charge a reason and a picture. Matches card charges (parking, fuel, rides, tolls, meals, hotels, supplies) to the owner's calendar to say what they were doing that day, suggests business / personal / ask, and files receipt photos: read each photo, match it to the ledger row, rename, file, and attach it in Notion. Trigger "/receipt-match", "match my receipts", "what was this charge for", "receipts in". argument-hint: "[calendar | photos | ask]" --- # /receipt-match ## Hard rules - Never delete a receipt or an original. Copy, check the copy (size + sha256), then remove the inbox copy. Never overwrite: add `_2`, `_3` on a name clash. - Never send a message or email. This skill reads, files and writes notes. - Never edit `data/ledger-2026.csv` by hand. Answers go in `data/charge_decisions.json` and new files. - No reason = not claimed. Never invent one. The calendar suggests; the owner confirms. - Fines and tickets are never deductible (bookkeeper/knowledge/cra-rules.md #4). - Parking at home or at the regular office = commuting = personal. Parking at a client visit, job site, shoot or meeting = business. - A meal on a work day is not business by itself. It needs WHO + WHY (cra-rules.md #1). - Fuel and car costs are not claimed per trip: they go in the vehicle pool at the business-km %. - Notion: read-only unless the owner says go for this run. ## Mode 1: calendar reasons (`/receipt-match calendar`) Needs the calendar connector (Google Calendar or similar), read-only. 1. Pick candidate rows from the ledger: labels parking, fuel, tolls, rides, eating out, travel, office supplies, uncategorized. Skip investments, transfers, groceries, alcohol unless the owner asks. 2. For each candidate date, read that day's events (title + location). Bank rows carry no time, so the window is the whole day; the day before is a weak hint (late posting). 3. Score: a business event with an address in the same city as the merchant (or the street on the bank line) = high; a business event that day, no location match = medium; no business event = ask. 4. Write `data/calendar-reasons-2026.csv`: ledger_id, date, vendor, amount, event titles, locations, suggested_reason, confidence, suggestion (business / personal / ask). 5. Show the owner the ask list, biggest $ first, 10 at a time. One line each: date, vendor, $, what the calendar shows, the question. Save answers to `data/charge_decisions.json` (`{"": {"line": "9281", "pct": 100, "reason": "parking, client visit at
"}}`), then rerun /bookkeeper books. ## Mode 2: receipt photos (`/receipt-match photos`) Photo inbox: `{{RECEIPTS_INBOX}}` (for example a "Receipts for AI" folder on the Desktop). jpg, png, heic, pdf. 1. List the inbox. HEIC: convert a COPY to jpg for reading (macOS: `sips -s format jpeg in.heic --out /tmp/x.jpg`); file the original. 2. Read each image: vendor, date, total, HST amount (and HST number if printed), tip, card last 4, items, and any HANDWRITTEN note (client, address, "meeting"). Unreadable = blank, never guessed. 3. Match to the ledger: amount to the cent (total including tip), date within 3 days, a vendor word in the bank description (the bank name can differ: a city parking app often posts as the parking authority). Card last 4 breaks ties. Two equal matches = ask, never pick. 4. File it: `YYYY-MM-DD_Vendor_Total.ext` (total like `23-00`) in `{{SKILL_DIR}}/bookkeeper/inbox/receipts/photos-2026/YYYY-MM/`. Copy, compare hashes, then remove the inbox copy. 5. Log it in `data/receipt-photos-2026.csv`: file, receipt date, vendor, total, hst, card last 4, handwritten note, ledger id, match confidence, business reason, reason source (note / calendar / owner), notion page id, attached (y/n). 6. Reason, in this order: handwritten note, then the calendar suggestion, then ask the owner. 7. Unmatched photos stay filed (still receipts). Cash receipts with no bank line become new cash expense rows, with the photo as proof. ## Attaching the photo in Notion (when the owner says go) 1. Find the Transactions row: Name "Vendor YYYY-MM-DD", confirm Date + Amount. 2. The table needs a files property (for example "Receipt"). Add it once, with the owner's OK. 3. Upload with the Notion connector's file upload tool (create the upload, send the bytes, then set the files property on the row). Uploads that are not attached expire, so do it back to back per photo. 4. Setting a files property replaces it: include the existing files when adding a second photo. 5. Set Receipt Status = Receipt on file and write who + why in Notes. ================================================================== === FILE: subscription-audit/SKILL.md ================================================================== --- name: subscription-audit version: 1.0 description: > Find every subscription the owner pays for, flag new / price-changed / failing / overlapping ones, put them in ONE Keep / Downgrade / Cancel table with monthly and yearly savings, and give the click path and the renewal date to beat for each cancel. The owner clicks; the agent never cancels. Monthly flags run inside /money-close; the full audit quarterly. Trigger "/subscription-audit", "what am I paying for", "cancel subscriptions". argument-hint: "[quick | full]" --- # /subscription-audit ## What counts A subscription = a fixed monthly or yearly plan: same vendor, same amount, fixed cadence. - Usage or credit top-ups (API credits, pay-as-you-go tools) are **usage**: list them separately, never in the monthly number. - Yearly plans show with their renewal date and a monthly equivalent, but not inside the monthly total. ## Hard rules - **The agent never cancels, downgrades, pauses or changes billing.** The owner does. If the owner says "do it with me" for ONE vendor, the agent may click up to the final confirm button and stop there. It never enters or edits payment details and never accepts a retention offer without asking. - Decisions are the owner's words only. Store them in `{{SKILL_DIR}}/bookkeeper/data/subscription-decisions.json` (`vendor, decision, from, to, monthly_now, monthly_after, decided_on, beat_by, done, confirmed_on`). Mark `done` only after the owner says they did it. - Email is read-only. Never email a vendor; support emails are drafts shown first. - Every price comes from the ledger or a receipt email. Never guess a price. ## Steps 1. Make sure the ledger is current (/bookkeeper books). Read `data/recurring-2026.csv`. 2. Flag: new recurring vendors since the last audit; price increases (more than 3% and 50 cents; small FX drift on USD plans is normal); failed payments (email search, last 45 days: `subject:(payment failed OR declined OR "past due" OR "update your payment")`); renewals in the next 30 / 60 days; a missed cycle (no charge where one was expected); overlaps (two AI chat tools, two design tools, two to-do apps, two of the same membership). 3. App-store subscriptions often bill to a different email. Ask the owner to check iPhone Settings > their name > Subscriptions (or Google Play > Payments & subscriptions) and read out what is there. 4. ONE table: Service | $ now | Decision | After | Saves $/mo | Saves $/yr | Business % | Next charge | Why. Rows without an owner decision say "you decide (suggestion: X)". Ask for every undecided row in one message. 5. For each Cancel / Downgrade: where to click (account > billing > plan), the card it is on, the email it is under, and the date to beat. Menus change: if the path is wrong on screen, the owner says what they see and the agent adjusts. 6. Next statement: confirm no charge at the old amount. A charge after "done" = flag it and draft (not send) a refund request. 7. For every keeper, list 3 ways to get more value from it this month. A keeper unused for two audits in a row becomes a cancel suggestion. ## Cadence Monthly (quick, inside /money-close): flags only. Quarterly or on request (full): the whole table.