This commit is contained in:
gsinghpal
2026-04-03 15:45:18 -04:00
parent 4cd7357aa0
commit c66bdf5089
71 changed files with 6721 additions and 118 deletions

129
batch3_models.sql Normal file
View File

@@ -0,0 +1,129 @@
-- ============================================================
-- BATCH 3: Reconciliation Models for Westin Healthcare
-- Database: westin-v19 | Date: 2026-04-03
-- ============================================================
-- Tax IDs: 20 = HST PURCHASE (13%), 32 = NO TAX PURCHASE (0%)
-- ============================================================
BEGIN;
-- Helper function to create writeoff models in one shot
CREATE OR REPLACE FUNCTION _tmp_create_writeoff(
p_name text, p_seq int, p_match text,
p_account_id int, p_tax_id int, p_label text
) RETURNS void AS $$
DECLARE
v_model_id int;
v_line_id int;
BEGIN
INSERT INTO account_reconcile_model (name, sequence, company_id, trigger, match_label, match_label_param, active, can_be_proposed, create_uid, write_uid, create_date, write_date)
VALUES (jsonb_build_object('en_US', p_name), p_seq, 1, 'auto_reconcile', 'contains', p_match, true, false, 2, 2, NOW(), NOW())
RETURNING id INTO v_model_id;
INSERT INTO account_reconcile_model_line (model_id, company_id, sequence, account_id, amount_type, amount, amount_string, label, create_uid, write_uid, create_date, write_date)
VALUES (v_model_id, 1, 10, p_account_id, 'percentage', 100, '100', jsonb_build_object('en_US', p_label), 2, 2, NOW(), NOW())
RETURNING id INTO v_line_id;
INSERT INTO account_reconcile_model_line_account_tax_rel (account_reconcile_model_line_id, account_tax_id)
VALUES (v_line_id, p_tax_id);
END;
$$ LANGUAGE plpgsql;
-- Helper function for partner-mapping models
CREATE OR REPLACE FUNCTION _tmp_create_partner_map(
p_name text, p_seq int, p_match text, p_partner_id int
) RETURNS void AS $$
BEGIN
INSERT INTO account_reconcile_model (name, sequence, company_id, trigger, match_label, match_label_param, mapped_partner_id, active, can_be_proposed, create_uid, write_uid, create_date, write_date)
VALUES (jsonb_build_object('en_US', p_name), p_seq, 1, 'auto_reconcile', 'contains', p_match, p_partner_id, true, false, 2, 2, NOW(), NOW());
END;
$$ LANGUAGE plpgsql;
-- ============================================================
-- PART 1: WRITEOFF MODELS (36 models)
-- ============================================================
-- Acct 495=Computer/IT, 496=Advertising, 497=Car/Van, 499=Bank Charges
-- Acct 501=Dues/Subs, 506=Meals, 507=Office, 518=Shipping
-- Acct 523=Telephone, 526=Utilities, 552=Gas, 557=Security
-- Rideshare / Transportation
SELECT _tmp_create_writeoff('Uber Rides', 200, 'uber', 497, 20, 'Uber Rideshare');
SELECT _tmp_create_writeoff('Lyft Rides', 201, 'Lyft', 497, 20, 'Lyft Rideshare');
SELECT _tmp_create_writeoff('407 ETR Highway Tolls', 202, '407 ETR', 497, 20, '407 ETR Highway Tolls');
SELECT _tmp_create_writeoff('Klassic Car Wash', 203, 'KLASSIC CAR WASH', 497, 20, 'Klassic Car Wash');
-- Web Hosting / IT (NO TAX - foreign companies)
SELECT _tmp_create_writeoff('Cloud Clusters Hosting', 210, 'CLOUD CLUSTERS', 495, 32, 'Cloud Clusters Web Hosting');
SELECT _tmp_create_writeoff('Siteground Web Hosting', 211, 'SITEGROUND', 495, 32, 'Siteground Web Hosting');
SELECT _tmp_create_writeoff('WP Media / Imagify Plugin',212, 'WP MEDIA', 495, 32, 'WP Media Imagify Image Optimization');
SELECT _tmp_create_writeoff('Railway.app Cloud Hosting',213, 'RAILWAY', 495, 32, 'Railway.app Cloud Hosting');
SELECT _tmp_create_writeoff('Fiverr Freelance Services',214, 'FIVERR', 495, 32, 'Fiverr Freelance Services');
-- IT Services (HST - Canadian)
SELECT _tmp_create_writeoff('Microsoft 365 Subscription',215, 'Microsoft', 495, 20, 'Microsoft 365 Subscription');
SELECT _tmp_create_writeoff('Webware Website Platform', 216, 'Webware', 495, 20, 'Webware Website Platform');
SELECT _tmp_create_writeoff('Google Workspace', 217, 'WORKSPACE', 495, 20, 'Google Workspace Subscription');
-- Advertising (NO TAX - foreign companies)
SELECT _tmp_create_writeoff('Yelp Advertising', 220, 'YELP', 496, 32, 'Yelp Online Advertising');
SELECT _tmp_create_writeoff('ClickCease Ad Protection', 221, 'CLICKCEASE', 496, 32, 'ClickCease Ad Fraud Protection');
SELECT _tmp_create_writeoff('Kliken / SiteWit Ads', 222, 'KLIKEN', 496, 32, 'Kliken SiteWit Online Advertising');
SELECT _tmp_create_writeoff('Constant Contact Email', 223, 'CONSTANT CONTACT', 496, 32, 'Constant Contact Email Marketing');
-- Advertising (HST - Canadian)
SELECT _tmp_create_writeoff('Yellow Pages Advertising', 224, 'YELLOW PAGES', 496, 20, 'Yellow Pages Directory Advertising');
SELECT _tmp_create_writeoff('Microsoft Advertising', 225, 'MICROSOFT*ADVERTISING', 496, 20, 'Microsoft Bing Advertising');
-- Telephone / Communications
SELECT _tmp_create_writeoff('Bell Canada Telecom', 230, 'BELL CANADA', 523, 20, 'Bell Canada Telephone & Internet');
SELECT _tmp_create_writeoff('eFax Online Fax Service', 231, 'EFAX', 523, 32, 'eFax Online Fax Service');
SELECT _tmp_create_writeoff('Faxdeck Online Fax', 232, 'FAXDECK', 523, 32, 'Faxdeck Online Fax Service');
SELECT _tmp_create_writeoff('RingCentral Phone', 233, 'RINGCENTRAL', 523, 32, 'RingCentral Cloud Phone Service');
-- Subscriptions / Dues
SELECT _tmp_create_writeoff('Scribd Medical Reference', 240, 'SCRIBD', 501, 32, 'Scribd Medical Reference Subscription');
SELECT _tmp_create_writeoff('Amazon Channels', 241, 'Amazon Channel', 501, 20, 'Amazon Channels Subscription');
SELECT _tmp_create_writeoff('Dominion Insurance', 242, 'DOMINION PREM', 501, 32, 'Dominion Insurance Premium');
-- Meals & Entertainment
SELECT _tmp_create_writeoff('Tim Hortons - Meals', 250, 'Tim Horton', 506, 20, 'Tim Hortons Meals');
SELECT _tmp_create_writeoff('Malton Best Restaurant', 251, 'malton best', 506, 20, 'Malton Best Restaurant Meals');
-- Office / Supplies
SELECT _tmp_create_writeoff('Princess Auto - Supplies', 260, 'Princess Auto', 507, 20, 'Princess Auto Supplies');
SELECT _tmp_create_writeoff('Canadian Tire - Supplies', 261, 'CANADIAN TIRE', 507, 20, 'Canadian Tire Office/Shop Supplies');
SELECT _tmp_create_writeoff('Staples Office Supplies', 262, 'STAPLES', 507, 20, 'Staples Office Supplies');
SELECT _tmp_create_writeoff('MGS Business Registration',263, 'MGS-BUSINESS', 507, 20, 'MGS Ontario Business Registration');
-- Shipping
SELECT _tmp_create_writeoff('DHL Express Shipping', 270, 'DHL', 518, 20, 'DHL Express Shipping');
SELECT _tmp_create_writeoff('FedEx Shipping', 271, 'Fedex', 518, 20, 'FedEx Shipping & Delivery');
-- Bank Fees
SELECT _tmp_create_writeoff('Scotia Service Charge', 280, 'Service Charge', 499, 32, 'Scotia Bank Service Charge');
-- Security / Building
SELECT _tmp_create_writeoff('ADT Canada Security', 290, 'ADT CANADA', 557, 20, 'ADT Canada Security Monitoring');
SELECT _tmp_create_writeoff('Seccan Security', 291, 'seccan', 557, 20, 'Seccan Security Services');
-- Utilities
SELECT _tmp_create_writeoff('Alectra Utilities - Hydro',292, 'ALECTRA', 526, 20, 'Alectra Utilities Hydro Payment');
-- ============================================================
-- PART 2: PARTNER-MAPPING MODELS (9 models)
-- ============================================================
SELECT _tmp_create_partner_map('VGM Canada', 300, 'VGM Canada', 5024);
SELECT _tmp_create_partner_map('Medical Mart', 301, 'Medical Mart', 4991);
SELECT _tmp_create_partner_map('AMG Medical', 302, 'AMG medical', 4934);
SELECT _tmp_create_partner_map('HoMedics Group Canada', 303, 'HOMEDICS', 4975);
SELECT _tmp_create_partner_map('Stevens Company Limited', 304, 'Stevens Company', 5017);
SELECT _tmp_create_partner_map('Ki Mobility Canada', 305, 'Ki Mobility', 4981);
SELECT _tmp_create_partner_map('R82 Inc', 306, 'R82', 5009);
SELECT _tmp_create_partner_map('Harmony Group / Products',307, 'HARMONY', 6216);
SELECT _tmp_create_partner_map('Continent Globe Freight', 308, 'CONTINENT GLOBE', NULL);
-- Cleanup temp functions
DROP FUNCTION _tmp_create_writeoff(text, int, text, int, int, text);
DROP FUNCTION _tmp_create_partner_map(text, int, text, int);
COMMIT;

53
batch4_models.sql Normal file
View File

@@ -0,0 +1,53 @@
BEGIN;
CREATE OR REPLACE FUNCTION _tmp_wo(p_name text, p_seq int, p_match text, p_acct int, p_tax int, p_label text) RETURNS void AS $$
DECLARE v_mid int; v_lid int;
BEGIN
INSERT INTO account_reconcile_model (name, sequence, company_id, trigger, match_label, match_label_param, active, can_be_proposed, create_uid, write_uid, create_date, write_date)
VALUES (jsonb_build_object('en_US', p_name), p_seq, 1, 'auto_reconcile', 'contains', p_match, true, true, 2, 2, NOW(), NOW()) RETURNING id INTO v_mid;
INSERT INTO account_reconcile_model_line (model_id, company_id, sequence, account_id, amount_type, amount, amount_string, label, create_uid, write_uid, create_date, write_date)
VALUES (v_mid, 1, 10, p_acct, 'percentage', 100, '100', jsonb_build_object('en_US', p_label), 2, 2, NOW(), NOW()) RETURNING id INTO v_lid;
INSERT INTO account_reconcile_model_line_account_tax_rel (account_reconcile_model_line_id, account_tax_id) VALUES (v_lid, p_tax);
END; $$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION _tmp_pm(p_name text, p_seq int, p_match text, p_pid int) RETURNS void AS $$
BEGIN
INSERT INTO account_reconcile_model (name, sequence, company_id, trigger, match_label, match_label_param, mapped_partner_id, active, can_be_proposed, create_uid, write_uid, create_date, write_date)
VALUES (jsonb_build_object('en_US', p_name), p_seq, 1, 'auto_reconcile', 'contains', p_match, p_pid, true, true, 2, 2, NOW(), NOW());
END; $$ LANGUAGE plpgsql;
SELECT _tmp_wo('UPS Shipping', 400, 'UPS', 518, 20, 'UPS Shipping & Delivery');
SELECT _tmp_wo('Shopify Subscription', 401, 'SHOPIFY', 495, 20, 'Shopify E-Commerce Platform');
SELECT _tmp_wo('Canva Design', 402, 'CANVA', 495, 32, 'Canva Design Subscription');
SELECT _tmp_wo('Massive.com / Polygon.io', 403, 'MASSIVE.COM', 495, 32, 'Polygon.io Stock Data API');
SELECT _tmp_wo('Air Canada Travel', 404, 'AIR CAN', 525, 20, 'Air Canada Travel');
SELECT _tmp_wo('Enterprise Rent-A-Car', 405, 'ENTERPRISE RENT', 497, 20, 'Enterprise Car Rental');
SELECT _tmp_wo('Walmart Purchases', 406, 'WALMART', 507, 20, 'Walmart Office/Shop Supplies');
SELECT _tmp_wo('FlightHub Travel', 407, 'FLIGHTHUB', 525, 20, 'FlightHub Travel Booking');
SELECT _tmp_wo('G2A Software', 408, 'G2A.COM', 495, 32, 'G2A Software Licenses');
SELECT _tmp_wo('Ubiquiti Network Equipment', 409, 'UBIQUITI', 495, 20, 'Ubiquiti Network Hardware');
SELECT _tmp_wo('Facebook Ads (FACEBK)', 410, 'FACEBK', 496, 20, 'Facebook/Meta Advertising');
SELECT _tmp_wo('Eventbrite Events', 411, 'eventbrite', 496, 20, 'Eventbrite Event Registration');
SELECT _tmp_wo('WP Mail SMTP Plugin', 412, 'WPMAILSMTP', 495, 32, 'WP Mail SMTP Plugin');
SELECT _tmp_wo('Synthesia AI Video', 413, 'SYNTHESIA', 495, 32, 'Synthesia AI Video Platform');
SELECT _tmp_wo('E2PDF WordPress Plugin', 414, 'E2PDF', 495, 32, 'E2PDF WordPress Plugin');
SELECT _tmp_wo('Plugins For WP', 415, 'PLUGINSFORWP', 495, 32, 'WordPress Plugins');
SELECT _tmp_wo('Google Cloud Platform', 416, 'GOOGLE*CLOUD', 495, 20, 'Google Cloud Platform');
SELECT _tmp_wo('Best Buy Retail', 417, 'BEST BUY', 507, 20, 'Best Buy Electronics/Supplies');
SELECT _tmp_wo('Scotia Visa Annual Fee', 418, 'annual fee', 499, 32, 'Scotia Visa Annual Fee');
SELECT _tmp_wo('Corp Canada Registration', 419, 'CORP CANADA', 507, 20, 'Corporation Canada Registration');
SELECT _tmp_wo('NUANS Name Search', 420, 'NUANS', 507, 20, 'NUANS Business Name Search');
SELECT _tmp_wo('Wisprflow AI', 421, 'WISPRFLOW', 495, 32, 'Wisprflow AI Platform');
SELECT _tmp_wo('LawDepot Legal Docs', 422, 'lawdepot', 507, 20, 'LawDepot Legal Documents');
SELECT _tmp_wo('eBay Purchases', 423, 'eBay', 507, 20, 'eBay Online Purchases');
SELECT _tmp_wo('Ooma VoIP Phone', 424, 'OOMA', 523, 20, 'Ooma VoIP Phone Service');
SELECT _tmp_wo('Paddle / Synergy App', 425, 'PADDLE.NET', 495, 32, 'Paddle Software Subscription');
SELECT _tmp_pm('Power Plus Mobility', 500, 'POWER PLUS', 35);
SELECT _tmp_pm('Best Buy Medical Supplies', 501, 'Best Buy Medical', 4939);
SELECT _tmp_pm('Cheelcare Canada', 502, 'cheelcare', 11955);
DROP FUNCTION _tmp_wo(text, int, text, int, int, text);
DROP FUNCTION _tmp_pm(text, int, text, int);
COMMIT;

188
batch5_models.sql Normal file
View File

@@ -0,0 +1,188 @@
BEGIN;
CREATE OR REPLACE FUNCTION _tmp_wo(p_name text, p_seq int, p_match text, p_acct int, p_tax int, p_label text) RETURNS void AS $$
DECLARE v_mid int; v_lid int;
BEGIN
INSERT INTO account_reconcile_model (name, sequence, company_id, trigger, match_label, match_label_param, active, can_be_proposed, create_uid, write_uid, create_date, write_date)
VALUES (jsonb_build_object('en_US', p_name), p_seq, 1, 'auto_reconcile', 'contains', p_match, true, true, 2, 2, NOW(), NOW()) RETURNING id INTO v_mid;
INSERT INTO account_reconcile_model_line (model_id, company_id, sequence, account_id, amount_type, amount, amount_string, label, create_uid, write_uid, create_date, write_date)
VALUES (v_mid, 1, 10, p_acct, 'percentage', 100, '100', jsonb_build_object('en_US', p_label), 2, 2, NOW(), NOW()) RETURNING id INTO v_lid;
INSERT INTO account_reconcile_model_line_account_tax_rel (account_reconcile_model_line_id, account_tax_id) VALUES (v_lid, p_tax);
END; $$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION _tmp_pm(p_name text, p_seq int, p_match text, p_pid int) RETURNS void AS $$
BEGIN
INSERT INTO account_reconcile_model (name, sequence, company_id, trigger, match_label, match_label_param, mapped_partner_id, active, can_be_proposed, create_uid, write_uid, create_date, write_date)
VALUES (jsonb_build_object('en_US', p_name), p_seq, 1, 'auto_reconcile', 'contains', p_match, p_pid, true, true, 2, 2, NOW(), NOW());
END; $$ LANGUAGE plpgsql;
-- MJR Capital = collections payments (Office Expense, HST)
SELECT _tmp_wo('MJR Capital Services - Collections', 600, 'mjr capital', 507, 20, 'MJR Capital Collections Payment');
-- Landry & Jacobs = legal/collections (Office Expense, NO TAX - US company in AZ)
SELECT _tmp_wo('Landry & Jacobs - Collections', 601, 'landry', 507, 32, 'Landry & Jacobs Collections');
-- Micro Center = US electronics retailer (Computer/IT, NO TAX - US)
SELECT _tmp_wo('Micro Center Electronics', 602, 'MICRO CENTER', 495, 32, 'Micro Center Electronics Purchase');
-- Maravi Canada = medical supplies vendor (partner mapping)
-- Need partner ID first - create as writeoff to Office for now
SELECT _tmp_wo('Maravi Canada Medical', 603, 'MARAVI', 507, 20, 'Maravi Canada Medical Supplies');
-- Google Turbo AI Note (SaaS, HST Canadian)
SELECT _tmp_wo('Google Turbo AI Note', 604, 'TURBO AI NOTE', 495, 20, 'Google Turbo AI Note');
-- FUSION NEXASYSTEMS = own company test charges (Office Expense, HST)
SELECT _tmp_wo('Fusion NexaSystems Test', 605, 'NEXASYSTEMS', 507, 20, 'NexaSystems Test Charge');
-- VPS IT NEXASYSTEMS = own company VPS hosting (Computer/IT, HST)
-- already covered by NEXASYSTEMS match above
-- Sunnybrook / St Josephs = hospital parking (Meals & Ent or Office, HST)
SELECT _tmp_wo('Hospital Parking - Sunnybrook', 606, 'sunnybrook', 506, 20, 'Sunnybrook Hospital Parking/Meals');
SELECT _tmp_wo('Hospital Parking - St Josephs', 607, 'st josephs', 506, 20, 'St Josephs Hospital Parking');
-- Canada Post (CPC SCP) - already exists but let's check
-- Model 49 matches "CPC SCP" - should work
-- Bolts Plus Inc = hardware supplies (Office Expense, HST)
SELECT _tmp_wo('Bolts Plus Hardware', 608, 'BOLTS PLUS', 507, 20, 'Bolts Plus Hardware Supplies');
-- Durafast Label Company = labels/printing (Office Expense, HST)
SELECT _tmp_wo('Durafast Label Company', 609, 'durafast', 507, 20, 'Durafast Label Printing');
-- Better Business Bureau = membership (Dues & Subs, HST)
SELECT _tmp_wo('Better Business Bureau', 610, 'better business bureau', 501, 20, 'Better Business Bureau Membership');
-- AmySystems = software (Computer/IT, HST Canadian - QC)
SELECT _tmp_wo('AmySystems Software', 611, 'AMYSYSTEMS', 495, 20, 'AmySystems Software');
-- Thermor Limited = medical equipment vendor
SELECT _tmp_pm('Thermor Limited', 612, 'thermor', NULL);
-- Aqua Creek Products = pool/medical equipment (US vendor)
-- Large amounts ($21K) - this is a PO vendor
SELECT _tmp_pm('Aqua Creek Products', 613, 'aqua creek', NULL);
-- Rogers (line 20184 with ******4596) - existing model should match
-- 407 ETR (line 20131) - existing model matches "407 ETR" but this says "407ETR (WEB)"
SELECT _tmp_wo('407 ETR Web Payment', 614, '407ETR', 497, 20, '407 ETR Web Highway Tolls');
-- 7 Spice Bistro / The Kebob / Momo2Go = restaurants (Meals, HST)
SELECT _tmp_wo('7 Spice Bistro', 615, '7 SPICE', 506, 20, '7 Spice Bistro Meals');
SELECT _tmp_wo('The Kebob Restaurant', 616, 'KEBOB', 506, 20, 'The Kebob Restaurant Meals');
SELECT _tmp_wo('Momo2Go Restaurant', 617, 'MOMO2GO', 506, 20, 'Momo2Go Restaurant Meals');
-- Jay Cee Sales & Rivet = hardware/industrial (Office, NO TAX - US in MI)
SELECT _tmp_wo('Jay Cee Sales & Rivet', 618, 'jay cee sales', 507, 32, 'Jay Cee Sales Industrial Supplies');
-- Kickstarter / Eufymake = crowdfunding purchase (Computer/IT, NO TAX - US)
SELECT _tmp_wo('Kickstarter Purchase', 619, 'kickstarter', 495, 32, 'Kickstarter Crowdfunding Purchase');
-- Bambu Lab = 3D printer (Computer/IT, NO TAX - Hong Kong)
SELECT _tmp_wo('Bambu Lab 3D Printer', 620, 'bambulab', 495, 32, 'Bambu Lab 3D Printer');
-- Dhillon Video Karo = video production (Advertising, HST)
SELECT _tmp_wo('Dhillon Video Karo', 621, 'dhillon video', 496, 20, 'Dhillon Video Production');
-- Cansew = sewing/upholstery supplies (Office Expense, HST)
SELECT _tmp_wo('Cansew Supplies', 622, 'cansew', 507, 20, 'Cansew Sewing/Upholstery Supplies');
-- NuthutVancouver = food/snacks (Meals, HST)
SELECT _tmp_wo('SP Nuthut', 623, 'NUTHUT', 506, 20, 'Nuthut Food/Snacks');
-- Flywire = payment processing for education (Office, HST)
SELECT _tmp_wo('Flywire Payment', 624, 'flywire', 507, 20, 'Flywire Education Payment');
-- IELTS Humber = education/testing (Office, HST)
SELECT _tmp_wo('IELTS Humber College', 625, 'IELTS', 507, 20, 'IELTS Testing Fee');
-- York University = education (Office, HST)
SELECT _tmp_wo('York University', 626, 'york u', 507, 20, 'York University Application Fee');
-- ESW US Direct = e-commerce (Office, NO TAX - US)
SELECT _tmp_wo('ESW US Direct E-Commerce', 627, 'ESW U.S.', 507, 32, 'ESW US Direct E-Commerce');
-- Corp Canada = already created (419), skip
-- NextDigitalKeys = software keys (Computer/IT, NO TAX - UK)
SELECT _tmp_wo('NextDigitalKeys Software', 628, 'nextdigitalkeys', 495, 32, 'NextDigitalKeys Software License');
-- StenoKeyboards = keyboard hardware (Computer/IT, NO TAX - foreign)
SELECT _tmp_wo('StenoKeyboards', 629, 'stenokeyboards', 495, 32, 'StenoKeyboards Hardware');
-- Global Technologies of Barrie = IT services vendor
SELECT _tmp_wo('Global Technologies Barrie', 630, 'global technologies', 495, 20, 'Global Technologies IT Services');
-- Milutin Vuicin = contractor/consultant (Computer/IT, NO TAX - US TX)
SELECT _tmp_wo('Milutin Vuicin Consulting', 631, 'milutin vuicin', 495, 32, 'Milutin Vuicin Consulting');
-- Maple Leaf Wheelchair = PO vendor
SELECT _tmp_pm('Maple Leaf Wheelchair', 632, 'maple leaf wheelchair', NULL);
-- Distributions GNX = distribution vendor (QC)
SELECT _tmp_wo('Distributions GNX', 633, 'distributions gnx', 507, 20, 'Distributions GNX');
-- ParkWhiz / ParkLink = parking (Car/Van, HST)
SELECT _tmp_wo('ParkWhiz / ParkLink Parking', 634, 'park', 497, 20, 'Parking Fee');
-- Actually 'park' is too broad, skip that. Use specific ones:
-- delete that last one, too generic
DELETE FROM account_reconcile_model WHERE name::text LIKE '%ParkWhiz%';
-- Re-do with specific matches
SELECT _tmp_wo('ParkWhiz Parking', 635, 'ParkWhiz', 497, 20, 'ParkWhiz Parking Fee');
SELECT _tmp_wo('Precise ParkLink', 636, 'parklink', 497, 20, 'Precise ParkLink Parking');
-- Span Medical Products = PO vendor
SELECT _tmp_pm('Span Medical Products', 637, 'SPAN MEDICAL', NULL);
-- NSC Medical = PO vendor
SELECT _tmp_pm('NSC Medical', 638, 'nsc medical', NULL);
-- WOW Mobile Boutique = phone accessories (Office, HST)
SELECT _tmp_wo('WOW Mobile Boutique', 639, 'MOBILE BOUTIQ', 507, 20, 'WOW Mobile Boutique');
-- Triumph Mobility = PO vendor
SELECT _tmp_pm('Triumph Mobility', 640, 'triumph mobility', NULL);
-- Home Healthcare Store = PO vendor
SELECT _tmp_pm('Home Healthcare Store', 641, 'home healthcare store', NULL);
-- Ubiquiti already created (409)
-- Anthropic already matched by model 138 (ANTHROPIC)
-- Royalmount Town = travel/accommodation (Travel, HST QC)
SELECT _tmp_wo('Royalmount Town Hotel', 642, 'royalmount', 525, 20, 'Royalmount Town Accommodation');
-- Westin Healthcare own charges = test transactions
SELECT _tmp_wo('Westin Healthcare Test', 643, 'WESTIN HEALTHCARE', 507, 20, 'Westin Healthcare Test Charge');
-- XTool Canada = laser cutter/tools (Computer/IT, HST - Canadian store)
SELECT _tmp_wo('XTool Canada', 644, 'xtool', 495, 20, 'XTool Canada Equipment');
-- Providence Healthcare = hospital parking (Meals, HST)
SELECT _tmp_wo('Providence Healthcare', 645, 'providence healthcare', 506, 20, 'Providence Healthcare Parking');
-- Glentel Wirelesswave = phone accessory (Office, HST)
SELECT _tmp_wo('Glentel Wirelesswave', 646, 'wirelesswave', 507, 20, 'Glentel Wirelesswave Phone');
-- 3DMouse = computer peripheral (Computer/IT, HST)
SELECT _tmp_wo('3DMouse Input Device', 647, '3dmouse', 495, 20, '3DMouse Input Device');
-- LawDepot already created (422)
-- Best Buy Medical already created as partner_map (501)
-- Catherwood & Vittoria = restaurant (Meals, HST)
SELECT _tmp_wo('Catherwood & Vittoria', 648, 'catherwood', 506, 20, 'Catherwood & Vittoria Restaurant');
-- SB M Wing = hospital cafeteria (Meals, HST)
SELECT _tmp_wo('Sunnybrook M Wing Cafe', 649, 'sb m wing', 506, 20, 'Sunnybrook M Wing Cafeteria');
-- Canada/Ottawa lines = government fees/parking
SELECT _tmp_wo('Canada Ottawa Govt Fee', 650, 'canada-Ottawa', 507, 20, 'Ottawa Government Fee');
SELECT _tmp_wo('Canada Ottawa Fee 2', 651, 'canada ottawa on', 507, 20, 'Ottawa Government Fee');
DROP FUNCTION _tmp_wo(text, int, text, int, int, text);
DROP FUNCTION _tmp_pm(text, int, text, int);
COMMIT;

22
batch6_transfers.sql Normal file
View File

@@ -0,0 +1,22 @@
BEGIN;
CREATE OR REPLACE FUNCTION _tmp_wo_transfer(p_name text, p_seq int, p_match text, p_acct int, p_label text) RETURNS void AS $$
DECLARE v_mid int; v_lid int;
BEGIN
INSERT INTO account_reconcile_model (name, sequence, company_id, trigger, match_label, match_label_param, active, can_be_proposed, create_uid, write_uid, create_date, write_date)
VALUES (jsonb_build_object('en_US', p_name), p_seq, 1, 'auto_reconcile', 'contains', p_match, true, true, 2, 2, NOW(), NOW()) RETURNING id INTO v_mid;
INSERT INTO account_reconcile_model_line (model_id, company_id, sequence, account_id, amount_type, amount, amount_string, label, partner_id, create_uid, write_uid, create_date, write_date)
VALUES (v_mid, 1, 10, p_acct, 'percentage', 100, '100', jsonb_build_object('en_US', p_label), 1, 2, 2, NOW(), NOW()) RETURNING id INTO v_lid;
-- No tax on internal transfers
INSERT INTO account_reconcile_model_line_account_tax_rel (account_reconcile_model_line_id, account_tax_id) VALUES (v_lid, 32);
END; $$ LANGUAGE plpgsql;
-- These models post PAYMENT FROM lines directly to Outstanding Receipts (493)
-- This handles cases where the source side was already reconciled
SELECT _tmp_wo_transfer('Scotia Visa - Payment From Current (7814)', 50, 'PAYMENT FROM', 493, 'CC Payment from Scotia Current');
SELECT _tmp_wo_transfer('Scotia Visa - Transfer From Current', 51, 'from - *****', 493, 'CC Payment from Scotia Current');
SELECT _tmp_wo_transfer('Scotia Visa - Payment From (X0)', 52, 'payment from -', 493, 'CC Payment from Scotia Current');
DROP FUNCTION _tmp_wo_transfer(text, int, text, int, text);
COMMIT;

124
batch7_rbc.sql Normal file
View File

@@ -0,0 +1,124 @@
BEGIN;
CREATE OR REPLACE FUNCTION _tmp_wo(p_name text, p_seq int, p_match text, p_acct int, p_tax int, p_label text) RETURNS void AS $$
DECLARE v_mid int; v_lid int;
BEGIN
INSERT INTO account_reconcile_model (name, sequence, company_id, trigger, match_label, match_label_param, active, can_be_proposed, create_uid, write_uid, create_date, write_date)
VALUES (jsonb_build_object('en_US', p_name), p_seq, 1, 'auto_reconcile', 'contains', p_match, true, true, 2, 2, NOW(), NOW()) RETURNING id INTO v_mid;
INSERT INTO account_reconcile_model_line (model_id, company_id, sequence, account_id, amount_type, amount, amount_string, label, create_uid, write_uid, create_date, write_date)
VALUES (v_mid, 1, 10, p_acct, 'percentage', 100, '100', jsonb_build_object('en_US', p_label), 2, 2, NOW(), NOW()) RETURNING id INTO v_lid;
INSERT INTO account_reconcile_model_line_account_tax_rel (account_reconcile_model_line_id, account_tax_id) VALUES (v_lid, p_tax);
END; $$ LANGUAGE plpgsql;
-- ============================================================
-- GOVERNMENT CUSTOMER PAYMENTS → Outstanding Receipts (493)
-- These are payments FROM government agencies TO Westin for equipment/services
-- No tax on government transfer payments
-- ============================================================
-- ODSP = Ontario Disability Support Program (already partially matched by other models)
-- Check: model already exists? No - "Misc Payment ODSP" has no model
SELECT _tmp_wo('ODSP Government Payment', 700, 'ODSP', 493, 32, 'ODSP Customer Payment');
-- MODC = March of Dimes Canada (Expense Payment MODC = incoming govt payment)
SELECT _tmp_wo('MODC - March of Dimes Payment', 701, 'MODC', 493, 32, 'March of Dimes Customer Payment');
-- Revera Long Term Care payments
SELECT _tmp_wo('Revera LTC Payment', 702, 'Revera', 493, 32, 'Revera Long-Term Care Payment');
-- Medavie Blue Cross insurance payments
SELECT _tmp_wo('Medavie Blue Cross Payment', 703, 'MEDAVIE', 493, 32, 'Medavie Blue Cross Insurance Payment');
-- OMOD (Ontario March of Dimes variant)
SELECT _tmp_wo('OMOD Payment', 704, 'OMOD', 493, 32, 'Ontario March of Dimes Payment');
-- Peel Region payroll deposits (home care worker funding)
SELECT _tmp_wo('Peel Region North Deposit', 705, 'PEEL NORTH', 493, 32, 'Region of Peel North Payment');
SELECT _tmp_wo('Peel Region South Deposit', 706, 'PEEL SOUTH', 493, 32, 'Region of Peel South Payment');
SELECT _tmp_wo('Peel Region CMSM Deposit', 707, 'PEEL CMSM', 493, 32, 'Region of Peel CMSM Payment');
-- WSIB payments
SELECT _tmp_wo('WSIB Payment', 708, 'WSIB', 493, 32, 'WSIB Workers Compensation Payment');
-- GST Refund from CRA
SELECT _tmp_wo('CRA GST Refund', 709, 'GSTCANADA', 493, 32, 'CRA GST/HST Refund');
-- Affinity Health bill payments (incoming)
SELECT _tmp_wo('Affinity Health Payment', 710, 'Affinity Health', 493, 32, 'Affinity Health Customer Payment');
-- Amica Senior Living AP payments
SELECT _tmp_wo('Amica Senior Living Payment', 711, 'AMICA', 493, 32, 'Amica Senior Living Payment');
-- PCHS = Peel Community Health Services
SELECT _tmp_wo('PCHS Payment', 712, 'PCHS', 493, 32, 'PCHS Community Health Payment');
-- Run Care Canada
SELECT _tmp_wo('Run Care Canada Payment', 713, 'RUN CARE', 493, 32, 'Run Care Canada Payment');
-- Teskie International
SELECT _tmp_wo('Teskie International Payment', 714, 'TESKIE', 493, 32, 'Teskie International Payment');
-- ============================================================
-- STRIPE DEPOSITS → Outstanding Receipts (493)
-- Online payment gateway deposits
-- ============================================================
SELECT _tmp_wo('Stripe Payment Deposit', 720, 'STRIPE', 493, 32, 'Stripe Online Payment Deposit');
-- ============================================================
-- DEPOSITS / CHEQUE DEPOSITS → Outstanding Receipts (493)
-- Customer payments received
-- ============================================================
SELECT _tmp_wo('Mobile Cheque Deposit', 730, 'Mobile cheque deposit', 493, 32, 'Customer Cheque Deposit');
SELECT _tmp_wo('ATM Deposit', 731, 'ATM deposit', 493, 32, 'Customer ATM Cash/Cheque Deposit');
-- ============================================================
-- NSF RETURNS → Outstanding Receipts (493)
-- Bounced cheques — need to reverse original payment
-- ============================================================
SELECT _tmp_wo('Item Returned NSF', 740, 'Item returned NSF', 493, 32, 'NSF Item Return');
SELECT _tmp_wo('Cheque Returned NSF', 741, 'Cheque returned NSF', 493, 32, 'NSF Cheque Return');
-- ============================================================
-- OUTGOING PAYMENTS / BILLS
-- ============================================================
-- Personal Loan SPL (already has model 80 but checking)
-- Wawanesa Insurance (already model 28 — partner_map, needs bills)
-- Bill Payment Telus (already model for Telus)
-- Bill Payment BuildingStack
SELECT _tmp_wo('BuildingStack Rent Payment', 750, 'BUILDING_STACK', 560, 20, 'BuildingStack Building Rent');
-- Commercial Taxes
SELECT _tmp_wo('Commercial Property Tax', 751, 'COMMERCIAL TAXES', 507, 20, 'Commercial Property Tax Payment');
-- HMS Auto Service
SELECT _tmp_wo('HMS Auto Service', 752, 'HMS AUTO', 497, 20, 'HMS Auto Service Vehicle Repair');
-- Dixie Tailoring (alterations)
SELECT _tmp_wo('Dixie Tailoring', 753, 'DIXIE TAILORIN', 507, 20, 'Dixie Tailoring Services');
-- Hardware Agency
SELECT _tmp_wo('Hardware Agency', 754, 'HARDWARE AGENC', 507, 20, 'Hardware Agency Supplies');
-- Desi Haveli / Bamiyan Kabob (meals)
SELECT _tmp_wo('Desi Haveli Restaurant', 755, 'DESI HAVELI', 506, 20, 'Desi Haveli Restaurant Meals');
SELECT _tmp_wo('Bamiyan Kabob Restaurant', 756, 'BAMIYAN KABOB', 506, 20, 'Bamiyan Kabob Restaurant Meals');
-- Intuit/ADP payroll verification
SELECT _tmp_wo('Intuit Payroll Verification', 757, 'INTUITCANADAULC', 507, 32, 'Intuit Canada Payroll Verification');
-- ============================================================
-- BRANCH TRANSFERS → Outstanding Receipts (493)
-- Internal RBC account transfers
-- ============================================================
SELECT _tmp_wo('RBC Branch Transfer 1306', 760, 'BR TO BR - 1306', 493, 32, 'RBC Branch Transfer 1306');
SELECT _tmp_wo('RBC Branch Transfer 9970', 761, 'BR TO BR - 9970', 493, 32, 'RBC Branch Transfer 9970');
-- ============================================================
-- GENERIC DEPOSIT → Outstanding Receipts
-- ============================================================
SELECT _tmp_wo('Generic Bank Deposit', 770, 'Deposit', 493, 32, 'Bank Deposit');
DROP FUNCTION _tmp_wo(text, int, text, int, int, text);
COMMIT;

91
batch8_fixes.sql Normal file
View File

@@ -0,0 +1,91 @@
BEGIN;
CREATE OR REPLACE FUNCTION _tmp_wo(p_name text, p_seq int, p_match text, p_acct int, p_tax int, p_label text) RETURNS void AS $$
DECLARE v_mid int; v_lid int;
BEGIN
INSERT INTO account_reconcile_model (name, sequence, company_id, trigger, match_label, match_label_param, active, can_be_proposed, create_uid, write_uid, create_date, write_date)
VALUES (jsonb_build_object('en_US', p_name), p_seq, 1, 'auto_reconcile', 'contains', p_match, true, true, 2, 2, NOW(), NOW()) RETURNING id INTO v_mid;
INSERT INTO account_reconcile_model_line (model_id, company_id, sequence, account_id, amount_type, amount, amount_string, label, create_uid, write_uid, create_date, write_date)
VALUES (v_mid, 1, 10, p_acct, 'percentage', 100, '100', jsonb_build_object('en_US', p_label), 2, 2, NOW(), NOW()) RETURNING id INTO v_lid;
INSERT INTO account_reconcile_model_line_account_tax_rel (account_reconcile_model_line_id, account_tax_id) VALUES (v_lid, p_tax);
END; $$ LANGUAGE plpgsql;
-- ============================================================
-- FIX 1: Wawanesa (model 28) — convert from partner_map to writeoff
-- Insurance → Car Insurance (548), NO TAX (insurance is exempt)
-- ============================================================
-- Deactivate old partner_map model
UPDATE account_reconcile_model SET active = false WHERE id = 28;
-- Create new writeoff model for Wawanesa
SELECT _tmp_wo('Wawanesa Insurance Premium', 800, 'WAWANESA', 548, 32, 'Wawanesa Car Insurance Premium');
-- ============================================================
-- FIX 2: Personal Loan SPL (model 80) — fix match param
-- "Personal Loan SPL" doesn't match "Personal Loan : SPL"
-- ============================================================
UPDATE account_reconcile_model SET match_label_param = 'Personal Loan' WHERE id = 80;
-- ============================================================
-- FIX 3: IFS Insurance (model 23) — same issue, convert from partner_map
-- ============================================================
-- Check if model 23 has writeoff line
-- Model 23: match "IFS PREMIUM", mapped_partner_id=7291, no writeoff line
UPDATE account_reconcile_model SET active = false WHERE id = 23;
SELECT _tmp_wo('IFS Insurance Premium', 801, 'IFS PREMIUM', 550, 32, 'IFS Commercial Insurance Premium');
-- ============================================================
-- NEW MODELS for remaining repeated patterns
-- ============================================================
-- Telus Bill Payment (523 = Telephone, HST)
SELECT _tmp_wo('Telus Bill Payment', 802, 'Telus Comm', 523, 20, 'Telus Communications Bill Payment');
-- e-Transfer fee (already model 5 but check if matching)
-- Model 5 matches "e-Transfer fee" — should work
-- Account Payable Pmt HOME (LTC home customer payments → Outstanding Receipts)
SELECT _tmp_wo('HOME LTC Customer Payment', 803, 'Account Payable PmtHOME', 493, 32, 'HOME Long-Term Care Payment');
SELECT _tmp_wo('HOME LTC Customer Payment 2', 804, 'Account Payable Pmt HOME', 493, 32, 'HOME Long-Term Care Payment');
SELECT _tmp_wo('HOME LTC Customer Payment 3', 805, 'Account Payable Pmt-HOME', 493, 32, 'HOME Long-Term Care Payment');
-- R & M Health Supplies payment
SELECT _tmp_wo('R&M Health Supplies Payment', 806, 'R & M HEALTH', 493, 32, 'R&M Health Supplies Customer Payment');
-- BCCL payment
SELECT _tmp_wo('BCCL Customer Payment', 807, 'Account Payable Pmt-BCCL', 493, 32, 'BCCL Customer Payment');
-- CARE payment
SELECT _tmp_wo('CARE Customer Payment', 808, 'Account Payable PmtCARE', 493, 32, 'CARE Customer Payment');
-- Amica Senior Life (different spelling from earlier model)
SELECT _tmp_wo('Amica Senior Life Payment', 809, 'AMICASENIORLIFE', 493, 32, 'Amica Senior Life Customer Payment');
-- Cash withdrawal (no tax, Office Expense)
SELECT _tmp_wo('Cash Withdrawal', 810, 'Cash withdrawal', 507, 32, 'Cash Withdrawal');
-- ATM/Mobile adjustment
SELECT _tmp_wo('ATM Mobile Adjustment Credit', 811, 'ATM/Mobile adjustment credit', 499, 32, 'ATM Mobile Adjustment Credit');
SELECT _tmp_wo('ATM Mobile Adjustment Debit', 812, 'ATM/Mobile adjustment debit', 499, 32, 'ATM Mobile Adjustment Debit');
-- e-Transfer cancel (returned funds → Outstanding Receipts)
SELECT _tmp_wo('e-Transfer Cancellation', 813, 'e-Transfer cancel', 493, 32, 'e-Transfer Cancelled Return');
-- OnRoute (highway rest stop meals)
SELECT _tmp_wo('OnRoute Highway Meals', 814, 'ONROUTE', 506, 20, 'OnRoute Highway Rest Stop');
-- Opening Balance
SELECT _tmp_wo('Opening Balance', 815, 'Opening Balance', 493, 32, 'Opening Balance Entry');
-- Foreign Exchange withdrawal
SELECT _tmp_wo('Royal Foreign Exchange', 816, 'Royal Foreign Exchange', 525, 32, 'Royal Foreign Exchange Withdrawal');
-- Online Banking wire payment
SELECT _tmp_wo('Online Banking Wire Payment', 817, 'Online Banking wire', 494, 32, 'Online Banking Wire Payment');
-- Henrys camera store refund
SELECT _tmp_wo('Henrys Camera Refund', 818, 'Henry', 507, 20, 'Henrys Camera Store');
DROP FUNCTION _tmp_wo(text, int, text, int, int, text);
COMMIT;

18
batch9_rbc_visa.sql Normal file
View File

@@ -0,0 +1,18 @@
BEGIN;
CREATE OR REPLACE FUNCTION _tmp_wo(p_name text, p_seq int, p_match text, p_acct int, p_tax int, p_label text) RETURNS void AS $$
DECLARE v_mid int; v_lid int;
BEGIN
INSERT INTO account_reconcile_model (name, sequence, company_id, trigger, match_label, match_label_param, active, can_be_proposed, create_uid, write_uid, create_date, write_date)
VALUES (jsonb_build_object('en_US', p_name), p_seq, 1, 'auto_reconcile', 'contains', p_match, true, true, 2, 2, NOW(), NOW()) RETURNING id INTO v_mid;
INSERT INTO account_reconcile_model_line (model_id, company_id, sequence, account_id, amount_type, amount, amount_string, label, partner_id, create_uid, write_uid, create_date, write_date)
VALUES (v_mid, 1, 10, p_acct, 'percentage', 100, '100', jsonb_build_object('en_US', p_label), 1, 2, 2, NOW(), NOW()) RETURNING id INTO v_lid;
INSERT INTO account_reconcile_model_line_account_tax_rel (account_reconcile_model_line_id, account_tax_id) VALUES (v_lid, p_tax);
END; $$ LANGUAGE plpgsql;
SELECT _tmp_wo('RBC Visa - CC Payment Received', 900, 'PAYMENT - THANK YOU', 77, 32, 'RBC CC Payment from Chequing');
SELECT _tmp_wo('RBC Visa - Credit Card Payment', 901, 'credit card payment', 77, 32, 'RBC CC Payment from Chequing');
SELECT _tmp_wo('RBC Visa - RBC CC Payment', 902, 'RBC credit card', 77, 32, 'RBC CC Payment from Chequing');
SELECT _tmp_wo('RBC Visa - Payment to CC', 903, 'payment to credit card', 77, 32, 'RBC CC Payment from Chequing');
DROP FUNCTION _tmp_wo(text, int, text, int, int, text);
COMMIT;

30
batch_reconcile.py Normal file
View File

@@ -0,0 +1,30 @@
import logging
RecModel = env['account.reconcile.model']
StLine = env['account.bank.statement.line']
models = RecModel.search([('trigger', '=', 'auto_reconcile'), ('can_be_proposed', '=', True)])
print(f'Auto-reconcile models: {len(models)}', flush=True)
# Run on ALL 4 journals
for jid, name in [(53, 'RBC Chequing'), (28, 'RBC Visa'), (50, 'Scotia Current'), (51, 'Scotia Passport Visa')]:
lines = StLine.search([('journal_id', '=', jid), ('is_reconciled', '=', False)])
count_before = len(lines)
if not count_before:
continue
batch_size = 100
for i in range(0, count_before, batch_size):
batch = lines[i:i+batch_size]
try:
models._apply_reconcile_models(batch)
except Exception as e:
print(f' Error: {e}', flush=True)
env.cr.commit()
remaining = StLine.search_count([('journal_id', '=', jid), ('is_reconciled', '=', False)])
reconciled = count_before - remaining
if reconciled > 0:
print(f'{name}: reconciled {reconciled}/{count_before}, remaining {remaining}', flush=True)
else:
print(f'{name}: no new matches ({count_before} remaining)', flush=True)

73
cleanup_duplicates.py Normal file
View File

@@ -0,0 +1,73 @@
import logging
_logger = logging.getLogger('cleanup_duplicates')
BSL = env['account.bank.statement.line'].sudo()
AML = env['account.move.line'].sudo()
AM = env['account.move'].sudo()
# All 64 duplicate statement line IDs (the second import set, 18703-18767)
dupe_ids = [
18703, 18704, 18705, 18706, 18707, 18708, 18709, 18710, 18711, 18712,
18713, 18714, 18715, 18716, 18717, 18718, 18719, 18720, 18721, 18722,
18723, 18724, 18725, 18726, 18727, 18728, 18729, 18730, 18731, 18732,
18733, 18734, 18735, 18736, 18737, 18738, 18739, 18740, 18741, 18742,
18743, 18744, 18745, 18746, 18747, 18748, 18749, 18750, 18751, 18752,
18753, 18754, 18755, 18756, 18757, 18758, 18759, 18760, 18761, 18762,
18763, 18764, 18766, 18767,
]
dupes = BSL.browse(dupe_ids)
print(f'Processing {len(dupes)} duplicate statement lines', flush=True)
reconciled_count = 0
unreconciled_count = 0
error_count = 0
for line in dupes:
move = line.move_id
if line.is_reconciled:
# Step 1: Un-reconcile — remove partial reconcile entries
# Find the statement line's AML and its partial reconciliations
st_aml = move.line_ids.filtered(lambda l: l.statement_line_id == line)
if st_aml:
# Find and remove partial reconcile entries
partials = env['account.partial.reconcile'].sudo().search([
'|',
('debit_move_id', 'in', st_aml.ids),
('credit_move_id', 'in', st_aml.ids),
])
if partials:
partials.unlink()
# Also check full reconcile
full_recs = st_aml.mapped('full_reconcile_id')
if full_recs:
full_recs.unlink()
reconciled_count += 1
# Step 2: Reset move to draft so we can delete it
try:
if move.state == 'posted':
move.button_draft()
# Step 3: Cancel and delete the move (which deletes the statement line too)
move.button_cancel()
move.with_context(force_delete=True).unlink()
unreconciled_count += 1
except Exception as e:
print(f' Error on line {line.id}: {e}', flush=True)
error_count += 1
env.cr.rollback()
continue
if unreconciled_count % 20 == 0:
env.cr.commit()
print(f' Progress: {unreconciled_count} deleted...', flush=True)
env.cr.commit()
print(f'DONE: {unreconciled_count} deleted, {reconciled_count} were reconciled, {error_count} errors', flush=True)
# Verify
remaining = BSL.search_count([('id', 'in', dupe_ids)])
print(f'Verification: {remaining} duplicate lines still exist (should be 0)', flush=True)

63
debug_reconcile.py Normal file
View File

@@ -0,0 +1,63 @@
from odoo.tools import SQL
lines = env['account.bank.statement.line'].browse([20262])
models = env['account.reconcile.model'].search([('trigger', '=', 'auto_reconcile'), ('can_be_proposed', '=', True)])
env['account.reconcile.model'].flush_model()
lines.flush_recordset()
# Run a simplified version of the _apply_reconcile_models SQL
env.cr.execute("""
WITH matching_journal_ids AS (
SELECT account_reconcile_model_id, ARRAY_AGG(account_journal_id) AS ids
FROM account_journal_account_reconcile_model_rel
GROUP BY account_reconcile_model_id
),
matching_partner_ids AS (
SELECT account_reconcile_model_id, ARRAY_AGG(res_partner_id) AS ids
FROM account_reconcile_model_res_partner_rel
GROUP BY account_reconcile_model_id
)
SELECT st_line.id AS st_line_id,
reco_model.id AS reco_model_id,
reco_model.trigger
FROM account_bank_statement_line st_line
JOIN account_move move ON st_line.move_id = move.id
LEFT JOIN LATERAL (
SELECT reco_model.id, reco_model.trigger
FROM account_reconcile_model reco_model
LEFT JOIN matching_journal_ids ON reco_model.id = matching_journal_ids.account_reconcile_model_id
LEFT JOIN matching_partner_ids ON reco_model.id = matching_partner_ids.account_reconcile_model_id
WHERE (matching_journal_ids.ids IS NULL OR st_line.journal_id = ANY(matching_journal_ids.ids))
AND (matching_partner_ids.ids IS NULL OR st_line.partner_id = ANY(matching_partner_ids.ids))
AND (reco_model.match_label IS NULL OR (
reco_model.match_label = 'contains'
AND (st_line.payment_ref ILIKE '%%' || reco_model.match_label_param || '%%'
OR move.narration::TEXT ILIKE '%%' || reco_model.match_label_param || '%%')
))
AND reco_model.id IN %s
AND reco_model.can_be_proposed IS TRUE
AND reco_model.company_id = st_line.company_id
ORDER BY reco_model.sequence ASC, reco_model.id ASC
LIMIT 1
) AS reco_model ON TRUE
WHERE st_line.id IN %s
""", (tuple(models.ids), tuple(lines.ids)))
results = env.cr.fetchall()
print(f'SQL results: {results}', flush=True)
# Now check what the full _apply_reconcile_models method SQL has that's different
# The key is that the method joins with model_fees and account_reconcile_model_line
# Let me check if the model 47 has an account_reconcile_model_line with account_id set
model47 = env['account.reconcile.model'].browse(47)
print(f'Model 47 lines: {[(l.id, l.account_id.id, l.account_id.name) for l in model47.line_ids]}', flush=True)
# Check the full method result
print('Calling _apply_reconcile_models...', flush=True)
lines2 = env['account.bank.statement.line'].browse([20266]) # FACEBK line
print(f'Line 20266 before: reconciled={lines2.is_reconciled}', flush=True)
models._apply_reconcile_models(lines2)
env.cr.commit()
lines2.invalidate_recordset()
print(f'Line 20266 after: reconciled={lines2.is_reconciled}', flush=True)

View File

@@ -0,0 +1,96 @@
# Interactive Tables for Fusion AI Chat
**Date:** 2026-04-03
**Module:** fusion_accounting
**Status:** Approved for implementation
## Problem
AI tool results render as plain Markdown tables in the chat. Users cannot annotate, act on, or provide feedback on individual rows. For actionable reports (missing ITCs, duplicate bills, overdue invoices), users need per-row input and bulk actions.
## Solution
A `fusion-table` structured data block that the AI returns instead of Markdown tables for actionable results. The frontend parses these blocks and renders an interactive table widget with: AI recommendations per row, user input fields, checkboxes, and a bulk action bar.
## AI Output Format
The AI wraps structured data in a fenced code block with language `fusion-table`:
```fusion-table
{
"mode": "interactive",
"title": "Missing ITC Bills",
"columns": ["Date", "Vendor", "Amount", "ITC Risk"],
"rows": [
{
"id": 123,
"cells": ["2024-01-10", "Ki Mobility LLC", "-$14,917.95", "HST ITC?"],
"recommendation": {"action": "dismiss", "reason": "US vendor, no HST applies"}
}
],
"actions": ["dismiss", "flag", "create_rule"],
"source_tool": "find_missing_itc_bills"
}
```
- `mode`: `"interactive"` (full widget) or `"readonly"` (styled table, no inputs)
- `columns`: header labels for the data columns
- `rows[].id`: Odoo record ID (e.g., account.move ID)
- `rows[].cells`: display values matching columns
- `rows[].recommendation`: AI's suggested action + reasoning (optional)
- `actions`: which bulk action buttons to show
- `source_tool`: which tool produced this data
## Frontend Components
### 1. mdToHtml() Enhancement (chat_panel.js)
Detect `fusion-table` fenced blocks during Markdown parsing. Extract the JSON payload and render a placeholder `<div class="fusion_interactive_table" data-table-idx="N"/>` that the OWL component will mount into.
### 2. FusionInteractiveTable (new OWL component)
Renders inside the chat message area. Structure:
- **Header row**: Select-all checkbox + data columns + "AI Recommendation" + "Your Input"
- **Body rows**: Per-row checkbox + data cells + recommendation badge (colour-coded: green=dismiss, amber=flag, blue=create_rule) + text input
- **Action bar** (bottom): "Apply Recommendations", "Flag Selected", "Create Rules", "Dismiss Selected", "Submit All Notes to AI"
### 3. Action Flow
Button clicks collect `{rowIds, notes, action}` and call `this.props.onTableAction(payload)`. The chat panel formats this into a structured user message and sends it via the existing `/fusion_accounting/chat` endpoint:
```
[TABLE_ACTION] source=find_missing_itc_bills action=dismiss
Rows: #123 (note: "Confirmed, no ITC needed"), #125 (note: "Need to check PO")
```
The AI processes this through its normal tool-calling flow — dismissing, flagging, creating rules, etc.
## Styling
All colours via Odoo CSS variables and Bootstrap utilities:
- Dismiss badge: `bg-success-subtle` / `text-success`
- Flag badge: `bg-warning-subtle` / `text-warning`
- Create Rule badge: `bg-info-subtle` / `text-info`
- Input fields: Odoo form control classes
- Action bar: `bg-view` with `border-top`
- No hardcoded colours — dark/light mode handled by Odoo theme
## Files Changed
| File | Change |
|---|---|
| `static/src/components/chat/chat_panel.js` | Parse fusion-table blocks in mdToHtml(), mount interactive tables, wire action handler |
| `static/src/components/chat/chat_panel.xml` | Add template slot for interactive tables |
| `static/src/components/chat/interactive_table.js` | New OWL component |
| `static/src/components/chat/interactive_table.xml` | New template |
| `static/src/scss/chat.scss` | Interactive table styles (CSS variables only) |
| `services/prompts/system_prompt.py` | Add fusion-table format instructions to system prompt |
## What Does NOT Change
- Backend tools (same return data)
- AI adapters/orchestrator
- Tier 3 approval cards (separate flow)
- Controller endpoints
- Regular Markdown rendering for non-table content

145
fix_elavon.py Normal file
View File

@@ -0,0 +1,145 @@
import logging
_logger = logging.getLogger('fix_elavon')
AML = env['account.move.line'].sudo()
BSL = env['account.bank.statement.line'].sudo()
# ============================================================
# PART 1: Fix 144 incoming Elavon payments (Bank Charges -> Outstanding Receipts)
# ============================================================
print('=== PART 1: Fix incoming Elavon payments ===', flush=True)
incoming_bad_amls = AML.search([
('account_id', '=', 499), # Bank Charges
('statement_line_id', '!=', False),
('statement_line_id.payment_ref', 'ilike', 'elavon'),
('credit', '>', 0), # Credit to Bank Charges = incoming payment writeoff
])
# Filter to only those where the statement line amount > 0 (incoming)
incoming_ids = []
for aml in incoming_bad_amls:
if aml.statement_line_id.amount > 0:
incoming_ids.append(aml.id)
print(f'Found {len(incoming_ids)} incoming Elavon writeoff lines to fix', flush=True)
if incoming_ids:
# Direct SQL update - change account from 499 to 493
env.cr.execute("""
UPDATE account_move_line
SET account_id = 493
WHERE id IN %s
""", (tuple(incoming_ids),))
env.cr.commit()
print(f'Changed {len(incoming_ids)} lines: Bank Charges (499) -> Outstanding Receipts (493)', flush=True)
# ============================================================
# PART 2: Fix 6 round-number refund Business PADs
# ============================================================
print('\n=== PART 2: Fix round-number customer refunds ===', flush=True)
refund_bad_amls = AML.search([
('account_id', '=', 499), # Bank Charges
('statement_line_id', '!=', False),
('statement_line_id.payment_ref', 'ilike', 'elavon'),
('debit', '>', 0), # Debit to Bank Charges = outgoing writeoff
])
refund_ids = []
for aml in refund_bad_amls:
st_line = aml.statement_line_id
if st_line.amount < 0 and st_line.amount == round(st_line.amount, 0):
refund_ids.append(aml.id)
print(f' Refund: line {st_line.id}, ${st_line.amount}, {st_line.move_id.date}', flush=True)
print(f'Found {len(refund_ids)} round-number refund lines to fix', flush=True)
if refund_ids:
env.cr.execute("""
UPDATE account_move_line
SET account_id = 493
WHERE id IN %s
""", (tuple(refund_ids),))
env.cr.commit()
print(f'Changed {len(refund_ids)} lines: Bank Charges (499) -> Outstanding Receipts (493)', flush=True)
# ============================================================
# PART 3: Fix reconcile model 96 - should ONLY match fees (Business PAD)
# and create new model for incoming Elavon payments
# ============================================================
print('\n=== PART 3: Update reconcile models ===', flush=True)
# Model 96 currently matches "Elavon Mrch Svc" which catches EVERYTHING
# Change it to only match "Business PAD" (the fees)
model96 = env['account.reconcile.model'].sudo().browse(96)
print(f'Model 96 before: match="{model96.match_label_param}", account={model96.line_ids.account_id.name}', flush=True)
model96.write({'match_label_param': 'Business PAD'})
# Keep account 499 (Bank Charges) for the fees - that's correct
print(f'Model 96 after: match="{model96.match_label_param}" (now only matches fees)', flush=True)
# Model 85 matches "MRCH" which also catches Elavon payments on RBC Chequing
# Leave it for now - those are the RBC monthly MRCH fee lines, different pattern
# Create new model for incoming Elavon payments -> Outstanding Receipts (493)
existing = env['account.reconcile.model'].sudo().search([
('match_label_param', '=', 'Elavon Mrch Svc : Miscellaneous'),
])
if not existing:
new_model = env['account.reconcile.model'].sudo().create({
'name': 'Elavon Customer Payment Deposit',
'sequence': 55,
'company_id': 1,
'trigger': 'auto_reconcile',
'match_label': 'contains',
'match_label_param': 'Elavon Mrch Svc : Miscellaneous',
'can_be_proposed': True,
})
new_line = env['account.reconcile.model.line'].sudo().create({
'model_id': new_model.id,
'company_id': 1,
'sequence': 10,
'account_id': 493, # Outstanding Receipts
'amount_type': 'percentage',
'amount': 100,
'amount_string': '100',
'label': 'Elavon Visa Terminal Customer Payment',
'partner_id': 1, # Westin Healthcare (company)
})
# No tax on payment deposits
env.cr.execute("""
INSERT INTO account_reconcile_model_line_account_tax_rel
(account_reconcile_model_line_id, account_tax_id) VALUES (%s, 32)
""", (new_line.id,))
print(f'Created new model: "Elavon Customer Payment Deposit" -> Outstanding Receipts (493)', flush=True)
else:
print(f'Model for Elavon incoming already exists: {existing.name}', flush=True)
env.cr.commit()
# ============================================================
# PART 4: Verify
# ============================================================
print('\n=== VERIFICATION ===', flush=True)
# Count remaining Elavon lines posted to Bank Charges
remaining_499 = env.cr.execute("""
SELECT COUNT(*), ROUND(SUM(ABS(aml.balance))::numeric, 2)
FROM account_move_line aml
JOIN account_bank_statement_line bsl ON bsl.id = aml.statement_line_id
WHERE aml.account_id = 499 AND bsl.payment_ref ILIKE '%%elavon%%'
""")
row = env.cr.fetchone()
print(f'Elavon lines still on Bank Charges: {row[0]} lines, ${row[1]}', flush=True)
print('(These should be the monthly processing fees only)', flush=True)
# Count Elavon lines now on Outstanding Receipts
env.cr.execute("""
SELECT COUNT(*), ROUND(SUM(ABS(aml.balance))::numeric, 2)
FROM account_move_line aml
JOIN account_bank_statement_line bsl ON bsl.id = aml.statement_line_id
WHERE aml.account_id = 493 AND bsl.payment_ref ILIKE '%%elavon%%'
""")
row = env.cr.fetchone()
print(f'Elavon lines now on Outstanding Receipts: {row[0]} lines, ${row[1]}', flush=True)

27
fix_from_lines.py Normal file
View File

@@ -0,0 +1,27 @@
# Manually reconcile the 4 "from" lines — they're Scotia Current transfers
# with no account number in the ref
AML = env['account.move.line'].sudo()
BSL = env['account.bank.statement.line'].sudo()
line_ids = [16375, 16380, 16383, 16433]
for lid in line_ids:
line = BSL.browse(lid)
if line.is_reconciled:
continue
print(f'Line {lid}: {line.payment_ref}, ${line.amount}, {line.move_id.date}', flush=True)
# These are transfers from Scotia Current — post to Outstanding Receipts (493)
model = env['account.reconcile.model'].search([
('match_label_param', '=', 'PAYMENT FROM'),
('trigger', '=', 'auto_reconcile'),
], limit=1)
if model:
try:
model._trigger_reconciliation_model(line)
env.cr.commit()
line.invalidate_recordset()
print(f' -> Reconciled: {line.is_reconciled}', flush=True)
except Exception as e:
print(f' -> Error: {e}', flush=True)
env.cr.rollback()

17
fix_no_tax.sql Normal file
View File

@@ -0,0 +1,17 @@
BEGIN;
-- Fix ALL model lines that have NO explicit tax set.
-- These inherit the account's default tax (HST PURCHASE) which is WRONG
-- for bank fees, foreign vendors, insurance, interest, etc.
-- Set them all to NO TAX PURCHASE (ID 32) explicitly.
INSERT INTO account_reconcile_model_line_account_tax_rel (account_reconcile_model_line_id, account_tax_id)
SELECT rml.id, 32
FROM account_reconcile_model rm
JOIN account_reconcile_model_line rml ON rml.model_id = rm.id
LEFT JOIN account_reconcile_model_line_account_tax_rel tr ON tr.account_reconcile_model_line_id = rml.id
WHERE rm.active = true AND rm.company_id = 1
AND tr.account_tax_id IS NULL
ON CONFLICT DO NOTHING;
COMMIT;

105
fix_po_vendor_models.sql Normal file
View File

@@ -0,0 +1,105 @@
BEGIN;
-- ============================================================
-- Partner-mapping reconciliation models for PO vendors
-- These auto-assign the vendor to the bank line so the payment
-- appears on the vendor's account. When the bill is posted from
-- the PO, the payment shows up as "outstanding credit" on the bill.
-- ============================================================
-- Access BDD / TK Access Solutions (partner 6895)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Access BDD / TK Access"}', 1, 'auto_reconcile', 'contains', 'TK ACCESS', 6895, false, 200, true, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Access BDD - Storage"}', 1, 'auto_reconcile', 'contains', 'access storage', 6895, false, 201, true, 2, 2, NOW(), NOW());
-- Blake Medical (partner 4944)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Blake Medical"}', 1, 'auto_reconcile', 'contains', 'blake medical', 4944, false, 202, true, 2, 2, NOW(), NOW());
-- Drive Medical (partner 15)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Drive Medical"}', 1, 'auto_reconcile', 'contains', 'DRIVE MEDICAL', 15, false, 203, true, 2, 2, NOW(), NOW());
-- Evolution Technologies (partner 4962)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Evolution Technologies"}', 1, 'auto_reconcile', 'contains', 'Evolution Tech', 4962, false, 204, true, 2, 2, NOW(), NOW());
-- HumanCare Canada (partner 4976)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "HumanCare Canada"}', 1, 'auto_reconcile', 'contains', 'HumanCare', 4976, false, 205, true, 2, 2, NOW(), NOW());
-- Sunrise Medical (partner 42)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Sunrise Medical"}', 1, 'auto_reconcile', 'contains', 'Sunrise Medical', 42, false, 206, true, 2, 2, NOW(), NOW());
-- East Penn Canada (partner 4959)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "East Penn Canada"}', 1, 'auto_reconcile', 'contains', 'EAST PENN', 4959, false, 207, true, 2, 2, NOW(), NOW());
-- Invacare Canada (partner 24)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Invacare Canada"}', 1, 'auto_reconcile', 'contains', 'Invacare', 24, false, 208, true, 2, 2, NOW(), NOW());
-- Joerns Healthcare (partner 25)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Joerns Healthcare"}', 1, 'auto_reconcile', 'contains', 'joerns', 25, false, 209, true, 2, 2, NOW(), NOW());
-- Nighthawk Manufacturing (partner 4998)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Nighthawk Manufacturing"}', 1, 'auto_reconcile', 'contains', 'NIGHTHAWK', 4998, false, 210, true, 2, 2, NOW(), NOW());
-- Savaria Concord (partner 6864)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Savaria Concord Lifts"}', 1, 'auto_reconcile', 'contains', 'SAVARIA', 6864, false, 211, true, 2, 2, NOW(), NOW());
-- Parsons ADL (partner 5001)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Parsons ADL"}', 1, 'auto_reconcile', 'contains', 'PARSONS', 5001, false, 212, true, 2, 2, NOW(), NOW());
-- Cardinal Health (partner 4948)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Cardinal Health"}', 1, 'auto_reconcile', 'contains', 'Cardinal Health', 4948, false, 213, true, 2, 2, NOW(), NOW());
-- HPU Rehab / HPU Medical (partner 5137)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "HPU Rehab"}', 1, 'auto_reconcile', 'contains', 'hpu medical', 5137, false, 214, true, 2, 2, NOW(), NOW());
-- Interstate Batteries (partner 6200)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Interstate Batteries"}', 1, 'auto_reconcile', 'contains', 'INTERSTATE', 6200, false, 215, true, 2, 2, NOW(), NOW());
-- Standers Inc (partner 5014)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Standers Inc"}', 1, 'auto_reconcile', 'contains', 'STANDERS', 5014, false, 216, true, 2, 2, NOW(), NOW());
-- Handicare / Accessibility Canada (partner 5588)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Handicare Canada"}', 1, 'auto_reconcile', 'contains', 'handicare', 5588, false, 217, true, 2, 2, NOW(), NOW());
-- Mobb Healthcare (partner 4994)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Mobb Healthcare"}', 1, 'auto_reconcile', 'contains', 'Mobb Healthcare', 4994, false, 218, true, 2, 2, NOW(), NOW());
-- Healthcraft Products (partner 4973)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Healthcraft Products"}', 1, 'auto_reconcile', 'contains', 'HEALTHCRAFT', 4973, false, 219, true, 2, 2, NOW(), NOW());
-- Medline Canada (partner 28)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Medline Canada"}', 1, 'auto_reconcile', 'contains', 'MEDLINE', 28, false, 220, true, 2, 2, NOW(), NOW());
-- Carex Health Brands (partner 6779)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Carex Health"}', 1, 'auto_reconcile', 'contains', 'CAREX HEALTH', 6779, false, 221, true, 2, 2, NOW(), NOW());
-- Advanced Mobility Systems (partner 5158)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Advanced Mobility Systems"}', 1, 'auto_reconcile', 'contains', 'advanced mobility', 5158, false, 222, true, 2, 2, NOW(), NOW());
-- Enhance Mobility (partner 6745)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Enhance Mobility"}', 1, 'auto_reconcile', 'contains', 'ENHANCE MOBILITY', 6745, false, 223, true, 2, 2, NOW(), NOW());
COMMIT;

170
fix_reconcile_models.sql Normal file
View File

@@ -0,0 +1,170 @@
BEGIN;
-- ============================================
-- FIX 1: Wawanesa model 28 — add missing match_label_param
-- ============================================
UPDATE account_reconcile_model
SET match_label = 'contains', match_label_param = 'WAWANESA'
WHERE id = 28 AND company_id = 1;
-- ============================================
-- FIX 2: IFS Insurance model 23 — remove HST (insurance is exempt)
-- ============================================
DELETE FROM account_reconcile_model_line_account_tax_rel
WHERE account_reconcile_model_line_id IN (
SELECT id FROM account_reconcile_model_line WHERE model_id = 23
);
-- ============================================
-- NEW MODELS — each needs a model row + a line row (+ tax rel if HST)
-- ============================================
-- Helper: create models via INSERT
-- Personal Loan SPL → 6028 Car/Van Expenses + HST
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Personal Loan SPL"}', 1, 'auto_reconcile', 'contains', 'Personal Loan SPL', true, 100, true, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
VALUES (currval('account_reconcile_model_id_seq'), 1, 497, 'percentage', 100, '100', '{"en_US": "Vehicle Finance Payment"}', 10, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line_account_tax_rel (account_reconcile_model_line_id, account_tax_id)
VALUES (currval('account_reconcile_model_line_id_seq'), 20);
-- Overdraft Fee → 6560 Bank Overdraft Charges, no HST
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Overdraft Fee"}', 1, 'auto_reconcile', 'contains', 'Overdraft', true, 101, true, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
VALUES (currval('account_reconcile_model_id_seq'), 1, 558, 'percentage', 100, '100', '{"en_US": "Bank Overdraft Fee/Interest"}', 10, 2, 2, NOW(), NOW());
-- Overlimit Fee → 6560 Bank Overdraft Charges, no HST
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Overlimit Fee"}', 1, 'auto_reconcile', 'contains', 'OVERLIMIT', true, 102, true, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
VALUES (currval('account_reconcile_model_id_seq'), 1, 558, 'percentage', 100, '100', '{"en_US": "Credit Card Overlimit Fee"}', 10, 2, 2, NOW(), NOW());
-- Transaction Fee → 6030 Bank Charges, no HST
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Bank Transaction Fee"}', 1, 'auto_reconcile', 'contains', 'transaction fee', true, 103, true, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
VALUES (currval('account_reconcile_model_id_seq'), 1, 499, 'percentage', 100, '100', '{"en_US": "Bank Transaction Fee"}', 10, 2, 2, NOW(), NOW());
-- PAY-FILE FEES → 6030 Bank Charges, no HST
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "PAY-FILE Fee"}', 1, 'auto_reconcile', 'contains', 'PAY-FILE', true, 104, true, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
VALUES (currval('account_reconcile_model_id_seq'), 1, 499, 'percentage', 100, '100', '{"en_US": "Payroll File Processing Fee"}', 10, 2, 2, NOW(), NOW());
-- MRCH Merchant Fees → 6030 Bank Charges, no HST
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Merchant MRCH Fee"}', 1, 'auto_reconcile', 'contains', 'MRCH', true, 105, true, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
VALUES (currval('account_reconcile_model_id_seq'), 1, 499, 'percentage', 100, '100', '{"en_US": "Merchant Processing Fee"}', 10, 2, 2, NOW(), NOW());
-- Reliance Esso → 6026 Car Gas + HST
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Reliance Esso"}', 1, 'auto_reconcile', 'contains', 'RELIANCE ESSO', true, 106, true, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
VALUES (currval('account_reconcile_model_id_seq'), 1, 552, 'percentage', 100, '100', '{"en_US": "Vehicle Fuel"}', 10, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line_account_tax_rel (account_reconcile_model_line_id, account_tax_id)
VALUES (currval('account_reconcile_model_line_id_seq'), 20);
-- Facebook Ads → 6025 Advertising, no HST (US company)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Facebook Ads"}', 1, 'auto_reconcile', 'contains', 'facebook', true, 107, true, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
VALUES (currval('account_reconcile_model_id_seq'), 1, 496, 'percentage', 100, '100', '{"en_US": "Facebook/Meta Advertising"}', 10, 2, 2, NOW(), NOW());
-- Cloudflare → 6050 IT Expenses, no HST (US company)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Cloudflare"}', 1, 'auto_reconcile', 'contains', 'cloudflare', true, 108, true, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
VALUES (currval('account_reconcile_model_id_seq'), 1, 495, 'percentage', 100, '100', '{"en_US": "Cloudflare Web Services"}', 10, 2, 2, NOW(), NOW());
-- Equifax → 6050 IT/Credit Check Expenses + HST
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Equifax"}', 1, 'auto_reconcile', 'contains', 'equifax', true, 109, true, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
VALUES (currval('account_reconcile_model_id_seq'), 1, 495, 'percentage', 100, '100', '{"en_US": "Equifax Credit Check Service"}', 10, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line_account_tax_rel (account_reconcile_model_line_id, account_tax_id)
VALUES (currval('account_reconcile_model_line_id_seq'), 20);
-- GoDaddy → 6050 IT Expenses, no HST (US/QC, typically no ON HST)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "GoDaddy"}', 1, 'auto_reconcile', 'contains', 'godaddy', true, 110, true, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
VALUES (currval('account_reconcile_model_id_seq'), 1, 495, 'percentage', 100, '100', '{"en_US": "GoDaddy Domain/Hosting"}', 10, 2, 2, NOW(), NOW());
-- Clover App → 6563 Clover Fee + HST
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Clover POS"}', 1, 'auto_reconcile', 'contains', 'CLOVER', true, 111, true, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
VALUES (currval('account_reconcile_model_id_seq'), 1, 570, 'percentage', 100, '100', '{"en_US": "Clover POS Monthly Fee"}', 10, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line_account_tax_rel (account_reconcile_model_line_id, account_tax_id)
VALUES (currval('account_reconcile_model_line_id_seq'), 20);
-- Google Workspace → 6050 IT Expenses + HST
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Google Workspace"}', 1, 'auto_reconcile', 'contains', 'GSUITE', true, 112, true, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
VALUES (currval('account_reconcile_model_id_seq'), 1, 495, 'percentage', 100, '100', '{"en_US": "Google Workspace Subscription"}', 10, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line_account_tax_rel (account_reconcile_model_line_id, account_tax_id)
VALUES (currval('account_reconcile_model_line_id_seq'), 20);
-- Bell Maison Intelligente → 6050 IT/Smart Home + HST
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Bell Smart Home"}', 1, 'auto_reconcile', 'contains', 'bell maison', true, 113, true, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
VALUES (currval('account_reconcile_model_id_seq'), 1, 495, 'percentage', 100, '100', '{"en_US": "Bell Smart Home/Security"}', 10, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line_account_tax_rel (account_reconcile_model_line_id, account_tax_id)
VALUES (currval('account_reconcile_model_line_id_seq'), 20);
-- CRA PAD → 2200 CRA Payroll Tax Liabilities, no HST
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "CRA PAD Payment"}', 1, 'auto_reconcile', 'contains', 'ccra canada', true, 114, true, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
VALUES (currval('account_reconcile_model_id_seq'), 1, 17, 'percentage', 100, '100', '{"en_US": "CRA Payroll Remittance"}', 10, 2, 2, NOW(), NOW());
-- Device Protection → 6558 Commercial Insurance, no HST
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Device Protection"}', 1, 'auto_reconcile', 'contains', 'device protection', true, 115, true, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
VALUES (currval('account_reconcile_model_id_seq'), 1, 550, 'percentage', 100, '100', '{"en_US": "Device Protection Insurance"}', 10, 2, 2, NOW(), NOW());
-- Elavon PAD Fee (Scotia) → 6030 Bank Charges, no HST
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Elavon Merchant Fee"}', 1, 'auto_reconcile', 'contains', 'Elavon Mrch Svc', true, 116, true, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
VALUES (currval('account_reconcile_model_id_seq'), 1, 499, 'percentage', 100, '100', '{"en_US": "Elavon Merchant Service Fee"}', 10, 2, 2, NOW(), NOW());
-- WSIB → need to check what account WSIB uses
-- Investment MERCH PAD → 6050 IT Expenses + HST (based on historical coding)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Investment Merchant PAD"}', 1, 'auto_reconcile', 'contains', 'Investment MERCH', true, 117, true, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
VALUES (currval('account_reconcile_model_id_seq'), 1, 495, 'percentage', 100, '100', '{"en_US": "Merchant Investment PAD"}', 10, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line_account_tax_rel (account_reconcile_model_line_id, account_tax_id)
VALUES (currval('account_reconcile_model_line_id_seq'), 20);
-- Debit Memo Loan Payment → 6028 Car/Van Expenses + HST (same as Personal Loan SPL)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Debit Memo Loan"}', 1, 'auto_reconcile', 'contains', 'debit memo loan', true, 118, true, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
VALUES (currval('account_reconcile_model_id_seq'), 1, 497, 'percentage', 100, '100', '{"en_US": "Vehicle Loan Payment"}', 10, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line_account_tax_rel (account_reconcile_model_line_id, account_tax_id)
VALUES (currval('account_reconcile_model_line_id_seq'), 20);
-- Prime Video → 6070 Dues and Subscriptions + HST
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Prime Video"}', 1, 'auto_reconcile', 'contains', 'prime video', true, 119, true, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
VALUES (currval('account_reconcile_model_id_seq'), 1, 501, 'percentage', 100, '100', '{"en_US": "Amazon Prime Video Subscription"}', 10, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line_account_tax_rel (account_reconcile_model_line_id, account_tax_id)
VALUES (currval('account_reconcile_model_line_id_seq'), 20);
-- Canada Post (via Visa) → 8010 Shipping + HST
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Canada Post Visa"}', 1, 'auto_reconcile', 'contains', 'canada post', true, 120, true, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
VALUES (currval('account_reconcile_model_id_seq'), 1, 518, 'percentage', 100, '100', '{"en_US": "Canada Post Shipping"}', 10, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line_account_tax_rel (account_reconcile_model_line_id, account_tax_id)
VALUES (currval('account_reconcile_model_line_id_seq'), 20);
COMMIT;

View File

@@ -0,0 +1 @@
{"reason":"idle timeout","timestamp":1775192388322}

View File

@@ -0,0 +1,92 @@
<h2>Hybrid: AI Recommendation + Your Input + Bulk Actions</h2>
<p class="subtitle">The AI pre-fills its recommendation. You get an editable input per row to override or add notes. Checkboxes for bulk actions.</p>
<div class="mockup">
<div class="mockup-header">Chat Panel — find_missing_itc_bills result</div>
<div class="mockup-body" style="padding: 0; overflow-x: auto;">
<table style="width:100%; border-collapse: collapse; font-size: 13px;">
<thead>
<tr style="background: rgba(255,255,255,0.05); border-bottom: 2px solid rgba(255,255,255,0.15);">
<th style="padding: 8px 6px; width:30px; text-align:center;"><input type="checkbox" title="Select all"></th>
<th style="padding: 8px 6px; text-align:left; font-weight:600;">Date</th>
<th style="padding: 8px 6px; text-align:left; font-weight:600;">Vendor</th>
<th style="padding: 8px 6px; text-align:right; font-weight:600;">Amount</th>
<th style="padding: 8px 6px; text-align:left; font-weight:600; color:#60a5fa;">AI Recommendation</th>
<th style="padding: 8px 6px; text-align:left; font-weight:600; color:#fbbf24;">Your Input</th>
</tr>
</thead>
<tbody>
<tr style="border-bottom: 1px solid rgba(255,255,255,0.08);">
<td style="padding: 6px; text-align:center;"><input type="checkbox"></td>
<td style="padding: 6px;">2024-01-10</td>
<td style="padding: 6px;">Ki Mobility LLC</td>
<td style="padding: 6px; text-align:right; font-weight:600;">-$14,917.95</td>
<td style="padding: 6px;"><span style="background:rgba(34,197,94,0.15); color:#4ade80; padding:2px 8px; border-radius:10px; font-size:11px; font-weight:500;">Dismiss</span> <span style="opacity:0.7; font-size:12px;">US vendor, no HST applies</span></td>
<td style="padding: 6px;"><input style="width:100%; padding:4px 8px; font-size:12px; background:rgba(255,255,255,0.06); border:1px solid rgba(255,255,255,0.15); border-radius:4px; color:inherit;" placeholder="Add your note..." value="Confirmed, no ITC needed"></td>
</tr>
<tr style="border-bottom: 1px solid rgba(255,255,255,0.08);">
<td style="padding: 6px; text-align:center;"><input type="checkbox" checked></td>
<td style="padding: 6px;">2024-02-16</td>
<td style="padding: 6px;">Savaria Concord Lifts</td>
<td style="padding: 6px; text-align:right; font-weight:600;">-$10,173.00</td>
<td style="padding: 6px;"><span style="background:rgba(251,191,36,0.15); color:#fbbf24; padding:2px 8px; border-radius:10px; font-size:11px; font-weight:500;">Flag</span> <span style="opacity:0.7; font-size:12px;">Canadian vendor, ITC likely missing</span></td>
<td style="padding: 6px;"><input style="width:100%; padding:4px 8px; font-size:12px; background:rgba(255,255,255,0.06); border:1px solid rgba(255,255,255,0.15); border-radius:4px; color:inherit;" placeholder="Add your note..."></td>
</tr>
<tr style="border-bottom: 1px solid rgba(255,255,255,0.08);">
<td style="padding: 6px; text-align:center;"><input type="checkbox" checked></td>
<td style="padding: 6px;">2024-02-13</td>
<td style="padding: 6px;">Savaria Concord Lifts</td>
<td style="padding: 6px; text-align:right; font-weight:600;">-$9,599.50</td>
<td style="padding: 6px;"><span style="background:rgba(251,191,36,0.15); color:#fbbf24; padding:2px 8px; border-radius:10px; font-size:11px; font-weight:500;">Flag</span> <span style="opacity:0.7; font-size:12px;">Canadian vendor, ITC likely missing</span></td>
<td style="padding: 6px;"><input style="width:100%; padding:4px 8px; font-size:12px; background:rgba(255,255,255,0.06); border:1px solid rgba(255,255,255,0.15); border-radius:4px; color:inherit;" placeholder="Add your note..." value="Need to check PO"></td>
</tr>
<tr style="border-bottom: 1px solid rgba(255,255,255,0.08);">
<td style="padding: 6px; text-align:center;"><input type="checkbox"></td>
<td style="padding: 6px;">2024-01-11</td>
<td style="padding: 6px;">Joerns Healthcare</td>
<td style="padding: 6px; text-align:right; font-weight:600;">-$2,392.80</td>
<td style="padding: 6px;"><span style="background:rgba(251,191,36,0.15); color:#fbbf24; padding:2px 8px; border-radius:10px; font-size:11px; font-weight:500;">Flag</span> <span style="opacity:0.7; font-size:12px;">Check fiscal position</span></td>
<td style="padding: 6px;"><input style="width:100%; padding:4px 8px; font-size:12px; background:rgba(255,255,255,0.06); border:1px solid rgba(255,255,255,0.15); border-radius:4px; color:inherit;" placeholder="Add your note..."></td>
</tr>
<tr style="border-bottom: 1px solid rgba(255,255,255,0.08);">
<td style="padding: 6px; text-align:center;"><input type="checkbox"></td>
<td style="padding: 6px;">2024-01-11</td>
<td style="padding: 6px;">Maple Leaf Wheelchair</td>
<td style="padding: 6px; text-align:right; font-weight:600;">-$2,181.30</td>
<td style="padding: 6px;"><span style="background:rgba(96,165,250,0.15); color:#60a5fa; padding:2px 8px; border-radius:10px; font-size:11px; font-weight:500;">Create Rule</span> <span style="opacity:0.7; font-size:12px;">Recurring vendor, always has HST</span></td>
<td style="padding: 6px;"><input style="width:100%; padding:4px 8px; font-size:12px; background:rgba(255,255,255,0.06); border:1px solid rgba(255,255,255,0.15); border-radius:4px; color:inherit;" placeholder="Add your note..."></td>
</tr>
<tr>
<td style="padding: 6px; text-align:center;"><input type="checkbox"></td>
<td style="padding: 6px;">2024-01-17</td>
<td style="padding: 6px;">Human Care Canada Inc.</td>
<td style="padding: 6px; text-align:right; font-weight:600;">-$2,446.20</td>
<td style="padding: 6px;"><span style="background:rgba(251,191,36,0.15); color:#fbbf24; padding:2px 8px; border-radius:10px; font-size:11px; font-weight:500;">Flag</span> <span style="opacity:0.7; font-size:12px;">Canadian vendor, ITC likely missing</span></td>
<td style="padding: 6px;"><input style="width:100%; padding:4px 8px; font-size:12px; background:rgba(255,255,255,0.06); border:1px solid rgba(255,255,255,0.15); border-radius:4px; color:inherit;" placeholder="Add your note..."></td>
</tr>
</tbody>
</table>
<!-- Bulk action bar -->
<div style="padding: 10px 12px; background: rgba(255,255,255,0.03); border-top: 1px solid rgba(255,255,255,0.1); display:flex; gap:8px; align-items:center; flex-wrap: wrap;">
<span style="font-size:12px; opacity:0.7; margin-right:4px;">2 selected</span>
<button style="padding:5px 12px; font-size:12px; background:#22c55e; border:none; border-radius:4px; color:white; cursor:pointer; font-weight:500;">&#10003; Apply Recommendations</button>
<button style="padding:5px 12px; font-size:12px; background:rgba(251,191,36,0.2); border:1px solid rgba(251,191,36,0.4); border-radius:4px; color:#fbbf24; cursor:pointer; font-weight:500;">&#9873; Flag Selected</button>
<button style="padding:5px 12px; font-size:12px; background:rgba(96,165,250,0.2); border:1px solid rgba(96,165,250,0.4); border-radius:4px; color:#60a5fa; cursor:pointer; font-weight:500;">&#43; Create Rules</button>
<button style="padding:5px 12px; font-size:12px; background:rgba(255,255,255,0.08); border:1px solid rgba(255,255,255,0.15); border-radius:4px; color:inherit; cursor:pointer;">Dismiss Selected</button>
<div style="flex:1;"></div>
<button style="padding:5px 12px; font-size:12px; background:rgba(139,92,246,0.2); border:1px solid rgba(139,92,246,0.4); border-radius:4px; color:#a78bfa; cursor:pointer; font-weight:500;">&#9997; Submit All Notes to AI</button>
</div>
</div>
</div>
<div style="margin-top: 24px;">
<h3>How it works</h3>
<ul style="font-size: 14px; line-height: 1.8; opacity: 0.85;">
<li><strong>AI Recommendation</strong> column — pre-filled by AI with a colour-coded badge (Dismiss/Flag/Create Rule) + reasoning</li>
<li><strong>Your Input</strong> column — editable text field per row for your notes, corrections, or instructions</li>
<li><strong>Checkboxes</strong> — select rows for bulk actions</li>
<li><strong>Bulk action bar</strong> — Apply Recommendations, Flag, Create Rules, Dismiss, or Submit All Notes back to the AI</li>
<li><strong>"Submit All Notes to AI"</strong> — sends your row-level annotations back into the chat so the AI can learn and act on your feedback</li>
</ul>
</div>

View File

@@ -0,0 +1,95 @@
<h2>How should AI report tables become interactive?</h2>
<p class="subtitle">Looking at the "Missing ITC Bills" report — you want to annotate rows with your input. Which approach feels right?</p>
<div class="options">
<div class="option" data-choice="a" onclick="toggleSelect(this)">
<div class="letter">A</div>
<div class="content">
<h3>Inline Action Column</h3>
<p>Every table the AI generates gets an extra column at the right with a <strong>text input + action dropdown</strong> per row. You type your note (e.g., "Exempt - no HST required") and pick an action (Dismiss, Flag, Create Rule, Ask AI). The AI sees your annotations and can act on them.</p>
<div style="margin-top: 12px; padding: 12px; background: rgba(255,255,255,0.05); border-radius: 6px; font-size: 13px; font-family: monospace;">
<table style="width:100%; border-collapse: collapse; font-size: 12px;">
<tr style="border-bottom: 1px solid rgba(255,255,255,0.15);">
<th style="padding: 6px; text-align:left;">Vendor</th>
<th style="padding: 6px; text-align:left;">Amount</th>
<th style="padding: 6px; text-align:left;">Risk</th>
<th style="padding: 6px; text-align:left; color: #fbbf24;">Your Input</th>
</tr>
<tr style="border-bottom: 1px solid rgba(255,255,255,0.1);">
<td style="padding: 6px;">Ki Mobility LLC</td>
<td style="padding: 6px;">-$14,917.95</td>
<td style="padding: 6px;">HST ITC?</td>
<td style="padding: 6px;"><input style="width:100px; padding:2px 4px; font-size:11px; background:rgba(255,255,255,0.1); border:1px solid rgba(255,255,255,0.2); border-radius:3px; color:inherit;" placeholder="Your note..." value="US vendor, no HST"><select style="margin-left:4px; padding:2px; font-size:11px; background:rgba(255,255,255,0.1); border:1px solid rgba(255,255,255,0.2); border-radius:3px; color:inherit;"><option>Dismiss</option><option>Flag</option><option>Rule</option></select></td>
</tr>
<tr>
<td style="padding: 6px;">Savaria Concord</td>
<td style="padding: 6px;">-$10,173.00</td>
<td style="padding: 6px;">HST ITC?</td>
<td style="padding: 6px;"><input style="width:100px; padding:2px 4px; font-size:11px; background:rgba(255,255,255,0.1); border:1px solid rgba(255,255,255,0.2); border-radius:3px; color:inherit;" placeholder="Your note..."><select style="margin-left:4px; padding:2px; font-size:11px; background:rgba(255,255,255,0.1); border:1px solid rgba(255,255,255,0.2); border-radius:3px; color:inherit;"><option>Dismiss</option><option>Flag</option><option>Rule</option></select></td>
</tr>
</table>
</div>
</div>
</div>
<div class="option" data-choice="b" onclick="toggleSelect(this)">
<div class="letter">B</div>
<div class="content">
<h3>Row-Click Expandable Panel</h3>
<p>Tables render normally, but <strong>clicking a row expands a detail panel</strong> below it with: the AI's recommendation, a text input for your notes, and action buttons (Approve, Dismiss, Create Rule, Ask AI about this). Keeps the table clean, shows detail on demand.</p>
<div style="margin-top: 12px; padding: 12px; background: rgba(255,255,255,0.05); border-radius: 6px; font-size: 13px;">
<div style="padding: 6px; border-bottom: 1px solid rgba(255,255,255,0.1); font-size: 12px;">Ki Mobility LLC &nbsp; -$14,917.95 &nbsp; <span style="color:#fbbf24">HST ITC?</span> &nbsp; <span style="font-size:10px; opacity:0.6">Click to expand &#9660;</span></div>
<div style="padding: 10px; margin: 4px 0; background: rgba(251,191,36,0.08); border-left: 3px solid #fbbf24; border-radius: 4px; font-size: 12px;">
<div><strong style="color:#fbbf24;">AI Recommendation:</strong> US-based vendor. No HST should apply. Consider dismissing or creating a rule for all Ki Mobility bills.</div>
<div style="margin-top: 8px; display:flex; gap:6px; align-items:center;">
<input style="flex:1; padding:4px 6px; font-size:11px; background:rgba(255,255,255,0.1); border:1px solid rgba(255,255,255,0.2); border-radius:3px; color:inherit;" placeholder="Your note or correction...">
<button style="padding:3px 8px; font-size:11px; background:#22c55e; border:none; border-radius:3px; color:white; cursor:pointer;">Dismiss</button>
<button style="padding:3px 8px; font-size:11px; background:#3b82f6; border:none; border-radius:3px; color:white; cursor:pointer;">Create Rule</button>
<button style="padding:3px 8px; font-size:11px; background:rgba(255,255,255,0.15); border:1px solid rgba(255,255,255,0.2); border-radius:3px; color:inherit; cursor:pointer;">Ask AI</button>
</div>
</div>
<div style="padding: 6px; border-bottom: 1px solid rgba(255,255,255,0.1); font-size: 12px; opacity: 0.7;">Savaria Concord &nbsp; -$10,173.00 &nbsp; <span style="color:#fbbf24">HST ITC?</span></div>
</div>
</div>
</div>
<div class="option" data-choice="c" onclick="toggleSelect(this)">
<div class="letter">C</div>
<div class="content">
<h3>AI Recommendation Column + Bulk Actions</h3>
<p>The AI proactively fills a <strong>"Recommendation" column</strong> with its suggested action per row (e.g., "Dismiss - US vendor", "Flag - check with accountant"). You can <strong>edit the recommendation</strong>, check rows, and use bulk action buttons (Apply Selected, Dismiss Selected, Create Rules). The AI pre-fills its best guess so you only edit what's wrong.</p>
<div style="margin-top: 12px; padding: 12px; background: rgba(255,255,255,0.05); border-radius: 6px; font-size: 13px; font-family: monospace;">
<table style="width:100%; border-collapse: collapse; font-size: 12px;">
<tr style="border-bottom: 1px solid rgba(255,255,255,0.15);">
<th style="padding: 6px; width:20px;"><input type="checkbox" checked></th>
<th style="padding: 6px; text-align:left;">Vendor</th>
<th style="padding: 6px; text-align:left;">Amount</th>
<th style="padding: 6px; text-align:left; color: #22c55e;">AI Recommendation</th>
</tr>
<tr style="border-bottom: 1px solid rgba(255,255,255,0.1);">
<td style="padding: 6px;"><input type="checkbox" checked></td>
<td style="padding: 6px;">Ki Mobility LLC</td>
<td style="padding: 6px;">-$14,917.95</td>
<td style="padding: 6px; color:#22c55e;">Dismiss - US vendor, no HST</td>
</tr>
<tr style="border-bottom: 1px solid rgba(255,255,255,0.1);">
<td style="padding: 6px;"><input type="checkbox"></td>
<td style="padding: 6px;">Savaria Concord</td>
<td style="padding: 6px;">-$10,173.00</td>
<td style="padding: 6px; color:#fbbf24;">Flag - Canadian vendor, ITC likely missing</td>
</tr>
<tr>
<td style="padding: 6px;"><input type="checkbox"></td>
<td style="padding: 6px;">Joerns Healthcare</td>
<td style="padding: 6px;">-$2,392.80</td>
<td style="padding: 6px; color:#fbbf24;">Flag - check fiscal position</td>
</tr>
</table>
<div style="margin-top:8px; display:flex; gap:6px;">
<button style="padding:4px 10px; font-size:11px; background:#22c55e; border:none; border-radius:3px; color:white;">Apply Selected</button>
<button style="padding:4px 10px; font-size:11px; background:rgba(255,255,255,0.15); border:1px solid rgba(255,255,255,0.2); border-radius:3px; color:inherit;">Create Rules from Selected</button>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,3 @@
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh">
<p class="subtitle">Continuing in terminal...</p>
</div>

View File

@@ -6,19 +6,20 @@ An AI agent (Claude/GPT with tool-calling) embedded in Odoo 19 Enterprise Accoun
## Architecture
```
fusion_accounting/
├── models/ 7 models (6 new + 1 inherit on account.move)
├── models/ 7 files (5 new models + 2 inherits: account.move, res.config.settings)
├── services/
│ ├── agent.py AI orchestrator (prompt assembly, tool dispatch loop)
│ ├── adapters/ Claude + OpenAI adapters with native tool-calling
│ ├── tools/ 85 tool functions across 11 domain files
│ ├── tools/ 93 tool functions across 11 domain files
│ ├── prompts/ System prompt builder + 12 domain-specific prompts
│ └── scoring.py Confidence scoring + tier promotion logic
├── controllers/ 8 JSON-RPC endpoints
├── controllers/ 10 JSON-RPC endpoints
├── wizards/ Rule creation wizard
├── static/src/ OWL dashboard + chat panel + approval cards
├── views/ List/form/search views, menus, settings
├── security/ 3 groups (User/Manager/Admin), record rules, ACLs
├── data/ 82 tool definitions, 2 default rules, 2 crons
├── data/ 88 tool definitions, 2 default rules, 2 crons, 1 sequence
├── tests/ API integration tests
└── report/ Audit report QWeb template
```
@@ -26,24 +27,62 @@ fusion_accounting/
### AI Provider Integration
- Uses `fusion.api.service` (from fusion_api module) for API key resolution with fallback to `ir.config_parameter` — NO hard dependency on fusion_api
- Claude adapter: native `tool_use` blocks, extended thinking enabled (8K budget) for 4.5+ models
- Claude adapter: native `tool_use` blocks, extended thinking enabled (8K budget) for all Claude 4.x models
- OpenAI adapter: Chat Completions API with o-series reasoning model support (`developer` role, `max_completion_tokens`, `reasoning_effort`)
- API keys stored in `ir.config_parameter` with `fusion_accounting.` prefix
- API key fields in Settings use `password="True"` widget — labels include "(Fusion AI)" suffix to avoid conflicts with other modules' key fields
- **Provider pinning**: Sessions remember which provider was used. If the global provider changes mid-session, the session continues with its original provider to prevent cross-adapter message format contamination.
### Tool Tiering
- **Tier 1** (Free): Read-only, execute immediately — 60+ tools
- **Tier 2** (Auto-approved): Low-risk writes, logged — ~10 tools
- **Tier 3** (Requires approval): Financial writes, user must approve — ~15 tools
- Auto-promotion: Tier 3 → Tier 2 at 95% accuracy over 30+ decisions (atomic SQL counters)
- Auto-promotion: Tier 3 → Tier 2 at 95% accuracy over 30+ decisions (atomic SQL counters on `fusion.accounting.rule._record_decision`)
- Tool descriptions include tier labels (e.g., `[Tier 3: Requires user approval]`) so the AI knows which tools need approval
- When a Tier 3 tool is encountered during the chat loop, the loop short-circuits: a final text response is forced so the AI can present approval cards to the user
### Tier 3 Approval Flow
- When a Tier 3 action is approved/rejected, the session's `message_ids_json` is updated to replace the `pending_approval` placeholder with the actual tool result — this prevents dangling `tool_use` blocks that would cause API errors on the next chat turn
- After approval, `scoring.check_promotions()` is called to check if any rules should be promoted
### Menu Location
- **Parent**: `accountant.menu_accounting` (NOT `account.menu_finance` — that's Community Edition only)
- Enterprise uses `accountant.menu_accounting` (ID 1663) as the visible menu root
- `account.menu_finance` (ID 180) exists but has NO visible children in Enterprise — it's the Community root
### Session Persistence
- Chat sessions stored in `fusion.accounting.session` with `message_ids_json` (JSON text field)
- On page load, chat panel calls `/session/latest` to restore the most recent active session
- Empty assistant messages (tool-call-only responses with no text) are filtered out by the controller
- "New Chat" button closes current session and creates a fresh one
- Session name (e.g., FAS/2026/00001) shown in the chat header
- **Session ownership**: Controllers verify the current user owns the session (managers can access any session)
### Rich Text Chat Output
- AI responses are rendered as rich HTML, not plain text
- Markdown-to-HTML conversion happens client-side in `chat_panel.js` via `mdToHtml()` function
- HTML is injected via `innerHTML` on `onMounted` + `onPatched` (NOT via OWL's `markup()` / `t-out` — those proved unreliable in Odoo 19)
- The `_renderRichMessages()` method finds `.fusion_rich_slot[data-idx]` divs and sets their innerHTML
- Supported: headers (# through #####), **bold**, *italic*, `code`, tables, bullet/numbered lists, horizontal rules, [links](url)
- System prompt instructs AI to use markdown formatting and include Odoo record links like `[INV/2026/00123](/odoo/accounting/123)`
### Interactive Tables (fusion-table)
- AI can return `fusion-table` fenced code blocks instead of Markdown tables for actionable results
- `mdToHtml()` detects these blocks, extracts JSON, and renders `FusionInteractiveTable` OWL components via `mount()`
- **Interactive mode**: checkbox column + data columns + AI Recommendation column (colour-coded badge) + Your Input column (text field per row) + bottom bulk action bar
- **Read-only mode**: styled table, no inputs/actions
- Actions: Apply Recommendations, Flag Selected, Create Rules, Dismiss Selected, Submit All Notes to AI
- Action button clicks format a `[TABLE_ACTION]` structured message and send it back through the chat endpoint
- The AI decides per-response whether to use interactive or Markdown tables based on whether the data is actionable
- Used for: `find_missing_itc_bills`, `find_duplicate_bills`, `get_overdue_invoices`, `find_draft_entries`, `get_unreconciled_bank_lines`, etc.
- NOT used for: `get_profit_loss`, `get_balance_sheet`, `get_trial_balance` (informational, read-only)
- All styles use Odoo CSS variables — dark/light mode handled automatically
### Dashboard Layout
- Health cards row at top (6 cards: Bank Recon, AR, AP, HST, Audit Score, Month-End)
- Below: side-by-side layout — "Needs Attention" panel (flex-grow) + Chat panel (720px fixed width)
- Chat panel is 720px (80% larger than original 400px design)
- Dashboard endpoint returns `needs_attention` and `recent_activity` JSON arrays alongside health card metrics
## Odoo 19 Gotchas (Learned the Hard Way)
@@ -57,6 +96,12 @@ fusion_accounting/
- Components registered as client actions receive props: `action`, `actionId`, `updateActionState`, `className`
- Must use `static props = ["*"]` (accept any) — NOT `static props = []` (accept none)
### OWL Rich HTML Rendering
- `markup()` from `@odoo/owl` + `t-out` is UNRELIABLE in Odoo 19 for rendering HTML in OWL components
- Use `onMounted` + `onPatched` hooks to find DOM elements and set `innerHTML` directly
- Pattern: render a placeholder `<div class="slot" t-att-data-idx="index"/>`, then in the hook find it and set `.innerHTML`
- Always use BOTH `onMounted` AND `onPatched``onPatched` alone misses the first render
### Cron Safe Eval
- NO `import` statements (forbidden opcode `IMPORT_NAME`)
- `datetime` module available as `datetime` (use `datetime.datetime.now()`, `datetime.timedelta()`)
@@ -65,14 +110,20 @@ fusion_accounting/
### read_group Deprecated
- `read_group()` is deprecated in Odoo 19 — use `_read_group()` instead
- Still works but throws DeprecationWarning
- Dashboard `accounting_dashboard.py` still uses `read_group()` — migrate to `_read_group()` when the new API is stable
### Config Parameter Values
- When changing a Selection field's options, the stored DB value in `ir_config_parameter` must match one of the new options or Settings page will crash with `ValueError: Wrong value`
- Fix: UPDATE the value in DB after changing selection options
- Fix: UPDATE the value in DB after changing selection options:
```sql
UPDATE ir_config_parameter SET value = 'new_value' WHERE key = 'fusion_accounting.field_name';
```
### Field Label Conflicts
- Odoo warns if two fields on the same model have the same `string` label
- Our `display_name_field` conflicted with built-in `display_name` — renamed string to "Tool Label"
- API key fields use "(Fusion AI)" suffix to avoid label conflicts with other modules
- Tool model uses `domain` (not `domain_name`) and `parameters_schema` (not `parameters`) as field names
### Group Assignment
- `implied_ids` on groups only applies to NEWLY added users, not existing ones
@@ -85,28 +136,36 @@ fusion_accounting/
ON CONFLICT DO NOTHING;
```
### TransientModel in Controllers
- Use `.new({...})` NOT `.create({...})` for TransientModels in controller endpoints
- `.create()` writes a DB row on every request; `.new()` is in-memory only
- Dashboard controller uses `.new()` to compute health metrics without DB writes
## Server Details
- **Server**: odoo-westin (192.168.1.40, SSH via `ssh odoo-westin`)
- **Container**: odoo-dev-app (Odoo), odoo-dev-db (PostgreSQL)
- **Database**: westin-v19
- **Module path**: `/mnt/extra-addons/fusion_accounting/`
- **Python deps**: anthropic (v0.88.0), openai (v2.30.0) — installed with `--break-system-packages`
- **URL**: erp.westinhealthcare.ca
## Deployment Commands
```bash
# Deploy module to server
# Full deploy cycle (clean + copy + upgrade + restart)
ssh odoo-westin "docker exec -u 0 odoo-dev-app rm -rf /mnt/extra-addons/fusion_accounting"
scp -r "K:\Github\Odoo-Modules\fusion_accounting" odoo-westin:/tmp/fusion_accounting
ssh odoo-westin "docker cp /tmp/fusion_accounting odoo-dev-app:/mnt/extra-addons/fusion_accounting && rm -rf /tmp/fusion_accounting"
# Upgrade module (use alt port to avoid conflict with running instance)
ssh odoo-westin "docker exec odoo-dev-app odoo -d westin-v19 -u fusion_accounting --stop-after-init --http-port=8099 -c /etc/odoo/odoo.conf"
# Restart container
ssh odoo-westin "docker restart odoo-dev-app"
# Check logs
ssh odoo-westin "docker logs odoo-dev-app --tail 100"
# Quick DB queries
ssh odoo-westin "docker exec odoo-dev-db psql -U odoo -d westin-v19 -t -c \"<SQL>\""
# Check module state
ssh odoo-westin "docker exec odoo-dev-db psql -U odoo -d westin-v19 -t -c \"SELECT name, state, latest_version FROM ir_module_module WHERE name = 'fusion_accounting';\""
```
## Security Groups
@@ -118,19 +177,37 @@ ssh odoo-westin "docker logs odoo-dev-app --tail 100"
Auto-assigned: `account.group_account_user` → User, `account.group_account_manager` → Admin
## Models
| Model | Type | Purpose |
## Controller Endpoints
| Route | Auth | Purpose |
|---|---|---|
| `fusion.accounting.session` | Model | Chat sessions with message JSON storage |
| `fusion.accounting.match.history` | Model | Every AI tool call + decision (approved/rejected/pending) |
| `fusion.accounting.rule` | Model | Fusion Rules engine with versioning and auto-promotion |
| `fusion.accounting.tool` | Model | Tool registry (82 tools seeded from XML) |
| `fusion.accounting.dashboard` | TransientModel | Computed health metrics (use `.new()` not `.create()`) |
| `fusion.accounting.agent` | AbstractModel | AI orchestrator |
| `fusion.accounting.adapter.claude` | AbstractModel | Claude tool-calling adapter |
| `fusion.accounting.adapter.openai` | AbstractModel | OpenAI tool-calling adapter |
| `fusion.accounting.scoring` | AbstractModel | Confidence scoring |
| `account.move` (inherit) | Model | Post-action audit hook |
| `/fusion_accounting/session/create` | user | Create new chat session |
| `/fusion_accounting/session/close` | user (ownership check) | Close active session |
| `/fusion_accounting/session/latest` | user (own sessions only) | Load most recent active session + messages |
| `/fusion_accounting/session/history` | user (ownership check, managers see all) | Load specific session messages |
| `/fusion_accounting/chat` | user (ownership check) | Send message, get AI response |
| `/fusion_accounting/approve` | user + manager group check | Approve single Tier 3 action |
| `/fusion_accounting/reject` | user + manager group check | Reject single Tier 3 action |
| `/fusion_accounting/approve_all` | user + manager group check | Batch approve multiple actions |
| `/fusion_accounting/reject_all` | user + manager group check | Batch reject multiple actions |
| `/fusion_accounting/dashboard/data` | user | Get dashboard health card metrics + needs_attention + recent_activity |
Note: Approve/reject endpoints use `auth='user'` at the decorator level with an imperative `has_group()` check inside the handler (Odoo has no built-in `auth='manager'`).
## Models
| Model | Type | Location | Purpose |
|---|---|---|---|
| `fusion.accounting.session` | Model | models/ | Chat sessions with message JSON storage |
| `fusion.accounting.match.history` | Model | models/ | Every AI tool call + decision (approved/rejected/pending) |
| `fusion.accounting.rule` | Model | models/ | Fusion Rules engine with versioning and auto-promotion |
| `fusion.accounting.tool` | Model | models/ | Tool registry (82 tools seeded from XML) |
| `fusion.accounting.dashboard` | TransientModel | models/ | Computed health metrics (use `.new()` not `.create()`) |
| `res.config.settings` (inherit) | TransientModel | models/ | Settings page (API keys, thresholds, toggles) |
| `account.move` (inherit) | Model | models/ | Post-action audit hook |
| `fusion.accounting.agent` | AbstractModel | services/ | AI orchestrator |
| `fusion.accounting.adapter.claude` | AbstractModel | services/ | Claude tool-calling adapter |
| `fusion.accounting.adapter.openai` | AbstractModel | services/ | OpenAI tool-calling adapter |
| `fusion.accounting.scoring` | AbstractModel | services/ | Confidence scoring |
| `fusion.accounting.rule.wizard` | TransientModel | wizards/ | Quick-create rule from chat suggestion |
## AI Models Available
**Claude** (default: claude-sonnet-4-6):
@@ -146,9 +223,26 @@ Auto-assigned: `account.group_account_user` → User, `account.group_account_man
- NO hardcoded colours — use CSS variables (`var(--o-border-color)`, `var(--bs-body-color-rgb)`) and Bootstrap utility classes
- Must work in both light and dark mode
- Box shadows: use `rgba(var(--bs-body-color-rgb), 0.1)` not `rgba(0,0,0,0.1)`
- AI messages use `var(--o-view-background-color)` background + `var(--o-border-color)` border
- Links use `var(--o-action-color)` for theme awareness
### HST Filing Workflow (4-Phase AI-Driven)
- Phase 1: AI runs all HST reports (tax report, missing ITCs, compliance audit, HST balance)
- Phase 2: AI sweeps ALL bank accounts for unreconciled expense payments
- Phase 3: Per-line processing — check for existing bills, check history for coding patterns, ask about HST, create bills, register payments
- Phase 4: Re-run reports to verify updated HST position
- New tools added: `search_partners` (Tier 1), `find_similar_bank_lines` (Tier 1), `get_bank_line_details` (Tier 1), `create_vendor_bill` (Tier 3), `register_bill_payment` (Tier 3), `create_expense_entry` (Tier 3)
- Two paths for recording expenses: (a) formal vendor bill + payment, or (b) direct GL entry in MISC journal with optional HST split
- The `create_expense_entry` tool posts directly to the Miscellaneous Operations journal — debit expense + debit HST ITC (2006) + credit bank
- Domain prompt (`hst_management` in domain_prompts.py) includes bank journal IDs and the full 4-phase workflow instructions
## Known Issues / Future Work
- `read_group()` deprecation warnings — migrate to `_read_group()` when format is documented
- `verify_source_deductions`, `generate_t4`, `generate_roe` are stubs pointing to fusion_payroll (by design — Phase 2)
- `account.return` model used in HST tools may not exist in all Odoo 19 setups — needs try/except guard
- `read_group()` deprecation warnings in `accounting_dashboard.py` — migrate to `_read_group()` when the new API format is stable
- `generate_t4`, `generate_roe` are stubs pointing to fusion_payroll (by design — Phase 2)
- `get_payroll_schedule`, `verify_source_deductions`, `verify_payroll_deductions` are stubs (Phase 2 — fusion_payroll integration)
- `answer_financial_question` is a stub (returns message to use other tools instead)
- Batch approval "Approve All" / "Reject All" buttons are in the chat panel but not yet in the match history list view
- "Needs Attention" panel shows placeholder text in the dashboard — the data is computed and returned by the API but the frontend rendering needs to be connected
- Consider switching OpenAI adapter from Chat Completions API to Responses API for better tool handling with newer models
- `o1` model does not support tool calling — no guard in place (o3/o4-mini do support it)
- Multi-company record rule missing on `fusion.accounting.session` — add if multi-company usage is needed

View File

@@ -42,6 +42,8 @@ Built by Nexa Systems Inc.
'views/match_history_views.xml',
'views/rule_views.xml',
'views/dashboard_views.xml',
'views/vendor_tax_profile_views.xml',
'views/recurring_pattern_views.xml',
'views/menus.xml',
# Wizards
'wizards/rule_wizard.xml',

View File

@@ -9,6 +9,14 @@ _logger = logging.getLogger(__name__)
class FusionAccountingChatController(http.Controller):
def _check_session_ownership(self, session):
"""S1-S3: Verify the current user owns the session."""
if session.user_id.id != request.env.user.id:
# Allow managers to access any session
if not request.env.user.has_group('fusion_accounting.group_fusion_accounting_manager'):
return {'error': 'Access denied: you do not own this session'}
return None
@http.route('/fusion_accounting/session/create', type='jsonrpc', auth='user')
def create_session(self, context_domain=None, **kwargs):
session = request.env['fusion.accounting.session'].create({
@@ -21,7 +29,13 @@ class FusionAccountingChatController(http.Controller):
@http.route('/fusion_accounting/session/close', type='jsonrpc', auth='user')
def close_session(self, session_id, **kwargs):
session = request.env['fusion.accounting.session'].browse(int(session_id))
if session.exists() and session.state == 'active':
if not session.exists():
return {'status': 'closed'}
# S2: Ownership check
error = self._check_session_ownership(session)
if error:
return error
if session.state == 'active':
session.action_close_session()
return {'status': 'closed'}
@@ -29,6 +43,12 @@ class FusionAccountingChatController(http.Controller):
def chat(self, session_id, message, context=None, **kwargs):
if not message:
return {'error': 'Message is required'}
# S3: Ownership check
session = request.env['fusion.accounting.session'].browse(int(session_id))
if session.exists():
error = self._check_session_ownership(session)
if error:
return error
agent = request.env['fusion.accounting.agent']
result = agent.chat(int(session_id), message, context=context)
return result
@@ -51,17 +71,35 @@ class FusionAccountingChatController(http.Controller):
@http.route('/fusion_accounting/dashboard/data', type='jsonrpc', auth='user')
def dashboard_data(self, **kwargs):
dashboard = request.env['fusion.accounting.dashboard'].new({
'company_id': request.env.company.id,
})
return {
'bank_recon': {'count': dashboard.bank_recon_count, 'amount': dashboard.bank_recon_amount},
'ar': {'total': dashboard.ar_total, 'overdue_count': dashboard.ar_overdue_count},
'ap': {'total': dashboard.ap_total, 'due_this_week': dashboard.ap_due_this_week},
'hst': {'balance': dashboard.hst_balance},
'audit': {'score': dashboard.audit_score, 'flags': dashboard.audit_flag_count},
'month_end': {'status': dashboard.month_end_status, 'open_items': dashboard.month_end_open_items},
}
# E2: Wrap in try/except so dashboard doesn't return 500
try:
dashboard = request.env['fusion.accounting.dashboard'].new({
'company_id': request.env.company.id,
})
return {
'bank_recon': {'count': dashboard.bank_recon_count, 'amount': dashboard.bank_recon_amount},
'ar': {'total': dashboard.ar_total, 'overdue_count': dashboard.ar_overdue_count},
'ap': {'total': dashboard.ap_total, 'due_this_week': dashboard.ap_due_this_week},
'hst': {'balance': dashboard.hst_balance},
'audit': {'score': dashboard.audit_score, 'flags': dashboard.audit_flag_count},
'month_end': {'status': dashboard.month_end_status, 'open_items': dashboard.month_end_open_items},
# E1: Include needs_attention and recent_activity
'needs_attention': json.loads(dashboard.needs_attention_json or '[]'),
'recent_activity': json.loads(dashboard.recent_activity_json or '[]'),
}
except Exception as e:
_logger.exception("Dashboard data computation failed")
return {
'error': 'Dashboard data could not be computed',
'bank_recon': {'count': 0, 'amount': 0},
'ar': {'total': 0, 'overdue_count': 0},
'ap': {'total': 0, 'due_this_week': 0},
'hst': {'balance': 0},
'audit': {'score': 0, 'flags': 0},
'month_end': {'status': 'Unknown', 'open_items': 0},
'needs_attention': [],
'recent_activity': [],
}
@http.route('/fusion_accounting/approve_all', type='jsonrpc', auth='user')
def approve_all(self, match_history_ids, **kwargs):
@@ -74,7 +112,9 @@ class FusionAccountingChatController(http.Controller):
result = agent.approve_action(int(mid))
results.append({'id': mid, 'status': 'approved', 'result': result})
except Exception as e:
results.append({'id': mid, 'status': 'error', 'error': str(e)})
# S4: Sanitize exception — log full error, return generic message
_logger.exception("Error approving match history %s", mid)
results.append({'id': mid, 'status': 'error', 'error': 'Action could not be approved. Check server logs for details.'})
return {'results': results}
@http.route('/fusion_accounting/reject_all', type='jsonrpc', auth='user')
@@ -86,19 +126,58 @@ class FusionAccountingChatController(http.Controller):
for mid in match_history_ids:
try:
result = agent.reject_action(int(mid), reason)
results.append({'id': mid, 'status': 'rejected'})
# E3: Consistent return shape with approve_all
results.append({'id': mid, 'status': 'rejected', 'result': result})
except Exception as e:
results.append({'id': mid, 'status': 'error', 'error': str(e)})
# S4: Sanitize exception
_logger.exception("Error rejecting match history %s", mid)
results.append({'id': mid, 'status': 'error', 'error': 'Action could not be rejected. Check server logs for details.'})
return {'results': results}
@http.route('/fusion_accounting/session/list', type='jsonrpc', auth='user')
def session_list(self, limit=20, **kwargs):
"""List recent sessions for the session picker dropdown."""
sessions = request.env['fusion.accounting.session'].search([
('user_id', '=', request.env.user.id),
], order='write_date desc', limit=int(limit))
return {
'sessions': [{
'id': s.id,
'name': s.name,
'state': s.state,
'date': s.write_date.isoformat() if s.write_date else '',
'message_count': len(json.loads(s.message_ids_json or '[]')),
'ai_model': s.ai_model or '',
} for s in sessions],
}
@http.route('/fusion_accounting/session/latest', type='jsonrpc', auth='user')
def session_latest(self, **kwargs):
session = request.env['fusion.accounting.session'].search([
# Find the most recent active session that has messages first,
# fall back to any active session (including empty ones)
sessions = request.env['fusion.accounting.session'].search([
('user_id', '=', request.env.user.id),
('state', '=', 'active'),
], limit=1, order='create_date desc')
if not session:
], order='write_date desc', limit=10)
if not sessions:
return {'session_id': None, 'messages': [], 'name': None}
# Prefer a session with actual messages
session = None
for s in sessions:
msg_json = s.message_ids_json or '[]'
if msg_json != '[]' and len(msg_json) > 5:
session = s
break
# If no session has messages, use the newest one
if not session:
session = sessions[0]
# Clean up empty stale sessions (created but never used)
for s in sessions:
if s.id != session.id and (s.message_ids_json or '[]') == '[]':
s.write({'state': 'closed'})
messages = json.loads(session.message_ids_json or '[]')
display_messages = []
for msg in messages:
@@ -119,6 +198,10 @@ class FusionAccountingChatController(http.Controller):
session = request.env['fusion.accounting.session'].browse(int(session_id))
if not session.exists():
return {'error': 'Session not found'}
# S1: Ownership check
error = self._check_session_ownership(session)
if error:
return error
return {
'messages': json.loads(session.message_ids_json or '[]'),
'session_id': session.id,

View File

@@ -36,4 +36,37 @@ for rule in model.search([('active', '=', True), ('approval_tier', '=', 'needs_a
<field name="interval_type">days</field>
<field name="active">True</field>
</record>
<!-- Weekly recurring pattern rebuild -->
<record id="cron_fusion_recurring_patterns" model="ir.cron">
<field name="name">Fusion AI: Rebuild Recurring Patterns</field>
<field name="model_id" ref="model_fusion_recurring_pattern"/>
<field name="state">code</field>
<field name="code">model._rebuild_all_patterns(min_occurrences=3)</field>
<field name="interval_number">7</field>
<field name="interval_type">days</field>
<field name="active">True</field>
</record>
<!-- Daily auto-reconcile inter-account transfers (CC payments) -->
<record id="cron_fusion_transfer_reconcile" model="ir.cron">
<field name="name">Fusion AI: Auto-Reconcile Inter-Account Transfers</field>
<field name="model_id" ref="model_fusion_accounting_agent"/>
<field name="state">code</field>
<field name="code">model._cron_reconcile_transfers()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="active">True</field>
</record>
<!-- Weekly vendor tax profile rebuild -->
<record id="cron_fusion_vendor_profiles" model="ir.cron">
<field name="name">Fusion AI: Rebuild Vendor Tax Profiles</field>
<field name="model_id" ref="model_fusion_vendor_tax_profile"/>
<field name="state">code</field>
<field name="code">model._rebuild_all_profiles(min_bills=3)</field>
<field name="interval_number">7</field>
<field name="interval_type">days</field>
<field name="active">True</field>
</record>
</odoo>

View File

@@ -65,7 +65,7 @@
<record id="tool_sum_payments_by_date" model="fusion.accounting.tool">
<field name="name">sum_payments_by_date</field>
<field name="display_name_field">Sum Payments by Date</field>
<field name="description">Sum payment journal items for a date range, useful for matching card batch deposits.</field>
<field name="description">Sum payment journal items for a date range. IMPORTANT: You MUST pass journal_ids to filter to specific journals (e.g., the card/POS journal). Without journal_ids, returns totals across ALL company journals which will be misleadingly large. Use this to verify card batch deposit amounts against the card payment journal for the prior business day.</field>
<field name="domain">bank_reconciliation</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}, "journal_ids": {"type": "array", "items": {"type": "integer"}}}, "required": ["date_from", "date_to"]}</field>
@@ -697,4 +697,78 @@
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}}</field>
</record>
<!-- HST Filing Workflow Tools (added 2026-04-03) -->
<record id="tool_search_partners" model="fusion.accounting.tool">
<field name="name">search_partners</field>
<field name="display_name_field">Search Partners</field>
<field name="description">Search for vendors/contacts by name keyword. Use this to resolve bank line descriptions (e.g., "AMAZON") to the correct Odoo partner record before creating bills. Pass supplier_only=true to filter to vendors only.</field>
<field name="domain">accounts_payable</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {"keyword": {"type": "string", "description": "Name keyword to search (min 2 chars)"}, "supplier_only": {"type": "boolean", "description": "Only return suppliers/vendors"}, "limit": {"type": "integer"}}, "required": ["keyword"]}</field>
</record>
<record id="tool_find_similar_bank_lines" model="fusion.accounting.tool">
<field name="name">find_similar_bank_lines</field>
<field name="display_name_field">Find Similar Bank Lines</field>
<field name="description">Search past RECONCILED bank lines with similar payment_ref descriptions. Returns the expense account, tax treatment, and partner used for each historical match. Use this to check how similar expenses were coded in the past before proposing a new bill.</field>
<field name="domain">accounts_payable</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {"keyword": {"type": "string", "description": "Keyword from payment_ref to search (min 3 chars)"}, "limit": {"type": "integer"}}, "required": ["keyword"]}</field>
</record>
<record id="tool_get_bank_line_details" model="fusion.accounting.tool">
<field name="name">get_bank_line_details</field>
<field name="display_name_field">Get Bank Line Details</field>
<field name="description">Get full details of a single unreconciled bank statement line. Also searches for existing vendor bills matching the amount and date, and suggests a partner based on the payment description. Use this to check if a bill already exists before creating a new one.</field>
<field name="domain">bank_reconciliation</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {"line_id": {"type": "integer", "description": "Bank statement line ID"}}, "required": ["line_id"]}</field>
</record>
<record id="tool_create_vendor_bill" model="fusion.accounting.tool">
<field name="name">create_vendor_bill</field>
<field name="display_name_field">Create Vendor Bill</field>
<field name="description">[Tier 3: Requires user approval] Create a vendor bill (account.move in_invoice) with expense lines and tax. Use after confirming the expense details with the user. Pass post=true to auto-post the bill after creation.</field>
<field name="domain">accounts_payable</field>
<field name="tier">3</field>
<field name="parameters_schema">{"type": "object", "properties": {"partner_id": {"type": "integer", "description": "Vendor partner ID"}, "invoice_date": {"type": "string", "description": "Bill date (YYYY-MM-DD)"}, "lines": {"type": "array", "items": {"type": "object", "properties": {"description": {"type": "string"}, "account_id": {"type": "integer"}, "price_unit": {"type": "number"}, "quantity": {"type": "number"}, "tax_ids": {"type": "array", "items": {"type": "integer"}}}}, "description": "Invoice line items"}, "post": {"type": "boolean", "description": "Auto-post the bill after creation"}}, "required": ["partner_id", "invoice_date", "lines"]}</field>
</record>
<record id="tool_register_bill_payment" model="fusion.accounting.tool">
<field name="name">register_bill_payment</field>
<field name="display_name_field">Register Bill Payment</field>
<field name="description">[Tier 3: Requires user approval] Register a payment on a posted vendor bill from a specific bank journal. Optionally reconcile the payment to a bank statement line. Use after create_vendor_bill to complete the full bill+payment+reconciliation flow.</field>
<field name="domain">accounts_payable</field>
<field name="tier">3</field>
<field name="parameters_schema">{"type": "object", "properties": {"bill_id": {"type": "integer", "description": "Posted bill ID (account.move)"}, "journal_id": {"type": "integer", "description": "Bank journal ID for payment"}, "payment_date": {"type": "string", "description": "Payment date (YYYY-MM-DD)"}, "amount": {"type": "number", "description": "Payment amount (defaults to bill total)"}, "statement_line_id": {"type": "integer", "description": "Bank statement line ID to reconcile with"}}, "required": ["bill_id", "journal_id"]}</field>
</record>
<record id="tool_check_recurring_pattern" model="fusion.accounting.tool">
<field name="name">check_recurring_pattern</field>
<field name="display_name_field">Check Recurring Pattern</field>
<field name="description">Check if a bank line matches a known recurring payment pattern. Returns the historical account coding, HST treatment, partner, and reconciliation model if one exists. ALWAYS call this FIRST for every unreconciled bank line — if a recurring pattern exists, follow its instructions instead of asking the user. Pass line_id to auto-extract ref and amount.</field>
<field name="domain">bank_reconciliation</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {"line_id": {"type": "integer", "description": "Bank statement line ID"}, "payment_ref": {"type": "string", "description": "Payment reference text (auto-extracted if line_id provided)"}, "amount": {"type": "number", "description": "Transaction amount (auto-extracted if line_id provided)"}}, "required": []}</field>
</record>
<record id="tool_match_internal_transfers" model="fusion.accounting.tool">
<field name="name">match_internal_transfers</field>
<field name="display_name_field">Match Internal Transfers</field>
<field name="description">[Tier 3: Requires user approval] Find and match inter-account transfers between two bank journals (e.g., Scotia Current ↔ Scotia Visa). Matches EXACT amounts within 2 days. ONLY matches when there is exactly one candidate — skips ambiguous cases. First call with execute=false to preview pairs, then execute=true to reconcile. Scotia Current=50, Scotia Visa=51, RBC Chequing=53, RBC Visa=28.</field>
<field name="domain">bank_reconciliation</field>
<field name="tier">3</field>
<field name="parameters_schema">{"type": "object", "properties": {"journal_a_id": {"type": "integer", "description": "First bank journal ID"}, "journal_b_id": {"type": "integer", "description": "Second bank journal ID"}, "date_from": {"type": "string"}, "date_to": {"type": "string"}, "max_days_apart": {"type": "integer", "description": "Max days between matching lines (default 2)"}, "execute": {"type": "boolean", "description": "false=preview pairs only, true=actually reconcile"}}, "required": ["journal_a_id", "journal_b_id"]}</field>
</record>
<record id="tool_create_expense_entry" model="fusion.accounting.tool">
<field name="name">create_expense_entry</field>
<field name="display_name_field">Create Direct GL Expense</field>
<field name="description">[Tier 3: Requires user approval] Create a direct GL expense entry in the Miscellaneous Operations journal. Alternative to creating a vendor bill — posts immediately. If has_hst=true, automatically splits the amount into net expense + 13% HST ITC on the 2006 account. Use this for small expenses where a formal vendor bill is not needed.</field>
<field name="domain">hst_management</field>
<field name="tier">3</field>
<field name="parameters_schema">{"type": "object", "properties": {"date": {"type": "string", "description": "Entry date (YYYY-MM-DD)"}, "description": {"type": "string", "description": "Expense description"}, "expense_account_id": {"type": "integer", "description": "GL expense account ID"}, "amount": {"type": "number", "description": "Total amount including HST if applicable"}, "has_hst": {"type": "boolean", "description": "Whether HST (13%) is included in the amount"}, "bank_journal_id": {"type": "integer", "description": "Bank journal for the credit side"}}, "required": ["date", "description", "expense_account_id", "amount"]}</field>
</record>
</odoo>

View File

@@ -5,3 +5,5 @@ from . import accounting_match_history
from . import accounting_rule
from . import accounting_dashboard
from . import account_move_hook
from . import vendor_tax_profile
from . import recurring_pattern

View File

@@ -34,9 +34,14 @@ class AccountMoveAuditHook(models.Model):
for line in move.line_ids:
if not line.account_id:
issues.append(f'Line missing account: {line.name}')
if line.product_id and not line.tax_ids:
if move.move_type in ('out_invoice', 'out_refund', 'in_invoice', 'in_refund'):
issues.append(f'Missing tax on product line: {line.product_id.name}')
# M6: Only flag missing tax when the product has taxes configured
# (avoids false positives for HST-exempt healthcare services)
if (line.product_id and not line.tax_ids
and move.move_type in ('out_invoice', 'out_refund', 'in_invoice', 'in_refund')):
# Check if the product has default taxes configured
product_taxes = line.product_id.taxes_id if move.move_type in ('out_invoice', 'out_refund') else line.product_id.supplier_taxes_id
if product_taxes:
issues.append(f'Missing tax on product line: {line.product_id.name} (product has taxes configured but line has none)')
if not move.line_ids:
issues.append('Entry has no lines')

View File

@@ -153,11 +153,15 @@ class FusionAccountingDashboard(models.TransientModel):
if balance > 0.01:
issues += 1
gaps = self.env['account.move'].search_count([
('state', '=', 'posted'),
('company_id', '=', rec.company_id.id),
('made_sequence_gap', '=', True),
])
# M4: Guard against made_sequence_gap field not existing
try:
gaps = self.env['account.move'].search_count([
('state', '=', 'posted'),
('company_id', '=', rec.company_id.id),
('made_sequence_gap', '=', True),
])
except (ValueError, KeyError):
gaps = 0
issues += gaps
pending_approvals = self.env['fusion.accounting.match.history'].search_count([
@@ -267,7 +271,7 @@ class FusionAccountingDashboard(models.TransientModel):
rec.recent_activity_json = json.dumps([{
'tool': r.tool_name,
'decision': r.decision,
'date': str(r.proposed_at),
'date': r.proposed_at.isoformat() if r.proposed_at else '',
'amount': r.amount,
} for r in recent])

View File

@@ -104,7 +104,7 @@ class FusionAccountingRule(models.Model):
if (rec.approval_tier == 'needs_approval'
and rec.total_uses >= rec.min_sample_size
and rec.confidence_score >= rec.promotion_threshold):
rec.approval_tier = 'auto'
rec.write({'approval_tier': 'auto'})
_logger.info(
"Rule '%s' promoted to auto-approved (confidence=%.2f, uses=%d)",
rec.name, rec.confidence_score, rec.total_uses,
@@ -116,5 +116,6 @@ class FusionAccountingRule(models.Model):
def action_rollback(self):
for rec in self:
if rec.parent_rule_id:
rec.active = False
rec.parent_rule_id.active = True
# M5: Use write() to trigger tracking on tracked fields
rec.write({'active': False})
rec.parent_rule_id.write({'active': True})

View File

@@ -0,0 +1,216 @@
import json
import logging
import re
from collections import defaultdict
from odoo import models, fields, api
_logger = logging.getLogger(__name__)
class FusionRecurringPattern(models.Model):
_name = 'fusion.recurring.pattern'
_description = 'Recurring Bank Transaction Pattern (AI Cache)'
_order = 'occurrences desc'
name = fields.Char(string='Pattern Name', required=True)
ref_keyword = fields.Char(
string='Reference Keyword',
help='The payment_ref substring that identifies this pattern.',
index=True,
)
amount = fields.Float(string='Amount', digits=(12, 2))
amount_is_fixed = fields.Boolean(
string='Fixed Amount',
help='True if the amount is always the same. False if it varies.',
)
journal_id = fields.Many2one('account.journal', string='Bank Journal')
company_id = fields.Many2one(
'res.company', string='Company',
default=lambda self: self.env.company,
)
# How this was coded historically
expense_account_id = fields.Many2one(
'account.account', string='Expense Account',
)
expense_account_code = fields.Char(
related='expense_account_id.code', string='Account Code', store=True,
)
has_hst = fields.Boolean(string='Has HST')
partner_id = fields.Many2one('res.partner', string='Partner')
reconcile_model_id = fields.Many2one(
'account.reconcile.model', string='Reconciliation Model',
help='If this pattern was handled by a reconciliation model.',
)
# AI-readable instructions
action_note = fields.Text(
string='Action (AI-Readable)',
help='Plain English instructions for the AI on how to handle this pattern.',
)
# Stats
occurrences = fields.Integer(string='Times Seen')
first_seen = fields.Date(string='First Seen')
last_seen = fields.Date(string='Last Seen')
last_computed = fields.Datetime(string='Last Computed')
_sql_constraints = [
('pattern_uniq', 'unique(ref_keyword, amount, company_id)',
'One pattern per keyword+amount per company'),
]
def _rebuild_all_patterns(self, min_occurrences=3, since='2024-01-01'):
"""Scan reconciled bank lines for recurring patterns and cache how they were coded."""
_logger.info("Rebuilding recurring patterns (min=%d, since=%s)...", min_occurrences, since)
companies = self.env['res.company'].search([])
total_created = 0
total_updated = 0
for company in companies:
# Step 1: Find recurring ref+amount combinations
self.env.cr.execute("""
SELECT LEFT(bsl.payment_ref, 60) as ref_pattern,
bsl.amount,
count(*) as occurrences,
MIN(am.date) as first_seen,
MAX(am.date) as last_seen,
MODE() WITHIN GROUP (ORDER BY am.journal_id) as journal_id
FROM account_bank_statement_line bsl
JOIN account_move am ON bsl.move_id = am.id
WHERE bsl.is_reconciled = true
AND am.company_id = %s
AND am.date >= %s
AND bsl.payment_ref IS NOT NULL
AND bsl.payment_ref != ''
GROUP BY LEFT(bsl.payment_ref, 60), bsl.amount
HAVING count(*) >= %s
ORDER BY count(*) DESC
LIMIT 200
""", (company.id, since, min_occurrences))
patterns = self.env.cr.dictfetchall()
for pat in patterns:
ref = pat['ref_pattern'].strip()
if not ref or len(ref) < 3:
continue
# Step 2: Trace how one instance was coded
self.env.cr.execute("""
SELECT aml.account_id, aml.tax_line_id, aml.partner_id
FROM account_bank_statement_line bsl
JOIN account_move am ON bsl.move_id = am.id
JOIN account_move_line aml ON aml.move_id = am.id
WHERE bsl.is_reconciled = true
AND bsl.payment_ref ILIKE %s
AND bsl.amount = %s
AND am.company_id = %s
AND aml.display_type NOT IN ('line_section', 'line_note')
AND aml.account_id NOT IN (
SELECT default_account_id FROM account_journal
WHERE company_id = %s AND default_account_id IS NOT NULL
)
ORDER BY bsl.id DESC
LIMIT 5
""", (f'%{ref[:40]}%', pat['amount'], company.id, company.id))
coded_lines = self.env.cr.dictfetchall()
expense_account_id = None
has_hst = False
partner_id = None
for cl in coded_lines:
if cl['tax_line_id']:
has_hst = True
elif cl['account_id'] and not expense_account_id:
acct = self.env['account.account'].browse(cl['account_id'])
if acct.exists() and acct.account_type in (
'expense', 'expense_direct_cost', 'expense_depreciation',
'asset_non_current', 'liability_non_current',
):
expense_account_id = cl['account_id']
if cl['partner_id'] and not partner_id:
partner_id = cl['partner_id']
# Build a friendly name
clean_ref = re.sub(r'[X*]{3,}[\w-]*', '', ref).strip()
clean_ref = re.sub(r'\s{2,}', ' ', clean_ref)[:50]
# Build AI action note
acct_name = ''
if expense_account_id:
acct = self.env['account.account'].browse(expense_account_id)
acct_name = f'{acct.code} {acct.name}' if acct.exists() else ''
partner_name = ''
if partner_id:
p = self.env['res.partner'].browse(partner_id)
partner_name = p.name if p.exists() else ''
action_parts = [f'RECURRING PAYMENT (seen {pat["occurrences"]} times).']
if expense_account_id:
action_parts.append(f'Post to account: {acct_name}.')
if has_hst:
action_parts.append('HST applies — split with 13% ITC.')
else:
action_parts.append('No HST — post without tax.')
if partner_name:
action_parts.append(f'Partner: {partner_name}.')
action_parts.append('Apply same coding as previous occurrences — no user input needed.')
action_note = ' '.join(action_parts)
# Step 3: Check if a reconciliation model already handles this pattern
reco_model_id = None
try:
reco_models = self.env['account.reconcile.model'].search([
('company_id', '=', company.id),
('active', '=', True),
('match_label_param', '!=', False),
])
ref_lower = ref.lower()
for rm in reco_models:
if rm.match_label_param and rm.match_label_param.lower() in ref_lower:
reco_model_id = rm.id
action_parts.append(
f'Reconciliation model "{rm.name}" (ID:{rm.id}) already handles this — '
f'use apply_reconcile_model to apply it automatically.'
)
break
except Exception:
pass
# Upsert
existing = self.search([
('ref_keyword', '=', ref),
('amount', '=', pat['amount']),
('company_id', '=', company.id),
], limit=1)
vals = {
'name': clean_ref,
'ref_keyword': ref,
'amount': pat['amount'],
'amount_is_fixed': True,
'journal_id': pat['journal_id'],
'company_id': company.id,
'expense_account_id': expense_account_id,
'has_hst': has_hst,
'partner_id': partner_id,
'reconcile_model_id': reco_model_id,
'action_note': action_note,
'occurrences': pat['occurrences'],
'first_seen': pat['first_seen'],
'last_seen': pat['last_seen'],
'last_computed': fields.Datetime.now(),
}
if existing:
existing.write(vals)
total_updated += 1
else:
self.create(vals)
total_created += 1
_logger.info("Recurring patterns rebuilt: %d created, %d updated", total_created, total_updated)
return {'created': total_created, 'updated': total_updated}

View File

@@ -0,0 +1,221 @@
import json
import logging
from odoo import models, fields, api
_logger = logging.getLogger(__name__)
class FusionVendorTaxProfile(models.Model):
_name = 'fusion.vendor.tax.profile'
_description = 'Vendor Tax Profile (AI Cache)'
_order = 'total_bills desc'
_rec_name = 'partner_id'
partner_id = fields.Many2one(
'res.partner', string='Vendor', required=True, index=True,
ondelete='cascade',
)
company_id = fields.Many2one(
'res.company', string='Company',
default=lambda self: self.env.company,
)
total_bills = fields.Integer(string='Total Bills')
bills_with_hst = fields.Integer(string='Bills with HST')
bills_zero_rated = fields.Integer(string='Bills Zero-Rated')
avg_tax_pct = fields.Float(string='Avg Tax %', digits=(5, 2))
# Classification
tax_classification = fields.Selection([
('always_hst', 'Always HST (13%)'),
('mostly_hst', 'Mostly HST (>10%)'),
('shipping_only', 'HST on Shipping Only (<2%)'),
('never_hst', 'Never HST (0%)'),
('mixed', 'Mixed / Inconsistent'),
], string='Tax Classification')
# Most common expense account
primary_account_id = fields.Many2one(
'account.account', string='Primary Expense Account',
)
primary_account_code = fields.Char(
related='primary_account_id.code', string='Account Code', store=True,
)
# AI-readable note
tax_note = fields.Text(
string='Tax Note (AI-Readable)',
help='Plain English note the AI reads to understand tax treatment.',
)
# PO-tracked vendor — bills come from purchase orders, never from bank recon
is_po_vendor = fields.Boolean(
string='PO-Tracked Vendor',
help='Bills for this vendor are created from Purchase Orders. '
'Do NOT create bills during bank reconciliation — just match to existing bills.',
)
po_count = fields.Integer(string='Purchase Orders')
# Vendor details for matching
is_foreign = fields.Boolean(string='Foreign Vendor')
vendor_country = fields.Char(string='Vendor Country')
# Timestamps
last_computed = fields.Datetime(string='Last Computed')
_sql_constraints = [
('partner_company_uniq', 'unique(partner_id, company_id)',
'One tax profile per vendor per company'),
]
def _rebuild_all_profiles(self, min_bills=3):
"""Rebuild all vendor tax profiles from posted bill history.
Called by cron or manually."""
_logger.info("Rebuilding vendor tax profiles (min_bills=%d)...", min_bills)
companies = self.env['res.company'].search([])
total_created = 0
total_updated = 0
for company in companies:
# Find all vendors with enough bills
self.env.cr.execute("""
SELECT m.partner_id, count(*) as bill_count,
SUM(CASE WHEN m.amount_tax > 0.01 THEN 1 ELSE 0 END) as with_tax,
SUM(CASE WHEN m.amount_tax <= 0.01 THEN 1 ELSE 0 END) as no_tax,
COALESCE(AVG(CASE WHEN m.amount_untaxed > 0
THEN m.amount_tax / m.amount_untaxed * 100
ELSE 0 END), 0) as avg_tax_pct
FROM account_move m
WHERE m.move_type = 'in_invoice'
AND m.state = 'posted'
AND m.company_id = %s
AND m.partner_id IS NOT NULL
GROUP BY m.partner_id
HAVING count(*) >= %s
""", (company.id, min_bills))
vendor_stats = self.env.cr.dictfetchall()
for vs in vendor_stats:
partner = self.env['res.partner'].browse(vs['partner_id'])
if not partner.exists():
continue
# Classify
avg_pct = round(vs['avg_tax_pct'], 2)
total = vs['bill_count']
with_tax = vs['with_tax']
no_tax = vs['no_tax']
if no_tax == total:
classification = 'never_hst'
note = f'{partner.name} NEVER charges HST. All {total} bills are zero-rated. Do NOT apply HST.'
elif avg_pct >= 12.0:
classification = 'always_hst'
note = f'{partner.name} consistently charges HST at ~{avg_pct}%. Apply HST PURCHASE (13%) to all product lines.'
elif avg_pct >= 10.0:
classification = 'mostly_hst'
note = f'{partner.name} usually charges HST (~{avg_pct}%). {no_tax} of {total} bills had no tax. Apply HST by default but verify zero-rated items.'
elif avg_pct < 2.0 and with_tax > 0:
classification = 'shipping_only'
note = (
f'{partner.name} products are zero-rated (avg tax only {avg_pct}% of subtotal). '
f'HST applies ONLY to shipping/freight charges, NOT to product lines. '
f'When creating a bill, use NO TAX PURCHASE on product lines and HST PURCHASE (13%) only on shipping lines.'
)
else:
classification = 'mixed'
note = (
f'{partner.name} has mixed tax treatment ({with_tax} bills with HST, {no_tax} without, avg {avg_pct}%). '
f'Check each bill individually — some items may be zero-rated while others have HST.'
)
# Find primary expense account
self.env.cr.execute("""
SELECT aml.account_id, count(*) as cnt
FROM account_move_line aml
JOIN account_move m ON aml.move_id = m.id
WHERE m.partner_id = %s
AND m.move_type = 'in_invoice'
AND m.state = 'posted'
AND m.company_id = %s
AND aml.display_type = 'product'
GROUP BY aml.account_id
ORDER BY count(*) DESC
LIMIT 1
""", (vs['partner_id'], company.id))
acct_row = self.env.cr.fetchone()
primary_account_id = acct_row[0] if acct_row else False
# Check if foreign vendor
is_foreign = False
country = ''
if partner.country_id:
country = partner.country_id.name
is_foreign = partner.country_id.code != 'CA'
elif partner.vat and not partner.vat.startswith('CA'):
is_foreign = True
# Only override to never_hst if foreign AND bills actually confirm no tax
# (Don't override if bill data shows they DO charge HST — e.g., Amazon Canada)
if is_foreign and avg_pct < 1.0 and no_tax > with_tax:
classification = 'never_hst'
note = f'{partner.name} is a FOREIGN vendor ({country or "non-Canadian"}) and bills confirm no HST. Do NOT apply any Canadian tax.'
# Check if this is a PO-tracked vendor (has confirmed purchase orders)
is_po_vendor = False
vendor_po_count = 0
try:
self.env.cr.execute("""
SELECT count(*) FROM purchase_order
WHERE partner_id = %s AND state IN ('purchase', 'done')
AND company_id = %s
""", (vs['partner_id'], company.id))
po_row = self.env.cr.fetchone()
vendor_po_count = po_row[0] if po_row else 0
is_po_vendor = vendor_po_count >= 3
except Exception:
pass # purchase module may not be installed
if is_po_vendor:
note = (
f'PO-TRACKED VENDOR ({vendor_po_count} purchase orders). '
f'Bills are created from Purchase Orders — do NOT create bills during bank reconciliation. '
f'Instead, find the existing unpaid bill and match the bank payment to it. '
f'Tax treatment: {note}'
)
# Upsert
existing = self.search([
('partner_id', '=', vs['partner_id']),
('company_id', '=', company.id),
], limit=1)
vals = {
'partner_id': vs['partner_id'],
'company_id': company.id,
'total_bills': total,
'bills_with_hst': with_tax,
'bills_zero_rated': no_tax,
'avg_tax_pct': avg_pct,
'tax_classification': classification,
'primary_account_id': primary_account_id,
'tax_note': note,
'is_po_vendor': is_po_vendor,
'po_count': vendor_po_count,
'is_foreign': is_foreign,
'vendor_country': country,
'last_computed': fields.Datetime.now(),
}
if existing:
existing.write(vals)
total_updated += 1
else:
self.create(vals)
total_created += 1
_logger.info(
"Vendor tax profiles rebuilt: %d created, %d updated",
total_created, total_updated,
)
return {'created': total_created, 'updated': total_updated}

View File

@@ -11,3 +11,9 @@ access_fusion_tool_user,fusion.accounting.tool.user,model_fusion_accounting_tool
access_fusion_tool_admin,fusion.accounting.tool.admin,model_fusion_accounting_tool,group_fusion_accounting_admin,1,1,1,1
access_fusion_dashboard_user,fusion.accounting.dashboard.user,model_fusion_accounting_dashboard,group_fusion_accounting_user,1,1,1,1
access_fusion_rule_wizard_manager,fusion.accounting.rule.wizard.manager,model_fusion_accounting_rule_wizard,group_fusion_accounting_manager,1,1,1,1
access_fusion_recurring_pattern_user,fusion.recurring.pattern.user,model_fusion_recurring_pattern,group_fusion_accounting_user,1,0,0,0
access_fusion_recurring_pattern_manager,fusion.recurring.pattern.manager,model_fusion_recurring_pattern,group_fusion_accounting_manager,1,1,1,0
access_fusion_recurring_pattern_admin,fusion.recurring.pattern.admin,model_fusion_recurring_pattern,group_fusion_accounting_admin,1,1,1,1
access_fusion_vendor_profile_user,fusion.vendor.tax.profile.user,model_fusion_vendor_tax_profile,group_fusion_accounting_user,1,0,0,0
access_fusion_vendor_profile_manager,fusion.vendor.tax.profile.manager,model_fusion_vendor_tax_profile,group_fusion_accounting_manager,1,1,1,0
access_fusion_vendor_profile_admin,fusion.vendor.tax.profile.admin,model_fusion_vendor_tax_profile,group_fusion_accounting_admin,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
11 access_fusion_tool_admin fusion.accounting.tool.admin model_fusion_accounting_tool group_fusion_accounting_admin 1 1 1 1
12 access_fusion_dashboard_user fusion.accounting.dashboard.user model_fusion_accounting_dashboard group_fusion_accounting_user 1 1 1 1
13 access_fusion_rule_wizard_manager fusion.accounting.rule.wizard.manager model_fusion_accounting_rule_wizard group_fusion_accounting_manager 1 1 1 1
14 access_fusion_recurring_pattern_user fusion.recurring.pattern.user model_fusion_recurring_pattern group_fusion_accounting_user 1 0 0 0
15 access_fusion_recurring_pattern_manager fusion.recurring.pattern.manager model_fusion_recurring_pattern group_fusion_accounting_manager 1 1 1 0
16 access_fusion_recurring_pattern_admin fusion.recurring.pattern.admin model_fusion_recurring_pattern group_fusion_accounting_admin 1 1 1 1
17 access_fusion_vendor_profile_user fusion.vendor.tax.profile.user model_fusion_vendor_tax_profile group_fusion_accounting_user 1 0 0 0
18 access_fusion_vendor_profile_manager fusion.vendor.tax.profile.manager model_fusion_vendor_tax_profile group_fusion_accounting_manager 1 1 1 0
19 access_fusion_vendor_profile_admin fusion.vendor.tax.profile.admin model_fusion_vendor_tax_profile group_fusion_accounting_admin 1 1 1 1

View File

@@ -1,12 +1,21 @@
import json
import logging
import time
from datetime import timedelta
from odoo import models, fields, api, _
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
# Inter-account transfer pairs: (source_journal, cc_journal, cc_account_pattern)
# Source sends "MB-CREDIT CARD" (outgoing), CC receives "PAYMENT FROM" (incoming)
TRANSFER_PAIRS = [
# (source_journal_id, cc_journal_id, outstanding_account_id)
(50, 51, 493), # Scotia Current → Passport Visa, Outstanding Receipts - All Banks
(53, 28, 493), # RBC Chequing → RBC Visa, Outstanding Receipts - All Banks
]
class FusionAccountingAgent(models.AbstractModel):
_name = 'fusion.accounting.agent'
@@ -41,9 +50,14 @@ class FusionAccountingAgent(models.AbstractModel):
def _build_tool_definitions(self, tools):
definitions = []
for tool in tools:
# A2: Include tier info in description so AI knows which tools need approval
tier_label = {'1': 'Read-only', '2': 'Auto-approved', '3': 'Requires user approval'}.get(tool.tier, '')
desc = tool.description or ''
if tier_label:
desc = f"[Tier {tool.tier}: {tier_label}] {desc}"
defn = {
'name': tool.name,
'description': tool.description,
'description': desc,
}
if tool.parameters_schema:
try:
@@ -117,6 +131,21 @@ class FusionAccountingAgent(models.AbstractModel):
raise UserError(_("Session not found."))
adapter = self._get_adapter()
provider = self._get_config('ai_provider', 'claude')
# Pin provider to session to prevent cross-adapter message contamination (C5)
if session.ai_provider and session.ai_provider != provider:
_logger.warning(
"Session %s was started with %s but current provider is %s. "
"Keeping original provider to avoid message format conflicts.",
session.name, session.ai_provider, provider,
)
provider = session.ai_provider
if provider == 'claude':
adapter = self.env['fusion.accounting.adapter.claude']
else:
adapter = self.env['fusion.accounting.adapter.openai']
tools = self._get_tools_for_user()
tool_definitions = self._build_tool_definitions(tools)
rules = self._load_rules()
@@ -132,6 +161,7 @@ class FusionAccountingAgent(models.AbstractModel):
total_tokens_in = 0
total_tokens_out = 0
response = {'text': '', 'tool_calls': None}
has_pending_tier3 = False
for turn in range(max_turns):
response = adapter.call_with_tools(
@@ -151,6 +181,7 @@ class FusionAccountingAgent(models.AbstractModel):
tier = tool_rec.tier if tool_rec else '1'
if tier == '3':
has_pending_tier3 = True
history_rec = self._log_match_history(
session, tool_name, tool_params, None,
reasoning=tc.get('reasoning', ''),
@@ -184,7 +215,29 @@ class FusionAccountingAgent(models.AbstractModel):
messages_json = adapter.append_tool_results(
messages_json, response, tool_results,
)
session.tool_call_count += len(tool_results)
session.write({'tool_call_count': session.tool_call_count + len(tool_results)})
# C2: Short-circuit loop when Tier 3 actions are pending —
# force a final text response so the AI can present approval cards
if has_pending_tier3:
try:
response = adapter.call_with_tools(
system_prompt=system_prompt,
messages=messages_json,
tools=[],
)
total_tokens_in += response.get('tokens_in', 0)
total_tokens_out += response.get('tokens_out', 0)
messages_json.append({
'role': 'assistant',
'content': response.get('text', 'I have proposed actions that require your approval.'),
})
except Exception:
messages_json.append({
'role': 'assistant',
'content': 'I have proposed actions that require your approval. Please review the pending items above.',
})
break
else:
assistant_text = response.get('text', '')
messages_json.append({'role': 'assistant', 'content': assistant_text})
@@ -210,7 +263,7 @@ class FusionAccountingAgent(models.AbstractModel):
'message_ids_json': json.dumps(messages_json),
'token_count_in': session.token_count_in + total_tokens_in,
'token_count_out': session.token_count_out + total_tokens_out,
'ai_provider': self._get_config('ai_provider', 'claude'),
'ai_provider': provider,
'ai_model': adapter._get_model_name(),
})
@@ -249,6 +302,15 @@ class FusionAccountingAgent(models.AbstractModel):
if history.rule_id:
history.rule_id.sudo()._record_decision(approved=True)
# C1: Update session messages_json so next chat turn has coherent history
self._update_session_after_decision(history, result)
# M8: Trigger promotion check after approval
try:
self.env['fusion.accounting.scoring'].check_promotions()
except Exception:
_logger.exception("Error checking promotions after approval")
return result
def _check_rule_proposal(self, tool_name, params, session):
@@ -312,4 +374,133 @@ class FusionAccountingAgent(models.AbstractModel):
if history.rule_id:
history.rule_id.sudo()._record_decision(approved=False)
return {'status': 'rejected', 'reason': reason}
# C1: Update session messages_json so next chat turn has coherent history
reject_result = {'status': 'rejected', 'reason': reason}
self._update_session_after_decision(history, reject_result)
return reject_result
def _update_session_after_decision(self, history, result):
"""Update session messages_json to replace pending_approval placeholder
with actual tool result, preventing dangling tool_use blocks."""
session = history.session_id
if not session or not session.message_ids_json:
return
try:
messages = json.loads(session.message_ids_json)
result_str = json.dumps(result) if not isinstance(result, str) else result
updated = False
for msg in messages:
if msg.get('role') != 'user':
continue
content = msg.get('content')
if isinstance(content, list):
for block in content:
if (isinstance(block, dict) and block.get('type') == 'tool_result'
and 'pending_approval' in str(block.get('content', ''))):
# Check if this is the matching tool_result block
if str(history.id) in str(block.get('content', '')):
block['content'] = result_str
updated = True
break
if updated:
break
if updated:
session.write({'message_ids_json': json.dumps(messages)})
except Exception:
_logger.warning("Failed to update session messages after decision for history %s", history.id)
# ----------------------------------------------------------------
# Cron: Auto-Reconcile Inter-Account Transfers
# ----------------------------------------------------------------
@api.model
def _cron_reconcile_transfers(self):
"""Automatically reconcile inter-account credit card payments.
When a payment is made from a bank account (e.g. Scotia Current) to a
credit card (e.g. Scotia Passport Visa), two bank statement lines appear:
- Source side: "MB-CREDIT CARD" (negative) — reconciled by model 38/35
- CC side: "PAYMENT FROM *7814" (positive) — needs matching
The source-side reconciliation creates outstanding entries on account 493.
This cron matches the CC-side lines against those outstanding entries by
exact amount and closest date (within 3 days).
"""
AML = self.env['account.move.line'].sudo()
BSL = self.env['account.bank.statement.line'].sudo()
company_partner_id = self.env.company.partner_id.id
total_reconciled = 0
for source_jid, cc_jid, outstanding_acct_id in TRANSFER_PAIRS:
# Find all unreconciled INCOMING lines on the credit card journal
cc_lines = BSL.search([
('journal_id', '=', cc_jid),
('is_reconciled', '=', False),
('amount', '>', 0), # Incoming payments only
('company_id', '=', self.env.company.id),
])
if not cc_lines:
continue
journal_name = cc_lines[0].journal_id.name
_logger.info(
"Transfer reconcile: %s%d incoming unreconciled lines",
journal_name, len(cc_lines),
)
reconciled = 0
skipped = 0
for line in cc_lines:
line_date = line.move_id.date
amount = line.amount
# Find outstanding entries with exact matching amount
candidates = AML.search([
('account_id', '=', outstanding_acct_id),
('partner_id', '=', company_partner_id),
('reconciled', '=', False),
('amount_residual', '=', amount),
])
if not candidates:
skipped += 1
continue
# Pick the candidate closest in date (within 3 days)
best = None
best_gap = 999
for c in candidates:
gap = abs((c.date - line_date).days)
if gap < best_gap:
best_gap = gap
best = c
if best_gap > 7:
skipped += 1
continue
# Set partner and reconcile
try:
line.partner_id = company_partner_id
line.set_line_bank_statement_line(best.ids)
reconciled += 1
except Exception as e:
_logger.warning(
"Transfer reconcile failed: line %s (%s, $%.2f): %s",
line.id, line.payment_ref, amount, e,
)
# Commit every 50 lines to avoid long transactions
if reconciled % 50 == 0 and reconciled > 0:
self.env.cr.commit()
self.env.cr.commit()
total_reconciled += reconciled
_logger.info(
"Transfer reconcile: %s — reconciled %d, skipped %d",
journal_name, reconciled, skipped,
)
_logger.info("Transfer reconcile complete: %d total reconciled", total_reconciled)

View File

@@ -18,6 +18,54 @@ You are helping with Canadian HST/GST tax management.
- Net HST = Collected - ITCs. Positive means owing to CRA.
- Quarterly filing periods. Check for missing tax on invoices/bills.
- All vendor bills should have ITCs unless explicitly exempt.
- HST Purchase tax ID is 20 (13%). No Tax Purchase ID is 32 (0%).
HST FILING WORKFLOW (4 phases — follow this order):
PHASE 1 — REPORTS: Run all at once:
calculate_hst_balance, get_tax_report, find_missing_itc_bills,
find_missing_tax_invoices, audit_tax_compliance.
Present summary with HST position (owing vs refund).
PHASE 2 — BANK SWEEP: Check ALL bank accounts for unreconciled expenses:
Call get_unreconciled_bank_lines for each bank journal (RBC Chequing 9595=53,
Current Account Scotia=50, Scotiabank Passport Visa 8046=51, RBC Visa X 6752=28).
Present ALL unreconciled expense lines (negative amounts) as a fusion-table
with your recommendation per row.
PHASE 3 — PER-LINE PROCESSING: For each flagged expense line:
0. FIRST: check_recurring_pattern(line_id=X) — if match found, follow action_note
instructions EXACTLY (account, HST, partner, reconcile model). No user input needed
for recurring payments. If a reconcile_model_id is returned, use apply_reconcile_model.
1. get_bank_line_details — check if a vendor bill already exists for same amount/date
2. find_similar_bank_lines — check history AND vendor_tax_pattern for coding/tax pattern
3. CRITICAL: Check vendor_tax_pattern.is_po_vendor flag:
- If is_po_vendor=true: This vendor's bills come from Purchase Orders. Do NOT create
a new bill. Instead, use get_unpaid_bills to find the existing bill and propose
match_bank_line_to_payments to match the bank payment to that bill.
- If is_po_vendor=false: Proceed with bill creation workflow below.
4. If bill already exists → propose match_bank_line_to_payments
5. If no bill but history match → propose create_vendor_bill with same coding pattern
6. If no bill and no history → ask user: "Does this expense include HST?"
7. search_partners — find the vendor by keyword from the bank description
8. Once confirmed → create_vendor_bill + register_bill_payment (Tier 3, needs approval)
9. Alternative: user can choose "Direct GL" → create_expense_entry (Tier 3)
For expenses that obviously have no HST (bank fees, interest charges, insurance),
proactively recommend "No HST" and explain why.
PO-TRACKED VENDORS (do NOT create bills for these — bills come from Purchase Orders):
When find_similar_bank_lines returns is_po_vendor=true or the vendor_tax_pattern
note starts with "PO-TRACKED VENDOR", the bill already exists or will be created
from a PO. Your job is ONLY to find the existing unpaid bill and match the bank
payment to it. If no unpaid bill exists, flag it for the user: "This is a PO vendor
but no matching bill was found — the PO may not have been billed yet."
PHASE 4 — VERIFICATION: Re-run calculate_hst_balance and get_tax_report
to show the updated HST position after all expenses are recorded.
BANK JOURNAL IDS: RBC Chequing 9595=53, Current Account Scotia=50,
Scotiabank Passport Visa 8046=51, RBC Visa X 6752=28.
MISC JOURNAL: ID=3 (for direct GL expense entries).
""",
'accounts_receivable': """
@@ -105,5 +153,36 @@ PAYROLL MANAGEMENT CONTEXT:
}
# A3/A5: Aliases so common domain variations still match a prompt
DOMAIN_ALIASES = {
'bank': 'bank_reconciliation',
'bank_recon': 'bank_reconciliation',
'hst': 'hst_management',
'gst': 'hst_management',
'tax': 'hst_management',
'ar': 'accounts_receivable',
'receivable': 'accounts_receivable',
'ap': 'accounts_payable',
'payable': 'accounts_payable',
'journal': 'journal_review',
'close': 'month_end',
'month_end_close': 'month_end',
'payroll': 'payroll_management',
'payroll_verify': 'payroll_verification',
'stock': 'inventory',
'cogs': 'inventory',
'report': 'reporting',
'reports': 'reporting',
'financial': 'reporting',
}
def get_domain_prompt(domain):
return DOMAIN_PROMPTS.get(domain, '')
if not domain:
return ''
# Try exact match first, then aliases
prompt = DOMAIN_PROMPTS.get(domain, '')
if not prompt:
resolved = DOMAIN_ALIASES.get(domain, domain)
prompt = DOMAIN_PROMPTS.get(resolved, '')
return prompt

View File

@@ -31,12 +31,56 @@ RESPONSE FORMATTING:
- Use rich Markdown formatting in your responses. The chat renders Markdown as HTML.
- Use **bold** for account names, amounts, and key terms.
- Use ## and ### headers to organize sections in longer responses.
- Use Markdown tables for tabular data (| col1 | col2 | format).
- Use bullet lists (- item) for findings, issues, and action items.
- Use numbered lists (1. item) for sequential steps or ranked items.
- Use `code` for account codes, reference numbers, and technical IDs.
- Use --- horizontal rules to separate sections in long reports.
INTERACTIVE TABLES (fusion-table) — MANDATORY FOR ACTIONABLE DATA:
IMPORTANT: When a tool returns a list of records that the user could act on, you MUST use
a ```fusion-table block instead of a Markdown table. This is REQUIRED — never use plain
Markdown tables for actionable data. The fusion-table renders an interactive widget with
checkboxes, your AI recommendations per row, user input fields, and bulk action buttons.
YOU MUST USE fusion-table FOR: missing ITCs/tax (find_missing_itc_bills, find_missing_tax_invoices),
duplicate entries (find_duplicate_bills, find_duplicate_entries), overdue invoices (get_overdue_invoices),
unreconciled lines (get_unreconciled_bank_lines, get_unreconciled_receipts, get_unmatched_payments,
find_unreconciled_suspense), draft entries (find_draft_entries), wrong balances
(find_wrong_direction_balances), sequence gaps (find_sequence_gaps), wrong accounts
(find_wrong_account_entries), unpaid bills (get_unpaid_bills), and any other list where
the user needs to review, dismiss, flag, or create rules for individual rows.
USE REGULAR MARKDOWN TABLES ONLY FOR: P&L (get_profit_loss), balance sheet (get_balance_sheet),
trial balance (get_trial_balance), cash flow (get_cash_flow), period summaries, tax reports,
and any purely informational/read-only data where there is nothing to act on per row.
Format: wrap a JSON object in a ```fusion-table fenced code block:
```fusion-table
{
"mode": "interactive",
"title": "Descriptive Title",
"columns": ["Col1", "Col2", "Col3"],
"rows": [
{"id": 123, "cells": ["val1", "val2", "val3"], "recommendation": {"action": "dismiss", "reason": "Brief explanation"}},
{"id": 456, "cells": ["val1", "val2", "val3"], "recommendation": {"action": "flag", "reason": "Brief explanation"}}
],
"actions": ["dismiss", "flag", "create_rule"],
"source_tool": "tool_name_that_produced_this"
}
```
- "mode": "interactive" (actionable) or "readonly" (informational but structured)
- "id": the Odoo record ID (account.move id, account.bank.statement.line id, etc.)
- "recommendation.action": one of "dismiss", "flag", "create_rule"
- "recommendation.reason": short explanation of why you recommend this action
- "actions": which bulk action buttons to show
- "source_tool": the tool name that produced the data
- You MUST provide a recommendation for each row when using interactive mode.
- Format monetary amounts as "$X,XXX.XX" in cells.
- Always include the record ID so actions can target the correct Odoo record.
- Add a brief text summary before or after the fusion-table block for context.
LINKING TO ODOO RECORDS:
- When referencing specific records, include clickable Odoo links.
- Journal entries: [INV/2026/00123](/odoo/accounting/123) where 123 is the move ID.
@@ -60,12 +104,14 @@ def _build_rules_section(rules):
for rule in rules:
priority = 'ADMIN' if rule.created_by == 'admin' else 'AI'
tier = 'auto' if rule.approval_tier == 'auto' else 'needs-approval'
conf_str = f', confidence={rule.confidence_score:.0%}, uses={rule.total_uses}' if rule.total_uses > 0 else ''
lines.append(
f'- [{priority}/{tier}] {rule.name} ({rule.rule_type}): '
f'- [{priority}/{tier}{conf_str}] {rule.name} ({rule.rule_type}): '
f'{rule.description or rule.match_logic or "No description"}'
)
if rule.match_logic:
lines.append(f' Match logic: {rule.match_logic}')
logic_text = rule.match_logic[:500] # Prevent prompt bloat
lines.append(f' Match logic: {logic_text}')
return '\n'.join(lines)
@@ -73,7 +119,9 @@ def _build_history_section(history):
if not history:
return ''
lines = ['RECENT MATCH HISTORY (learn from these patterns):']
for h in history[:50]:
# A4: Don't hard-cap at 50 — the caller (_load_match_history) already
# respects the history_in_prompt config setting
for h in history:
status = h.decision
reason = ''
if h.rejection_reason:

View File

@@ -140,6 +140,258 @@ def get_payment_schedule(env, params):
}
def search_partners(env, params):
"""Search for partners/vendors by name keyword."""
keyword = params.get('keyword', '')
if not keyword or len(keyword) < 2:
return {'error': 'Keyword must be at least 2 characters'}
domain = [('name', 'ilike', keyword), ('company_id', 'in', [env.company.id, False])]
if params.get('supplier_only'):
domain.append(('supplier_rank', '>', 0))
partners = env['res.partner'].search(domain, limit=int(params.get('limit', 20)))
return {
'count': len(partners),
'partners': [{
'id': p.id,
'name': p.name,
'supplier_rank': p.supplier_rank,
'customer_rank': p.customer_rank,
'vat': p.vat or '',
'email': p.email or '',
'phone': p.phone or '',
} for p in partners],
}
def find_similar_bank_lines(env, params):
"""Find past reconciled bank lines with similar description to suggest coding patterns.
Also checks vendor bill tax patterns if a partner is identified."""
keyword = params.get('keyword', '')
if not keyword or len(keyword) < 3:
return {'error': 'Keyword must be at least 3 characters'}
# Find reconciled bank lines with matching payment_ref
lines = env['account.bank.statement.line'].search([
('is_reconciled', '=', True),
('payment_ref', 'ilike', keyword),
('company_id', '=', env.company.id),
], order='date desc', limit=int(params.get('limit', 10)))
matches = []
found_partner_id = None
for line in lines:
move = line.move_id
if not move:
continue
expense_info = {'account_code': '', 'account_name': '', 'tax_applied': False, 'tax_amount': 0.0}
for ml in move.line_ids:
if ml.account_id.account_type in ('expense', 'expense_direct_cost', 'expense_depreciation'):
expense_info['account_code'] = ml.account_id.code
expense_info['account_name'] = ml.account_id.name
expense_info['tax_applied'] = bool(ml.tax_ids)
expense_info['tax_amount'] = sum(t.amount for t in ml.tax_ids) if ml.tax_ids else 0.0
break
if line.partner_id and not found_partner_id:
found_partner_id = line.partner_id.id
matches.append({
'id': line.id,
'date': str(line.date),
'payment_ref': line.payment_ref or '',
'amount': line.amount,
'partner': line.partner_id.name if line.partner_id else '',
'partner_id': line.partner_id.id if line.partner_id else None,
'expense_account': expense_info['account_code'],
'expense_account_name': expense_info['account_name'],
'tax_applied': expense_info['tax_applied'],
'tax_rate': expense_info['tax_amount'],
})
result = {
'keyword': keyword,
'count': len(matches),
'matches': matches,
'suggestion': matches[0] if matches else None,
}
# Check vendor tax profile cache first (fast), fall back to live query
partner_id = found_partner_id or (int(params['partner_id']) if params.get('partner_id') else None)
if partner_id:
profile = env['fusion.vendor.tax.profile'].search([
('partner_id', '=', partner_id),
('company_id', '=', env.company.id),
], limit=1)
if profile:
result['vendor_tax_pattern'] = {
'source': 'cached_profile',
'total_bills': profile.total_bills,
'bills_with_tax': profile.bills_with_hst,
'bills_no_tax': profile.bills_zero_rated,
'avg_tax_pct': profile.avg_tax_pct,
'tax_classification': profile.tax_classification,
'tax_note': profile.tax_note,
'primary_account_id': profile.primary_account_id.id if profile.primary_account_id else None,
'primary_account_code': profile.primary_account_code or '',
'is_foreign': profile.is_foreign,
'is_po_vendor': profile.is_po_vendor,
'po_count': profile.po_count,
}
else:
# No cached profile — live query for new/small vendors
bills = env['account.move'].search([
('move_type', '=', 'in_invoice'), ('state', '=', 'posted'),
('partner_id', '=', partner_id),
], order='date desc', limit=10)
tax_stats = {'source': 'live_query', 'total_bills': len(bills),
'bills_with_tax': 0, 'bills_no_tax': 0,
'avg_tax_pct': 0.0, 'tax_note': ''}
tax_pcts = []
for bill in bills:
if bill.amount_tax > 0.01:
tax_stats['bills_with_tax'] += 1
if bill.amount_untaxed > 0:
tax_pcts.append(round(bill.amount_tax / bill.amount_untaxed * 100, 2))
else:
tax_stats['bills_no_tax'] += 1
if tax_pcts:
tax_stats['avg_tax_pct'] = round(sum(tax_pcts) / len(tax_pcts), 2)
if tax_stats['total_bills'] > 0:
if tax_stats['bills_no_tax'] == tax_stats['total_bills']:
tax_stats['tax_note'] = 'This vendor NEVER charges HST. All bills are zero-rated.'
elif tax_stats['avg_tax_pct'] < 2.0 and tax_stats['bills_with_tax'] > 0:
tax_stats['tax_note'] = (
f'HST only on shipping (avg {tax_stats["avg_tax_pct"]}%). '
f'Do NOT apply HST to full amount.'
)
elif tax_stats['avg_tax_pct'] >= 12.0:
tax_stats['tax_note'] = f'Consistently charges HST at ~{tax_stats["avg_tax_pct"]}%.'
result['vendor_tax_pattern'] = tax_stats
return result
def create_vendor_bill(env, params):
"""[Tier 3] Create a vendor bill (account.move with move_type='in_invoice').
Requires user approval before execution."""
partner_id = int(params['partner_id'])
invoice_date = params.get('invoice_date', str(fields.Date.today()))
bill_lines = params.get('lines', [])
if not bill_lines:
return {'error': 'At least one invoice line is required'}
partner = env['res.partner'].browse(partner_id)
if not partner.exists():
return {'error': f'Partner not found: {partner_id}'}
invoice_line_vals = []
for line in bill_lines:
line_vals = {
'name': line.get('description', 'Expense'),
'price_unit': float(line.get('price_unit', 0)),
'quantity': float(line.get('quantity', 1)),
}
if line.get('account_id'):
line_vals['account_id'] = int(line['account_id'])
if line.get('tax_ids'):
line_vals['tax_ids'] = [(6, 0, [int(t) for t in line['tax_ids']])]
invoice_line_vals.append((0, 0, line_vals))
try:
bill = env['account.move'].create({
'move_type': 'in_invoice',
'partner_id': partner_id,
'invoice_date': invoice_date,
'date': invoice_date,
'invoice_line_ids': invoice_line_vals,
'company_id': env.company.id,
})
if params.get('post', False):
bill.action_post()
return {
'status': 'created',
'bill_id': bill.id,
'bill_name': bill.name,
'partner': partner.name,
'amount_total': bill.amount_total,
'state': bill.state,
}
except Exception as e:
_logger.error("Failed to create vendor bill: %s", e)
return {'error': str(e)}
def register_bill_payment(env, params):
"""[Tier 3] Register payment on a posted vendor bill and optionally reconcile to bank line.
Requires user approval before execution."""
bill_id = int(params['bill_id'])
journal_id = int(params['journal_id'])
bill = env['account.move'].browse(bill_id)
if not bill.exists() or bill.state != 'posted':
return {'error': 'Bill not found or not posted'}
payment_date = params.get('payment_date', str(fields.Date.today()))
try:
# Use the payment register wizard
ctx = {
'active_model': 'account.move',
'active_ids': [bill_id],
}
wizard = env['account.payment.register'].with_context(**ctx).create({
'journal_id': journal_id,
'payment_date': payment_date,
})
# Optionally set amount if provided (otherwise defaults to bill amount)
if params.get('amount'):
wizard.amount = float(params['amount'])
payments = wizard.action_create_payments()
# Find the created payment
payment = None
if isinstance(payments, dict) and payments.get('res_id'):
payment = env['account.payment'].browse(payments['res_id'])
elif isinstance(payments, dict) and payments.get('domain'):
payment = env['account.payment'].search(payments['domain'], limit=1)
else:
# Fallback: find the latest payment for this bill
payment = env['account.payment'].search([
('partner_id', '=', bill.partner_id.id),
], order='create_date desc', limit=1)
result = {
'status': 'paid',
'bill_id': bill_id,
'bill_name': bill.name,
'payment_state': bill.payment_state,
}
if payment:
result['payment_id'] = payment.id
result['payment_name'] = payment.name
# Optionally reconcile to a bank statement line
if params.get('statement_line_id') and payment:
try:
st_line = env['account.bank.statement.line'].browse(int(params['statement_line_id']))
if st_line.exists() and not st_line.is_reconciled:
# Find the payment's move lines on the bank's outstanding account
pay_move_lines = payment.move_id.line_ids.filtered(
lambda l: l.account_id.reconcile and not l.reconciled
)
if pay_move_lines:
st_line.set_line_bank_statement_line(pay_move_lines.ids)
result['reconciled'] = True
result['statement_line_id'] = st_line.id
except Exception as e:
_logger.warning("Payment created but bank reconciliation failed: %s", e)
result['reconcile_error'] = str(e)
return result
except Exception as e:
_logger.error("Failed to register payment: %s", e)
return {'error': str(e)}
TOOLS = {
'get_ap_aging': get_ap_aging,
'find_duplicate_bills': find_duplicate_bills,
@@ -147,4 +399,8 @@ TOOLS = {
'get_unpaid_bills': get_unpaid_bills,
'verify_bill_taxes': verify_bill_taxes,
'get_payment_schedule': get_payment_schedule,
'search_partners': search_partners,
'find_similar_bank_lines': find_similar_bank_lines,
'create_vendor_bill': create_vendor_bill,
'register_bill_payment': register_bill_payment,
}

View File

@@ -69,7 +69,11 @@ def flag_entry(env, params):
def get_audit_status(env, params):
statuses = env['account.audit.account.status'].search([])
try:
AuditStatus = env['account.audit.account.status']
except KeyError:
return {'error': 'Audit status model (account.audit.account.status) is not available. The account_audit Enterprise module may not be installed.'}
statuses = AuditStatus.search([])
return {
'statuses': [{
'id': s.id,
@@ -81,9 +85,13 @@ def get_audit_status(env, params):
def set_audit_status(env, params):
try:
AuditStatus = env['account.audit.account.status']
except KeyError:
return {'error': 'Audit status model (account.audit.account.status) is not available. The account_audit Enterprise module may not be installed.'}
status_id = int(params['status_id'])
new_status = params['status']
rec = env['account.audit.account.status'].browse(status_id)
rec = AuditStatus.browse(status_id)
if not rec.exists():
return {'error': 'Audit status record not found'}
rec.status = new_status

View File

@@ -1,5 +1,6 @@
import logging
from datetime import datetime
from odoo import fields
_logger = logging.getLogger(__name__)
@@ -139,6 +140,10 @@ def get_reconcile_suggestions(env, params):
def sum_payments_by_date(env, params):
"""Sum payment/journal activity for a date range.
IMPORTANT: Always pass journal_ids to filter to specific journals.
Without journal_ids, returns totals across ALL journals which is
almost never what you want for reconciliation."""
date_from = params.get('date_from')
date_to = params.get('date_to')
if not date_from or not date_to:
@@ -150,18 +155,332 @@ def sum_payments_by_date(env, params):
('date', '>=', date_from),
('date', '<=', date_to),
]
scope = 'all journals'
if journal_ids:
domain.append(('journal_id', 'in', [int(j) for j in journal_ids]))
jids = [int(j) for j in journal_ids]
domain.append(('journal_id', 'in', jids))
journals = env['account.journal'].browse(jids)
scope = ', '.join(j.name for j in journals if j.exists())
else:
# Without journal filter, include a warning and break down by journal
pass
lines = env['account.move.line'].search(domain)
total_debit = sum(l.debit for l in lines)
total_credit = sum(l.credit for l in lines)
return {
result = {
'date_from': date_from,
'date_to': date_to,
'total_debit': total_debit,
'total_credit': total_credit,
'net': total_debit - total_credit,
'line_count': len(lines),
'scope': scope,
}
# If no journal filter, add per-journal breakdown so AI doesn't
# mistake company-wide totals for a specific journal's activity
if not journal_ids:
result['warning'] = (
'No journal_ids filter was provided. These totals are across ALL '
'journals in the company. To get card payment totals, pass the '
'specific card/POS journal IDs.'
)
journal_totals = {}
for l in lines:
jname = l.journal_id.name
if jname not in journal_totals:
journal_totals[jname] = {'debit': 0.0, 'credit': 0.0, 'count': 0}
journal_totals[jname]['debit'] += l.debit
journal_totals[jname]['credit'] += l.credit
journal_totals[jname]['count'] += 1
result['by_journal'] = [
{'journal': jn, 'debit': v['debit'], 'credit': v['credit'], 'count': v['count']}
for jn, v in sorted(journal_totals.items(), key=lambda x: -x[1]['debit'])
][:15]
return result
def get_bank_line_details(env, params):
"""Get full details of a single bank statement line plus matching suggestions."""
line_id = int(params['line_id'])
line = env['account.bank.statement.line'].browse(line_id)
if not line.exists():
return {'error': 'Bank statement line not found'}
result = {
'id': line.id,
'date': str(line.date),
'payment_ref': line.payment_ref or '',
'partner_name': line.partner_name or (line.partner_id.name if line.partner_id else ''),
'partner_id': line.partner_id.id if line.partner_id else None,
'amount': line.amount,
'journal': line.journal_id.name,
'journal_id': line.journal_id.id,
'is_reconciled': line.is_reconciled,
'existing_bills': [],
'suggested_partner': None,
}
# Search for existing vendor bills matching amount ± $0.50 and date ± 3 days
abs_amount = abs(line.amount)
from datetime import timedelta as td
date_from = line.date - td(days=3)
date_to = line.date + td(days=3)
matching_bills = env['account.move'].search([
('move_type', '=', 'in_invoice'),
('state', '=', 'posted'),
('amount_total', '>=', abs_amount - 0.50),
('amount_total', '<=', abs_amount + 0.50),
('date', '>=', str(date_from)),
('date', '<=', str(date_to)),
('company_id', '=', env.company.id),
], limit=5)
for bill in matching_bills:
result['existing_bills'].append({
'id': bill.id,
'name': bill.name,
'partner': bill.partner_id.name if bill.partner_id else '',
'amount_total': bill.amount_total,
'date': str(bill.date),
'payment_state': bill.payment_state,
})
# Try to suggest a partner from payment_ref keyword
if line.payment_ref and not line.partner_id:
# Extract meaningful words from payment_ref (skip common banking terms)
skip_words = {'misc', 'payment', 'online', 'banking', 'pad', 'business',
'deposit', 'cheque', 'transfer', 'e-transfer', 'sent', 'autodeposit'}
words = [w for w in line.payment_ref.split() if len(w) > 2 and w.lower() not in skip_words]
for word in words[:3]:
partners = env['res.partner'].search([
('name', 'ilike', word),
('supplier_rank', '>', 0),
], limit=3)
if partners:
result['suggested_partner'] = {
'id': partners[0].id,
'name': partners[0].name,
'match_word': word,
}
break
return result
def check_recurring_pattern(env, params):
"""Check if a bank line matches a known recurring payment pattern.
Returns the historical coding (account, HST, partner, reconcile model) if found."""
line_id = params.get('line_id')
payment_ref = params.get('payment_ref', '')
amount = params.get('amount')
# If line_id provided, get the ref and amount from the line
if line_id:
line = env['account.bank.statement.line'].browse(int(line_id))
if line.exists():
payment_ref = line.payment_ref or ''
amount = line.amount
if not payment_ref:
return {'match': False, 'reason': 'No payment reference to match'}
# Search cached patterns by keyword
patterns = env['fusion.recurring.pattern'].search([
('company_id', '=', env.company.id),
])
best_match = None
for pat in patterns:
if not pat.ref_keyword:
continue
# Check if the pattern keyword appears in the payment_ref
if pat.ref_keyword.lower()[:30] in payment_ref.lower():
# If amount matches too, it's a strong match
if amount and pat.amount_is_fixed and abs(pat.amount - amount) < 0.01:
best_match = pat
break
# Keyword-only match (amount may vary)
if not best_match or pat.occurrences > best_match.occurrences:
best_match = pat
if not best_match:
return {'match': False, 'payment_ref': payment_ref}
result = {
'match': True,
'pattern_id': best_match.id,
'pattern_name': best_match.name,
'occurrences': best_match.occurrences,
'first_seen': str(best_match.first_seen) if best_match.first_seen else '',
'last_seen': str(best_match.last_seen) if best_match.last_seen else '',
'expense_account_id': best_match.expense_account_id.id if best_match.expense_account_id else None,
'expense_account_code': best_match.expense_account_code or '',
'expense_account_name': best_match.expense_account_id.name if best_match.expense_account_id else '',
'has_hst': best_match.has_hst,
'partner_id': best_match.partner_id.id if best_match.partner_id else None,
'partner_name': best_match.partner_id.name if best_match.partner_id else '',
'action_note': best_match.action_note or '',
'amount_is_fixed': best_match.amount_is_fixed,
}
if best_match.reconcile_model_id:
result['reconcile_model_id'] = best_match.reconcile_model_id.id
result['reconcile_model_name'] = best_match.reconcile_model_id.name
return result
def match_internal_transfers(env, params):
"""[Tier 3] Find and match inter-account transfers between two bank journals.
Matches exact amounts within a date window. Only matches when there is exactly
ONE candidate on each side (no ambiguous matches). Requires user approval.
Typical use: Scotia Current Account ↔ Scotia Visa payments."""
journal_a_id = int(params['journal_a_id']) # e.g., Scotia Current (50)
journal_b_id = int(params['journal_b_id']) # e.g., Scotia Visa (51)
date_from = params.get('date_from', '2025-01-01')
date_to = params.get('date_to', '2025-03-31')
max_days_apart = int(params.get('max_days_apart', 2))
# Get unreconciled positive lines from both journals
# (transfers show as positive on the RECEIVING side)
lines_a = env['account.bank.statement.line'].search([
('is_reconciled', '=', False),
('journal_id', '=', journal_a_id),
('company_id', '=', env.company.id),
])
lines_a = lines_a.filtered(
lambda l: l.move_id.date >= fields.Date.from_string(date_from)
and l.move_id.date <= fields.Date.from_string(date_to)
and l.amount > 0 # money coming IN on this account
)
lines_b = env['account.bank.statement.line'].search([
('is_reconciled', '=', False),
('journal_id', '=', journal_b_id),
('company_id', '=', env.company.id),
])
lines_b = lines_b.filtered(
lambda l: l.move_id.date >= fields.Date.from_string(date_from)
and l.move_id.date <= fields.Date.from_string(date_to)
and l.amount > 0 # money coming IN on this account
)
matched_pairs = []
used_a = set()
used_b = set()
# For each line in A, find exact-amount match in B within date window
for la in sorted(lines_a, key=lambda l: l.move_id.date):
if la.id in used_a:
continue
candidates = []
for lb in lines_b:
if lb.id in used_b:
continue
if abs(la.amount - lb.amount) < 0.01:
days = abs((la.move_id.date - lb.move_id.date).days)
if days <= max_days_apart:
candidates.append(lb)
# Only match if EXACTLY ONE candidate — skip ambiguous
if len(candidates) == 1:
lb = candidates[0]
matched_pairs.append({
'line_a_id': la.id,
'line_a_date': str(la.move_id.date),
'line_a_ref': la.payment_ref or '',
'line_a_journal': la.journal_id.name,
'line_b_id': lb.id,
'line_b_date': str(lb.move_id.date),
'line_b_ref': lb.payment_ref or '',
'line_b_journal': lb.journal_id.name,
'amount': la.amount,
'days_apart': abs((la.move_id.date - lb.move_id.date).days),
})
used_a.add(la.id)
used_b.add(lb.id)
if not matched_pairs:
return {
'status': 'no_matches',
'message': 'No unambiguous transfer pairs found.',
'lines_a_checked': len(lines_a),
'lines_b_checked': len(lines_b),
}
# If this is just a dry-run check (no execute flag), return the pairs for review
if not params.get('execute', False):
return {
'status': 'pairs_found',
'count': len(matched_pairs),
'pairs': matched_pairs,
'message': f'Found {len(matched_pairs)} unambiguous transfer pairs. Set execute=true to reconcile them.',
}
# Execute: create internal transfer journal entries to reconcile both sides
reconciled = []
for pair in matched_pairs:
try:
line_a = env['account.bank.statement.line'].browse(pair['line_a_id'])
line_b = env['account.bank.statement.line'].browse(pair['line_b_id'])
# Create an internal transfer payment
payment = env['account.payment'].create({
'payment_type': 'outbound',
'partner_type': 'supplier',
'partner_id': env.company.partner_id.id, # Self as partner for internal transfer
'amount': pair['amount'],
'journal_id': journal_a_id,
'destination_journal_id': journal_b_id,
'date': line_a.move_id.date,
'ref': f'Internal Transfer: {pair["line_a_ref"]}{pair["line_b_ref"]}',
'is_internal_transfer': True,
})
payment.action_post()
# Now match the payment's move lines to the bank statement lines
# The payment creates lines on both journals' outstanding accounts
for move_line in payment.move_id.line_ids:
if move_line.journal_id.id == journal_a_id and not move_line.reconciled:
try:
line_a.set_line_bank_statement_line(move_line.ids)
except Exception:
pass
# Check paired transfer for the other side
if payment.paired_internal_transfer_payment_id:
paired = payment.paired_internal_transfer_payment_id
for move_line in paired.move_id.line_ids:
if move_line.journal_id.id == journal_b_id and not move_line.reconciled:
try:
line_b.set_line_bank_statement_line(move_line.ids)
except Exception:
pass
reconciled.append({
'line_a_id': pair['line_a_id'],
'line_b_id': pair['line_b_id'],
'amount': pair['amount'],
'payment_id': payment.id,
'status': 'reconciled',
})
except Exception as e:
_logger.error("Failed to reconcile transfer pair %s: %s", pair, e)
reconciled.append({
'line_a_id': pair['line_a_id'],
'line_b_id': pair['line_b_id'],
'amount': pair['amount'],
'status': 'error',
'error': str(e),
})
return {
'status': 'executed',
'total_pairs': len(matched_pairs),
'reconciled': len([r for r in reconciled if r['status'] == 'reconciled']),
'errors': len([r for r in reconciled if r['status'] == 'error']),
'details': reconciled,
}
@@ -174,4 +493,7 @@ TOOLS = {
'unmatch_bank_line': unmatch_bank_line,
'get_reconcile_suggestions': get_reconcile_suggestions,
'sum_payments_by_date': sum_payments_by_date,
'get_bank_line_details': get_bank_line_details,
'check_recurring_pattern': check_recurring_pattern,
'match_internal_transfers': match_internal_transfers,
}

View File

@@ -15,12 +15,22 @@ def calculate_hst_balance(env, params):
if date_to:
base_domain.append(('date', '<=', date_to))
collected_accounts = env['account.account'].search([
('code', '=like', '2005%'), ('company_id', '=', env.company.id),
])
itc_accounts = env['account.account'].search([
('code', '=like', '2006%'), ('company_id', '=', env.company.id),
])
# Odoo 19 Enterprise: account.account may not have company_id field
# (shared chart of accounts). Use try/except to handle both cases.
try:
collected_accounts = env['account.account'].search([
('code', '=like', '2005%'), ('company_id', '=', env.company.id),
])
itc_accounts = env['account.account'].search([
('code', '=like', '2006%'), ('company_id', '=', env.company.id),
])
except Exception:
collected_accounts = env['account.account'].search([
('code', '=like', '2005%'),
])
itc_accounts = env['account.account'].search([
('code', '=like', '2006%'),
])
collected_lines = env['account.move.line'].search(
base_domain + [('account_id', 'in', collected_accounts.ids)]
@@ -124,7 +134,11 @@ def find_missing_itc_bills(env, params):
def get_tax_return_status(env, params):
returns = env['account.return'].search([
try:
AccountReturn = env['account.return']
except KeyError:
return {'error': 'Tax return model (account.return) is not available. The account_tax_report or related Enterprise module may not be installed.'}
returns = AccountReturn.search([
('company_id', '=', env.company.id),
], order='date_start desc', limit=10)
return {
@@ -140,7 +154,11 @@ def get_tax_return_status(env, params):
def generate_tax_return(env, params):
try:
env['account.return']._generate_or_refresh_all_returns(
AccountReturn = env['account.return']
except KeyError:
return {'error': 'Tax return model (account.return) is not available.'}
try:
AccountReturn._generate_or_refresh_all_returns(
company=env.company
)
return {'status': 'generated', 'message': 'Tax returns refreshed successfully.'}
@@ -149,8 +167,12 @@ def generate_tax_return(env, params):
def validate_tax_return(env, params):
try:
AccountReturn = env['account.return']
except KeyError:
return {'error': 'Tax return model (account.return) is not available.'}
return_id = int(params['return_id'])
tax_return = env['account.return'].browse(return_id)
tax_return = AccountReturn.browse(return_id)
if not tax_return.exists():
return {'error': 'Tax return not found'}
try:
@@ -160,6 +182,111 @@ def validate_tax_return(env, params):
return {'error': str(e)}
def create_expense_entry(env, params):
"""[Tier 3] Create a direct GL expense entry in the Misc journal with optional HST split.
This is the 'old school' way of recording expenses without a formal vendor bill.
Requires user approval before execution."""
date = params.get('date', str(env['account.move']._fields['date'].default(env['account.move'])))
description = params.get('description', 'Expense')
expense_account_id = int(params['expense_account_id'])
amount = abs(float(params['amount']))
has_hst = params.get('has_hst', False)
bank_journal_id = int(params.get('bank_journal_id', 0))
# Find the MISC journal
misc_journal = env['account.journal'].search([
('code', '=', 'MISC'), ('company_id', '=', env.company.id),
], limit=1)
if not misc_journal:
return {'error': 'Miscellaneous Operations journal (MISC) not found'}
expense_account = env['account.account'].browse(expense_account_id)
if not expense_account.exists():
return {'error': f'Expense account not found: {expense_account_id}'}
# Determine credit account (bank outstanding or AP)
credit_account = None
if bank_journal_id:
bank_journal = env['account.journal'].browse(bank_journal_id)
if bank_journal.exists():
# Use the bank journal's default debit/credit account
credit_account = (bank_journal.default_account_id
or bank_journal.company_id.account_journal_payment_credit_account_id)
if not credit_account:
# Fallback to AP account
credit_account = env['account.account'].search([
('account_type', '=', 'liability_payable'),
('company_id', '=', env.company.id),
], limit=1)
if not credit_account:
return {'error': 'Could not determine credit account for the expense entry'}
line_ids = []
if has_hst:
# Split: net expense + 13% HST ITC
hst_rate = 0.13
net_amount = round(amount / (1 + hst_rate), 2)
hst_amount = round(amount - net_amount, 2)
# Find HST ITC account (2006%)
itc_account = env['account.account'].search([
('code', '=like', '2006%'),
], limit=1)
if not itc_account:
# Fallback: use the HST purchase tax account
hst_tax = env['account.tax'].search([
('type_tax_use', '=', 'purchase'), ('amount', '=', 13.0),
('company_id', '=', env.company.id),
], limit=1)
if hst_tax and hst_tax.invoice_repartition_line_ids:
for rep in hst_tax.invoice_repartition_line_ids:
if rep.repartition_type == 'tax' and rep.account_id:
itc_account = rep.account_id
break
if not itc_account:
return {'error': 'HST ITC account (2006) not found'}
line_ids = [
(0, 0, {'name': description, 'account_id': expense_account_id,
'debit': net_amount, 'credit': 0.0}),
(0, 0, {'name': f'HST ITC - {description}', 'account_id': itc_account.id,
'debit': hst_amount, 'credit': 0.0}),
(0, 0, {'name': description, 'account_id': credit_account.id,
'debit': 0.0, 'credit': amount}),
]
else:
# Simple: debit expense / credit bank
line_ids = [
(0, 0, {'name': description, 'account_id': expense_account_id,
'debit': amount, 'credit': 0.0}),
(0, 0, {'name': description, 'account_id': credit_account.id,
'debit': 0.0, 'credit': amount}),
]
try:
move = env['account.move'].create({
'move_type': 'entry',
'journal_id': misc_journal.id,
'date': date,
'ref': description,
'line_ids': line_ids,
'company_id': env.company.id,
})
move.action_post()
return {
'status': 'posted',
'move_id': move.id,
'move_name': move.name,
'amount': amount,
'has_hst': has_hst,
'hst_amount': round(amount - amount / 1.13, 2) if has_hst else 0.0,
}
except Exception as e:
_logger.error("Failed to create expense entry: %s", e)
return {'error': str(e)}
TOOLS = {
'calculate_hst_balance': calculate_hst_balance,
'get_tax_report': get_tax_report,
@@ -168,4 +295,5 @@ TOOLS = {
'get_tax_return_status': get_tax_return_status,
'generate_tax_return': generate_tax_return,
'validate_tax_return': validate_tax_return,
'create_expense_entry': create_expense_entry,
}

View File

@@ -4,6 +4,48 @@ import { Component, useState, useRef, onWillStart, onMounted, onPatched } from "
import { rpc } from "@web/core/network/rpc";
import { FusionApprovalCard } from "./approval_card";
/**
* Parse a fusion-table JSON block from AI response.
* Returns {json, placeholder} or null if not a fusion-table block.
*/
function parseFusionTableBlock(text) {
// Match ```fusion-table ... ``` blocks
const regex = /```fusion-table\s*\n([\s\S]*?)```/g;
const tables = [];
let lastIndex = 0;
const parts = [];
let match;
while ((match = regex.exec(text)) !== null) {
// Add text before the block
if (match.index > lastIndex) {
parts.push({ type: "md", content: text.slice(lastIndex, match.index) });
}
// Parse the JSON
try {
const data = JSON.parse(match[1].trim());
const tableIdx = tables.length;
tables.push(data);
parts.push({ type: "table", idx: tableIdx });
} catch (e) {
// If JSON parse fails, treat as regular code block
parts.push({ type: "md", content: match[0] });
}
lastIndex = match.index + match[0].length;
}
// Remaining text after last block
if (lastIndex < text.length) {
parts.push({ type: "md", content: text.slice(lastIndex) });
}
if (tables.length === 0) {
return null;
}
return { parts, tables };
}
function mdToHtml(text) {
if (!text) return "";
@@ -150,6 +192,8 @@ export class FusionChatPanel extends Component {
setup() {
this.inputRef = useRef("chatInput");
this.messagesRef = useRef("messages");
// Track parsed table data per message index for interactive tables
this._parsedTables = {};
this.state = useState({
messages: [],
pendingApprovals: [],
@@ -158,6 +202,11 @@ export class FusionChatPanel extends Component {
loading: true,
internalSessionId: null,
sessionName: null,
// Interactive tables extracted from AI messages, keyed by msg index
interactiveTables: {},
// Session history picker
showSessionPicker: false,
sessionList: [],
});
onWillStart(async () => {
@@ -181,14 +230,240 @@ export class FusionChatPanel extends Component {
const idx = parseInt(div.dataset.idx);
const msg = this.state.messages[idx];
if (msg && msg.role === "assistant" && msg.content) {
const html = mdToHtml(msg.content);
if (div.innerHTML !== html) {
div.innerHTML = html;
// Check for fusion-table blocks
const parsed = parseFusionTableBlock(msg.content);
if (parsed) {
// Build HTML with placeholders for interactive tables
let html = "";
for (const part of parsed.parts) {
if (part.type === "md") {
html += mdToHtml(part.content);
} else if (part.type === "table") {
const tableKey = `${idx}_${part.idx}`;
html += `<div class="fusion_table_mount" data-table-key="${tableKey}"></div>`;
// Store table data for OWL mounting
this._parsedTables[tableKey] = parsed.tables[part.idx];
}
}
if (div.innerHTML !== html) {
div.innerHTML = html;
}
// Mount OWL interactive table components into placeholders
this._mountInteractiveTables(div);
} else {
const html = mdToHtml(msg.content);
if (div.innerHTML !== html) {
div.innerHTML = html;
}
}
}
}
}
_mountInteractiveTables(container) {
const mounts = container.querySelectorAll(".fusion_table_mount[data-table-key]");
for (const el of mounts) {
const key = el.dataset.tableKey;
if (el.dataset.mounted === "true") continue;
const tableData = this._parsedTables[key];
if (!tableData) continue;
el.dataset.mounted = "true";
el.innerHTML = this._buildInteractiveTableHtml(tableData, key);
this._wireTableEvents(el, tableData, key);
}
}
_badgeClass(action) {
switch (action) {
case "dismiss": return "bg-success-subtle text-success";
case "flag": return "bg-warning-subtle text-warning";
case "create_rule": return "bg-info-subtle text-info";
default: return "bg-secondary-subtle text-secondary";
}
}
_badgeLabel(action) {
switch (action) {
case "dismiss": return "Dismiss";
case "flag": return "Flag";
case "create_rule": return "Create Rule";
default: return action || "Review";
}
}
_esc(text) {
const d = document.createElement("div");
d.textContent = text;
return d.innerHTML;
}
_buildInteractiveTableHtml(tableData, key) {
const cols = tableData.columns || [];
const rows = tableData.rows || [];
const isInteractive = tableData.mode === "interactive";
const actions = tableData.actions || [];
const title = tableData.title || "";
let h = '<div class="fusion_interactive_table my-2">';
// Title
if (title) {
h += `<div class="d-flex align-items-center mb-2">`;
h += `<i class="fa fa-table me-2 text-muted"></i>`;
h += `<strong>${this._esc(title)}</strong>`;
h += `<span class="badge bg-secondary-subtle text-secondary ms-2">${rows.length} rows</span>`;
h += `</div>`;
}
// Table
h += '<div class="table-responsive"><table class="table table-sm table-hover align-middle mb-0"><thead><tr>';
if (isInteractive) {
h += `<th class="fit-content px-2"><input type="checkbox" class="form-check-input" data-action="select-all"/></th>`;
}
for (const col of cols) {
h += `<th class="px-2 py-1">${this._esc(col)}</th>`;
}
if (isInteractive) {
h += `<th class="px-2 py-1 text-info">AI Recommendation</th>`;
h += `<th class="px-2 py-1 text-warning" style="min-width:180px;">Your Input</th>`;
}
h += '</tr></thead><tbody>';
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
h += `<tr data-row-idx="${i}">`;
if (isInteractive) {
h += `<td class="fit-content px-2"><input type="checkbox" class="form-check-input" data-action="select-row" data-idx="${i}"/></td>`;
}
for (const cell of (row.cells || [])) {
h += `<td class="px-2 py-1">${this._esc(String(cell))}</td>`;
}
if (isInteractive) {
// Recommendation
h += `<td class="px-2 py-1">`;
if (row.recommendation) {
const rc = row.recommendation;
h += `<span class="badge me-1 ${this._badgeClass(rc.action)}">${this._badgeLabel(rc.action)}</span>`;
h += `<small class="text-muted">${this._esc(rc.reason || "")}</small>`;
}
h += `</td>`;
// User input
h += `<td class="px-2 py-1"><input type="text" class="form-control form-control-sm fusion_row_note" data-idx="${i}" placeholder="Add your note..."/></td>`;
}
h += '</tr>';
}
h += '</tbody></table></div>';
// Action bar
if (isInteractive) {
h += '<div class="fusion_table_action_bar d-flex flex-wrap align-items-center gap-2 p-2 border-top">';
h += '<small class="text-muted me-1 fusion_selected_count">0 selected</small>';
h += `<button class="btn btn-success btn-sm" data-action="apply_recommendations" disabled><i class="fa fa-check me-1"></i>Apply Recommendations</button>`;
if (actions.includes("flag")) {
h += `<button class="btn btn-outline-warning btn-sm" data-action="flag" disabled><i class="fa fa-flag me-1"></i>Flag Selected</button>`;
}
if (actions.includes("create_rule")) {
h += `<button class="btn btn-outline-info btn-sm" data-action="create_rule" disabled><i class="fa fa-plus me-1"></i>Create Rules</button>`;
}
if (actions.includes("dismiss")) {
h += `<button class="btn btn-outline-secondary btn-sm" data-action="dismiss" disabled>Dismiss Selected</button>`;
}
h += '<div class="flex-grow-1"></div>';
h += `<button class="btn btn-outline-primary btn-sm" data-action="submit_notes"><i class="fa fa-pencil me-1"></i>Submit All Notes to AI</button>`;
h += '</div>';
}
h += '</div>';
return h;
}
_wireTableEvents(container, tableData, key) {
const rows = tableData.rows || [];
// Select all checkbox
const selectAll = container.querySelector('[data-action="select-all"]');
if (selectAll) {
selectAll.addEventListener("change", () => {
const cbs = container.querySelectorAll('[data-action="select-row"]');
for (const cb of cbs) cb.checked = selectAll.checked;
this._updateTableActionBar(container);
});
}
// Individual row checkboxes
const rowCbs = container.querySelectorAll('[data-action="select-row"]');
for (const cb of rowCbs) {
cb.addEventListener("change", () => this._updateTableActionBar(container));
}
// Action buttons
const actionBtns = container.querySelectorAll('.fusion_table_action_bar button[data-action]');
for (const btn of actionBtns) {
btn.addEventListener("click", () => {
const action = btn.dataset.action;
const selectedRows = this._collectTableRows(container, tableData, action === "submit_notes");
if (selectedRows.length === 0) return;
this.onTableAction({
action,
source_tool: tableData.source_tool,
rows: selectedRows,
});
});
}
}
_updateTableActionBar(container) {
const cbs = container.querySelectorAll('[data-action="select-row"]:checked');
const count = cbs.length;
const countEl = container.querySelector('.fusion_selected_count');
if (countEl) countEl.textContent = `${count} selected`;
// Enable/disable action buttons
const btns = container.querySelectorAll('.fusion_table_action_bar button[data-action]');
for (const btn of btns) {
if (btn.dataset.action === "submit_notes") continue; // always enabled
btn.disabled = (count === 0);
}
}
_collectTableRows(container, tableData, allNotes) {
const rows = tableData.rows || [];
const result = [];
if (allNotes) {
// Collect all rows that have a note
const inputs = container.querySelectorAll('.fusion_row_note');
for (const inp of inputs) {
const idx = parseInt(inp.dataset.idx);
const note = inp.value.trim();
if (note && rows[idx]) {
result.push({
id: rows[idx].id,
cells: rows[idx].cells,
recommendation: rows[idx].recommendation,
userNote: note,
});
}
}
} else {
// Collect checked rows
const cbs = container.querySelectorAll('[data-action="select-row"]:checked');
for (const cb of cbs) {
const idx = parseInt(cb.dataset.idx);
if (rows[idx]) {
const noteInput = container.querySelector(`.fusion_row_note[data-idx="${idx}"]`);
result.push({
id: rows[idx].id,
cells: rows[idx].cells,
recommendation: rows[idx].recommendation,
userNote: noteInput ? noteInput.value.trim() : "",
});
}
}
}
return result;
}
get sessionId() {
return this.state.internalSessionId || this.props.sessionId;
}
@@ -209,17 +484,87 @@ export class FusionChatPanel extends Component {
this.scrollToBottom();
}
async toggleSessionPicker() {
if (this.state.showSessionPicker) {
this.state.showSessionPicker = false;
return;
}
try {
const data = await rpc("/fusion_accounting/session/list", { limit: 20 });
this.state.sessionList = data.sessions || [];
} catch (e) {
console.error("Failed to load session list:", e);
this.state.sessionList = [];
}
this.state.showSessionPicker = true;
}
async loadSession(sessionId) {
this.state.showSessionPicker = false;
this.state.loading = true;
try {
const data = await rpc("/fusion_accounting/session/history", { session_id: sessionId });
if (data.messages) {
this.state.internalSessionId = data.session_id;
// Filter display messages same as session/latest
const display = [];
for (const msg of data.messages) {
if (typeof msg.content === "string" && msg.content.trim()) {
display.push(msg);
} else if (Array.isArray(msg.content)) {
for (const block of msg.content) {
if (block && block.type === "text" && block.text && block.text.trim()) {
display.push({ role: msg.role, content: block.text });
}
}
}
}
this.state.messages = display;
// Find session name from the list
const found = this.state.sessionList.find(s => s.id === sessionId);
this.state.sessionName = found ? found.name : `Session #${sessionId}`;
this.state.pendingApprovals = [];
this._parsedTables = {};
}
} catch (e) {
console.error("Failed to load session:", e);
}
this.state.loading = false;
this.scrollToBottom();
}
formatSessionDate(isoDate) {
if (!isoDate) return "";
try {
const d = new Date(isoDate);
return d.toLocaleDateString("en-CA", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
} catch (e) {
return isoDate;
}
}
async onNewChat() {
// Close current session first — must succeed before creating new one
if (this.sessionId) {
try {
await rpc("/fusion_accounting/session/close", { session_id: this.sessionId });
} catch (e) { /* not critical */ }
const closeResult = await rpc("/fusion_accounting/session/close", { session_id: this.sessionId });
if (closeResult.error) {
console.warn("Failed to close session:", closeResult.error);
}
} catch (e) {
console.warn("Error closing session:", e);
}
}
try {
const session = await rpc("/fusion_accounting/session/create", {});
this.state.internalSessionId = session.session_id;
this.state.sessionName = session.name;
this.state.messages = [];
this.state.pendingApprovals = [];
this._parsedTables = {};
} catch (e) {
console.error("Failed to create new session:", e);
}
const session = await rpc("/fusion_accounting/session/create", {});
this.state.internalSessionId = session.session_id;
this.state.sessionName = session.name;
this.state.messages = [];
this.state.pendingApprovals = [];
}
async sendMessage() {
@@ -258,6 +603,66 @@ export class FusionChatPanel extends Component {
this.scrollToBottom();
}
/**
* Handle actions from interactive tables (Apply, Flag, Create Rule, Dismiss, Submit Notes).
* Formats a structured message and sends it back through the chat.
*/
async onTableAction(payload) {
const { action, source_tool, rows } = payload;
const actionLabels = {
apply_recommendations: "Apply Recommendations",
flag: "Flag",
create_rule: "Create Rules",
dismiss: "Dismiss",
submit_notes: "Submit Notes",
};
const label = actionLabels[action] || action;
// Build a structured message for the AI
let parts = [`[TABLE_ACTION] source=${source_tool} action=${action}`];
for (const row of rows) {
const cellSummary = (row.cells || []).join(" | ");
let line = `- Row #${row.id}: ${cellSummary}`;
if (row.recommendation) {
line += ` (AI suggested: ${row.recommendation.action} - ${row.recommendation.reason})`;
}
if (row.userNote) {
line += ` [User note: ${row.userNote}]`;
}
parts.push(line);
}
const message = parts.join("\n");
// Show user what we're sending
this.state.messages.push({
role: "user",
content: `**${label}** on ${rows.length} row(s) from ${source_tool}`,
});
this.state.sending = true;
this.scrollToBottom();
try {
const result = await rpc("/fusion_accounting/chat", {
session_id: this.sessionId,
message: message,
});
if (result.text) {
this.state.messages.push({ role: "assistant", content: result.text });
}
if (result.pending_approvals) {
this.state.pendingApprovals = result.pending_approvals;
}
} catch (e) {
this.state.messages.push({
role: "assistant",
content: `Error processing table action: ${e.message || "Something went wrong."}`,
});
}
this.state.sending = false;
this.scrollToBottom();
}
onKeyDown(ev) {
if (ev.key === "Enter" && !ev.shiftKey) {
ev.preventDefault();

View File

@@ -3,16 +3,60 @@
<t t-name="fusion_accounting.ChatPanel">
<div class="fusion_chat_panel card h-100 d-flex flex-column">
<div class="card-header d-flex justify-content-between align-items-center py-2">
<div>
<div class="d-flex align-items-center">
<h5 class="mb-0 d-inline"><i class="fa fa-comments-o me-2"/>Fusion AI</h5>
<small class="text-muted ms-2" t-if="state.sessionName" t-esc="state.sessionName"/>
</div>
<button class="btn btn-outline-secondary btn-sm" t-on-click="onNewChat"
title="Start a new conversation">
<i class="fa fa-plus me-1"/>New Chat
</button>
<div class="d-flex gap-1 align-items-center">
<!-- Session history button -->
<button class="btn btn-outline-secondary btn-sm"
t-on-click="toggleSessionPicker"
title="Load previous session">
<i class="fa fa-history"/>
</button>
<button class="btn btn-outline-secondary btn-sm" t-on-click="onNewChat"
title="Start a new conversation">
<i class="fa fa-plus me-1"/>New Chat
</button>
</div>
</div>
<!-- Session Picker Dropdown -->
<t t-if="state.showSessionPicker">
<div class="fusion_session_picker border-bottom">
<div class="p-2 bg-body-tertiary">
<div class="d-flex justify-content-between align-items-center mb-1">
<small class="fw-semibold text-muted">Recent Sessions</small>
<button class="btn-close btn-close-sm" t-on-click="toggleSessionPicker"/>
</div>
<t t-if="state.sessionList.length === 0">
<p class="text-muted small mb-0">No previous sessions found.</p>
</t>
<div class="fusion_session_list overflow-auto" style="max-height: 200px;">
<t t-foreach="state.sessionList" t-as="sess" t-key="sess.id">
<div class="fusion_session_item d-flex justify-content-between align-items-center p-2 rounded cursor-pointer"
t-att-class="sess.id === state.internalSessionId ? 'bg-primary-subtle' : ''"
t-on-click="() => this.loadSession(sess.id)">
<div>
<div class="small fw-semibold" t-esc="sess.name"/>
<div class="text-muted" style="font-size: 0.72rem;">
<t t-esc="formatSessionDate(sess.date)"/>
<span class="ms-2" t-if="sess.message_count">
<t t-esc="sess.message_count"/> msgs
</span>
<span class="ms-1 badge"
t-att-class="sess.state === 'active' ? 'bg-success-subtle text-success' : 'bg-secondary-subtle text-secondary'"
t-esc="sess.state"/>
</div>
</div>
<i class="fa fa-chevron-right text-muted" style="font-size: 0.7rem;"/>
</div>
</t>
</div>
</div>
</div>
</t>
<!-- Messages -->
<div class="fusion_chat_messages flex-grow-1 overflow-auto p-3" t-ref="messages">
<t t-if="state.loading">

View File

@@ -0,0 +1,164 @@
/** @odoo-module **/
import { Component, useState } from "@odoo/owl";
export class FusionInteractiveTable extends Component {
static template = "fusion_accounting.InteractiveTable";
static props = ["tableData", "onTableAction"];
setup() {
const rows = (this.props.tableData.rows || []).map((row) => ({
...row,
selected: false,
userNote: "",
}));
this.state = useState({
rows,
selectAll: false,
});
}
get isInteractive() {
return this.props.tableData.mode === "interactive";
}
get columns() {
return this.props.tableData.columns || [];
}
get title() {
return this.props.tableData.title || "";
}
get actions() {
return this.props.tableData.actions || [];
}
get selectedCount() {
return this.state.rows.filter((r) => r.selected).length;
}
get hasAction() {
return (action) => this.actions.includes(action);
}
actionAvailable(action) {
return this.actions.includes(action);
}
recommendationClass(action) {
switch (action) {
case "dismiss":
return "bg-success-subtle text-success";
case "flag":
return "bg-warning-subtle text-warning";
case "create_rule":
return "bg-info-subtle text-info";
default:
return "bg-secondary-subtle text-secondary";
}
}
recommendationLabel(action) {
switch (action) {
case "dismiss":
return "Dismiss";
case "flag":
return "Flag";
case "create_rule":
return "Create Rule";
default:
return action || "Review";
}
}
onToggleSelectAll() {
const newVal = !this.state.selectAll;
this.state.selectAll = newVal;
for (const row of this.state.rows) {
row.selected = newVal;
}
}
onToggleRow(rowIndex) {
this.state.rows[rowIndex].selected = !this.state.rows[rowIndex].selected;
this.state.selectAll = this.state.rows.every((r) => r.selected);
}
onNoteInput(rowIndex, ev) {
this.state.rows[rowIndex].userNote = ev.target.value;
}
_collectSelected() {
return this.state.rows
.filter((r) => r.selected)
.map((r) => ({
id: r.id,
cells: r.cells,
recommendation: r.recommendation,
userNote: r.userNote,
}));
}
_collectAllNotes() {
return this.state.rows
.filter((r) => r.userNote.trim())
.map((r) => ({
id: r.id,
cells: r.cells,
recommendation: r.recommendation,
userNote: r.userNote,
}));
}
onApplyRecommendations() {
const selected = this._collectSelected();
if (!selected.length) return;
this.props.onTableAction({
action: "apply_recommendations",
source_tool: this.props.tableData.source_tool,
rows: selected,
});
}
onFlagSelected() {
const selected = this._collectSelected();
if (!selected.length) return;
this.props.onTableAction({
action: "flag",
source_tool: this.props.tableData.source_tool,
rows: selected,
});
}
onCreateRules() {
const selected = this._collectSelected();
if (!selected.length) return;
this.props.onTableAction({
action: "create_rule",
source_tool: this.props.tableData.source_tool,
rows: selected,
});
}
onDismissSelected() {
const selected = this._collectSelected();
if (!selected.length) return;
this.props.onTableAction({
action: "dismiss",
source_tool: this.props.tableData.source_tool,
rows: selected,
});
}
onSubmitNotes() {
const noted = this._collectAllNotes();
if (!noted.length) return;
this.props.onTableAction({
action: "submit_notes",
source_tool: this.props.tableData.source_tool,
rows: noted,
});
}
}

View File

@@ -0,0 +1,121 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_accounting.InteractiveTable">
<div class="fusion_interactive_table my-2">
<!-- Title -->
<t t-if="title">
<div class="d-flex align-items-center mb-2">
<i class="fa fa-table me-2 text-muted"/>
<strong t-esc="title"/>
<span class="badge bg-secondary-subtle text-secondary ms-2"
t-esc="state.rows.length + ' rows'"/>
</div>
</t>
<!-- Table -->
<div class="table-responsive">
<table class="table table-sm table-hover align-middle mb-0">
<thead>
<tr>
<!-- Checkbox column (interactive only) -->
<t t-if="isInteractive">
<th class="fit-content px-2">
<input type="checkbox"
class="form-check-input"
t-att-checked="state.selectAll"
t-on-change="onToggleSelectAll"/>
</th>
</t>
<!-- Data columns -->
<t t-foreach="columns" t-as="col" t-key="col_index">
<th class="px-2 py-1" t-esc="col"/>
</t>
<!-- AI Recommendation column (interactive only) -->
<t t-if="isInteractive">
<th class="px-2 py-1 text-info">AI Recommendation</th>
<th class="px-2 py-1 text-warning" style="min-width: 180px;">Your Input</th>
</t>
</tr>
</thead>
<tbody>
<t t-foreach="state.rows" t-as="row" t-key="row_index">
<tr t-att-class="row.selected ? 'table-active' : ''">
<!-- Checkbox -->
<t t-if="isInteractive">
<td class="fit-content px-2">
<input type="checkbox"
class="form-check-input"
t-att-checked="row.selected"
t-on-change="() => this.onToggleRow(row_index)"/>
</td>
</t>
<!-- Data cells -->
<t t-foreach="row.cells" t-as="cell" t-key="cell_index">
<td class="px-2 py-1" t-esc="cell"/>
</t>
<!-- AI Recommendation -->
<t t-if="isInteractive">
<td class="px-2 py-1">
<t t-if="row.recommendation">
<span t-att-class="'badge me-1 ' + recommendationClass(row.recommendation.action)"
t-esc="recommendationLabel(row.recommendation.action)"/>
<small class="text-muted" t-esc="row.recommendation.reason"/>
</t>
</td>
<!-- User input -->
<td class="px-2 py-1">
<input type="text"
class="form-control form-control-sm fusion_row_note"
placeholder="Add your note..."
t-att-value="row.userNote"
t-on-input="(ev) => this.onNoteInput(row_index, ev)"/>
</td>
</t>
</tr>
</t>
</tbody>
</table>
</div>
<!-- Bulk Action Bar (interactive only) -->
<t t-if="isInteractive">
<div class="fusion_table_action_bar d-flex flex-wrap align-items-center gap-2 p-2 border-top">
<small class="text-muted me-1">
<t t-esc="selectedCount"/> selected
</small>
<button class="btn btn-success btn-sm"
t-att-disabled="selectedCount === 0"
t-on-click="onApplyRecommendations">
<i class="fa fa-check me-1"/>Apply Recommendations
</button>
<t t-if="actionAvailable('flag')">
<button class="btn btn-outline-warning btn-sm"
t-att-disabled="selectedCount === 0"
t-on-click="onFlagSelected">
<i class="fa fa-flag me-1"/>Flag Selected
</button>
</t>
<t t-if="actionAvailable('create_rule')">
<button class="btn btn-outline-info btn-sm"
t-att-disabled="selectedCount === 0"
t-on-click="onCreateRules">
<i class="fa fa-plus me-1"/>Create Rules
</button>
</t>
<t t-if="actionAvailable('dismiss')">
<button class="btn btn-outline-secondary btn-sm"
t-att-disabled="selectedCount === 0"
t-on-click="onDismissSelected">
Dismiss Selected
</button>
</t>
<div class="flex-grow-1"/>
<button class="btn btn-outline-primary btn-sm"
t-on-click="onSubmitNotes">
<i class="fa fa-pencil me-1"/>Submit All Notes to AI
</button>
</div>
</t>
</div>
</t>
</templates>

View File

@@ -17,35 +17,52 @@
</t>
<t t-else="">
<!-- Health Cards -->
<div class="fusion_health_cards d-flex flex-wrap gap-3 p-3">
<t t-foreach="cards" t-as="card" t-key="card.domain">
<FusionHealthCard
title="card.title"
metric="card.metric"
subtext="card.subtext"
status="card.status"
domain="card.domain"
onCardClick.bind="onCardClick"/>
</t>
</div>
<!-- Main layout: Left panel (cards + needs attention) | Right panel (chat) -->
<div class="fusion_main_layout d-flex">
<!-- Action Centre + Chat -->
<div class="d-flex gap-3 p-3" style="min-height: 500px;">
<!-- Action Centre -->
<div class="flex-grow-1">
<div class="card h-100">
<div class="card-header">
<h5 class="mb-0">Needs Attention</h5>
<!-- LEFT SIDE: Cards (2 rows of 3) + Needs Attention -->
<div class="fusion_left_panel d-flex flex-column p-3 gap-3">
<!-- Health Cards: 2 rows x 3 cards -->
<div class="fusion_health_cards d-flex flex-wrap gap-2">
<t t-foreach="cards" t-as="card" t-key="card.domain">
<FusionHealthCard
title="card.title"
metric="card.metric"
subtext="card.subtext"
status="card.status"
domain="card.domain"
onCardClick.bind="onCardClick"/>
</t>
</div>
<!-- Needs Attention Panel -->
<div class="card fusion_attention_card">
<div class="card-header py-2">
<h5 class="mb-0"><i class="fa fa-exclamation-triangle me-2 text-warning"/>Needs Attention</h5>
</div>
<div class="card-body overflow-auto">
<p class="text-muted">AI-prioritised items will appear here after the first audit scan.</p>
<div class="card-body overflow-auto p-2">
<t t-if="state.data and state.data.needs_attention and state.data.needs_attention.length">
<t t-foreach="state.data.needs_attention" t-as="item" t-key="item_index">
<div class="fusion_attention_item d-flex align-items-start gap-2 p-2 rounded mb-1 cursor-pointer"
t-on-click="() => this.onCardClick(item.domain)">
<i class="fa fa-circle-o text-warning mt-1" style="font-size: 0.6rem;"/>
<div>
<div class="fw-semibold small" t-esc="item.title"/>
<div class="text-muted" style="font-size: 0.78rem;" t-esc="item.action"/>
</div>
</div>
</t>
</t>
<t t-else="">
<p class="text-muted small mb-0">AI-prioritised items will appear here after the first audit scan.</p>
</t>
</div>
</div>
</div>
<!-- Chat Panel (720px = original 400 + 80%) -->
<div style="width: 720px; min-width: 600px;">
<!-- RIGHT SIDE: Chat Panel (full height, input pinned to bottom) -->
<div class="fusion_right_panel border-start">
<FusionChatPanel sessionId="state.chatSessionId"/>
</div>
</div>

View File

@@ -60,7 +60,20 @@
}
}
// Session picker dropdown
.fusion_session_picker {
flex-shrink: 0;
.fusion_session_item {
transition: background 0.15s ease;
&:hover {
background: rgba(var(--bs-body-color-rgb), 0.06);
}
}
}
.fusion_chat_input {
flex-shrink: 0;
textarea {
resize: none;
}
@@ -69,4 +82,83 @@
.fusion_approval_card {
border-left: 3px solid var(--bs-warning);
}
// Interactive table styles
.fusion_interactive_table {
border: 1px solid var(--o-border-color);
border-radius: 0.375rem;
overflow: hidden;
background: var(--o-view-background-color);
.table {
font-size: 0.85rem;
margin-bottom: 0;
thead th {
font-weight: 600;
font-size: 0.8rem;
white-space: nowrap;
background: rgba(var(--bs-body-color-rgb), 0.03);
border-bottom: 2px solid var(--o-border-color);
}
tbody tr {
transition: background-color 0.15s ease;
&:hover {
background: rgba(var(--bs-body-color-rgb), 0.04);
}
&.table-active {
background: rgba(var(--bs-primary-rgb), 0.06);
}
}
td {
vertical-align: middle;
}
}
.fit-content {
width: 1%;
white-space: nowrap;
}
// Row note input
.fusion_row_note {
font-size: 0.8rem;
padding: 0.2rem 0.4rem;
background: transparent;
border: 1px solid var(--o-border-color);
color: inherit;
&:focus {
background: var(--o-view-background-color);
border-color: var(--o-action-color, var(--bs-primary));
box-shadow: 0 0 0 0.15rem rgba(var(--bs-primary-rgb), 0.15);
}
&::placeholder {
opacity: 0.4;
}
}
// Recommendation badges
.badge {
font-size: 0.7rem;
font-weight: 500;
padding: 0.2em 0.5em;
}
// Action bar at bottom
.fusion_table_action_bar {
background: rgba(var(--bs-body-color-rgb), 0.02);
border-top: 1px solid var(--o-border-color);
.btn-sm {
font-size: 0.78rem;
padding: 0.25rem 0.6rem;
}
}
}
}

View File

@@ -1,11 +1,41 @@
.fusion_accounting_dashboard {
// Fill the available Odoo content area (below navbar + menu bar)
// Use 100% of parent instead of 100vh to respect Odoo's own layout
display: flex;
flex-direction: column;
height: 100%;
.fusion_dashboard_header {
border-bottom: 1px solid var(--o-border-color);
background: var(--o-view-background-color);
flex-shrink: 0;
}
// Main two-column layout — must fill remaining height
.fusion_main_layout {
flex: 1;
// This is the key: prevent the flex container from growing beyond
// the viewport, which would push the chat input off-screen
min-height: 0;
overflow: hidden;
}
// Left panel: cards + needs attention (scrollable)
.fusion_left_panel {
width: 50%;
min-width: 400px;
max-width: 600px;
overflow-y: auto;
flex-shrink: 0;
}
// Health cards: 3 per row
.fusion_health_cards {
flex-shrink: 0;
.fusion_health_card {
flex: 0 0 calc(33.333% - 6px);
min-width: 150px;
transition: transform 0.15s ease, box-shadow 0.15s ease;
&:hover {
transform: translateY(-2px);
@@ -13,4 +43,71 @@
}
}
}
// Needs Attention: fill remaining left panel space
.fusion_attention_card {
flex: 1;
min-height: 150px;
overflow: hidden;
.card-body {
overflow-y: auto;
}
}
// Needs Attention items
.fusion_attention_item {
transition: background 0.15s ease;
&:hover {
background: rgba(var(--bs-body-color-rgb), 0.04);
}
}
// Right panel: chat takes all remaining width and height
.fusion_right_panel {
flex: 1;
min-width: 500px;
display: flex;
flex-direction: column;
// Critical: prevent overflow so chat input stays visible
min-height: 0;
overflow: hidden;
// Override chat panel to fill the container
.fusion_chat_panel {
// Fill the right panel completely
flex: 1;
display: flex;
flex-direction: column;
border-radius: 0;
border: none;
// Must not exceed container
min-height: 0;
height: auto !important;
.card-header {
flex-shrink: 0;
}
.fusion_chat_messages {
// Override base chat.scss values that break flex layout
max-height: none !important;
min-height: 0 !important;
// Grow to fill, but scrollable
flex: 1;
overflow-y: auto;
}
.fusion_chat_input {
flex-shrink: 0;
}
}
}
}
// Also ensure the Odoo action container gives us full height
.o_action_manager {
.o_action.fusion_accounting_dashboard {
height: 100%;
}
}

View File

@@ -36,6 +36,22 @@
sequence="40"
groups="group_fusion_accounting_manager"/>
<!-- Vendor Tax Profiles -->
<menuitem id="menu_fusion_vendor_profiles"
name="Vendor Tax Profiles"
parent="menu_fusion_accounting_root"
action="action_vendor_tax_profiles"
sequence="50"
groups="group_fusion_accounting_manager"/>
<!-- Recurring Patterns -->
<menuitem id="menu_fusion_recurring_patterns"
name="Recurring Patterns"
parent="menu_fusion_accounting_root"
action="action_recurring_patterns"
sequence="55"
groups="group_fusion_accounting_manager"/>
<!-- Configuration (link to settings) -->
<menuitem id="menu_fusion_config"
name="Configuration"

View File

@@ -0,0 +1,86 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_recurring_pattern_list" model="ir.ui.view">
<field name="name">fusion.recurring.pattern.list</field>
<field name="model">fusion.recurring.pattern</field>
<field name="arch" type="xml">
<list>
<field name="name"/>
<field name="ref_keyword"/>
<field name="amount"/>
<field name="expense_account_code" string="Account"/>
<field name="has_hst"/>
<field name="partner_id"/>
<field name="reconcile_model_id"/>
<field name="occurrences"/>
<field name="last_seen"/>
</list>
</field>
</record>
<record id="view_recurring_pattern_form" model="ir.ui.view">
<field name="name">fusion.recurring.pattern.form</field>
<field name="model">fusion.recurring.pattern</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<group string="Pattern">
<field name="name"/>
<field name="ref_keyword"/>
<field name="amount"/>
<field name="amount_is_fixed"/>
<field name="journal_id"/>
</group>
<group string="Coding">
<field name="expense_account_id"/>
<field name="has_hst"/>
<field name="partner_id"/>
<field name="reconcile_model_id"/>
</group>
</group>
<group string="Statistics">
<group>
<field name="occurrences"/>
<field name="first_seen"/>
<field name="last_seen"/>
</group>
<group>
<field name="company_id" groups="base.group_multi_company"/>
<field name="last_computed"/>
</group>
</group>
<group string="AI Instructions">
<field name="action_note" nolabel="1" colspan="2"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="view_recurring_pattern_search" model="ir.ui.view">
<field name="name">fusion.recurring.pattern.search</field>
<field name="model">fusion.recurring.pattern</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<field name="ref_keyword"/>
<separator/>
<filter name="has_hst" string="Has HST" domain="[('has_hst', '=', True)]"/>
<filter name="no_hst" string="No HST" domain="[('has_hst', '=', False)]"/>
<filter name="has_reco_model" string="Has Reco Model" domain="[('reconcile_model_id', '!=', False)]"/>
<separator/>
<group>
<filter name="group_account" string="Account" domain="[]" context="{'group_by': 'expense_account_id'}"/>
</group>
</search>
</field>
</record>
<record id="action_recurring_patterns" model="ir.actions.act_window">
<field name="name">Recurring Patterns</field>
<field name="res_model">fusion.recurring.pattern</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_recurring_pattern_search"/>
</record>
</odoo>

View File

@@ -0,0 +1,95 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- Tree View -->
<record id="view_vendor_tax_profile_tree" model="ir.ui.view">
<field name="name">fusion.vendor.tax.profile.tree</field>
<field name="model">fusion.vendor.tax.profile</field>
<field name="arch" type="xml">
<list>
<field name="partner_id"/>
<field name="tax_classification" widget="badge"
decoration-success="tax_classification == 'always_hst'"
decoration-warning="tax_classification in ('shipping_only', 'mixed')"
decoration-danger="tax_classification == 'never_hst'"
decoration-info="tax_classification == 'mostly_hst'"/>
<field name="total_bills"/>
<field name="bills_with_hst"/>
<field name="bills_zero_rated"/>
<field name="avg_tax_pct" string="Avg Tax %"/>
<field name="primary_account_code" string="Primary Account"/>
<field name="is_po_vendor"/>
<field name="po_count" optional="hide"/>
<field name="is_foreign"/>
<field name="last_computed"/>
</list>
</field>
</record>
<!-- Form View -->
<record id="view_vendor_tax_profile_form" model="ir.ui.view">
<field name="name">fusion.vendor.tax.profile.form</field>
<field name="model">fusion.vendor.tax.profile</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<group string="Vendor">
<field name="partner_id"/>
<field name="company_id" groups="base.group_multi_company"/>
<field name="is_foreign"/>
<field name="vendor_country"/>
</group>
<group string="Tax Classification">
<field name="tax_classification"/>
<field name="avg_tax_pct"/>
<field name="primary_account_id"/>
</group>
</group>
<group string="Bill Statistics">
<group>
<field name="total_bills"/>
<field name="bills_with_hst"/>
<field name="bills_zero_rated"/>
</group>
<group>
<field name="last_computed"/>
</group>
</group>
<group string="AI Tax Note">
<field name="tax_note" nolabel="1" colspan="2"/>
</group>
</sheet>
</form>
</field>
</record>
<!-- Search View -->
<record id="view_vendor_tax_profile_search" model="ir.ui.view">
<field name="name">fusion.vendor.tax.profile.search</field>
<field name="model">fusion.vendor.tax.profile</field>
<field name="arch" type="xml">
<search>
<field name="partner_id"/>
<field name="tax_classification"/>
<separator/>
<filter name="always_hst" string="Always HST" domain="[('tax_classification', '=', 'always_hst')]"/>
<filter name="never_hst" string="Never HST" domain="[('tax_classification', '=', 'never_hst')]"/>
<filter name="shipping_only" string="Shipping Only" domain="[('tax_classification', '=', 'shipping_only')]"/>
<filter name="mixed" string="Mixed" domain="[('tax_classification', 'in', ('mixed', 'mostly_hst'))]"/>
<filter name="foreign" string="Foreign Vendors" domain="[('is_foreign', '=', True)]"/>
<separator/>
<group>
<filter name="group_classification" string="Classification" domain="[]" context="{'group_by': 'tax_classification'}"/>
</group>
</search>
</field>
</record>
<!-- Action -->
<record id="action_vendor_tax_profiles" model="ir.actions.act_window">
<field name="name">Vendor Tax Profiles</field>
<field name="res_model">fusion.vendor.tax.profile</field>
<field name="view_mode">tree,form</field>
<field name="search_view_id" ref="view_vendor_tax_profile_search"/>
</record>
</odoo>

View File

@@ -24,10 +24,12 @@
'views/account_move_views.xml',
'views/sale_order_views.xml',
'views/res_config_settings_views.xml',
'views/poynt_settlement_views.xml',
'wizard/poynt_payment_wizard_views.xml',
'wizard/poynt_refund_wizard_views.xml',
'data/payment_provider_data.xml',
'data/poynt_settlement_data.xml',
'data/poynt_receipt_email_template.xml',
],
'post_init_hook': 'post_init_hook',

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<!-- Sequence for settlement batch references -->
<record id="seq_poynt_settlement_batch" model="ir.sequence">
<field name="name">Poynt Settlement Batch</field>
<field name="code">poynt.settlement.batch</field>
<field name="prefix">SETTLE/%(year)s/%(month)s/</field>
<field name="padding">3</field>
<field name="company_id" eval="False"/>
</record>
<!-- Daily settlement sync cron -->
<record id="ir_cron_poynt_settlement_sync" model="ir.cron">
<field name="name">Poynt: Daily Settlement Sync</field>
<field name="model_id" ref="model_poynt_settlement_batch"/>
<field name="state">code</field>
<field name="code">model._cron_daily_settlement_sync()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="active">True</field>
</record>
</odoo>

View File

@@ -4,6 +4,7 @@ from . import account_move
from . import payment_provider
from . import payment_token
from . import payment_transaction
from . import poynt_settlement
from . import poynt_terminal
from . import res_config_settings
from . import sale_order

View File

@@ -539,6 +539,61 @@ class PaymentProvider(models.Model):
)
return None
# === BUSINESS METHODS - SETTLEMENT === #
def _poynt_fetch_settlement_transactions(self, date_from, date_to):
"""Fetch all transactions from Poynt API for a date range.
Paginates through results using startOffset. Filters for settled
SALE and REFUND transactions.
:param date date_from: Start date (inclusive).
:param date date_to: End date (inclusive).
:return: List of transaction dicts from the Poynt API.
:rtype: list[dict]
"""
self.ensure_one()
# Convert dates to ISO8601 timestamps (start of day / end of day)
start_at = f"{date_from}T00:00:00Z"
end_at = f"{date_to}T23:59:59Z"
all_transactions = []
offset = 0
limit = 100
while True:
params = {
'startAt': start_at,
'endAt': end_at,
'limit': limit,
'startOffset': offset,
}
result = self._poynt_make_request(
'GET', 'transactions', params=params,
)
transactions = result.get('transactions', [])
if not transactions:
# Also check if result itself is a list (API version variance)
if isinstance(result, list):
transactions = result
else:
break
all_transactions.extend(transactions)
# Check if there are more pages
if len(transactions) < limit:
break
offset += limit
_logger.info(
"Poynt: fetched %d transactions for %s to %s",
len(all_transactions), date_from, date_to,
)
return all_transactions
def _poynt_notification(self, message, notification_type='info'):
"""Return a display_notification action.

View File

@@ -0,0 +1,632 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
from datetime import timedelta
from odoo import _, api, fields, models
from odoo.exceptions import UserError, ValidationError
_logger = logging.getLogger(__name__)
class PoyntSettlementBatch(models.Model):
_name = 'poynt.settlement.batch'
_description = 'Poynt Settlement Batch'
_order = 'settlement_date desc, id desc'
_rec_name = 'name'
name = fields.Char(
string="Batch Reference",
required=True,
readonly=True,
default='/',
copy=False,
)
settlement_date = fields.Date(
string="Settlement Date",
required=True,
help="The date Elavon deposits into the bank (T+1 business day from transactions).",
)
transaction_date = fields.Date(
string="Transaction Date",
required=True,
help="The date card transactions were processed at the terminal.",
)
provider_id = fields.Many2one(
'payment.provider',
string="Payment Provider",
required=True,
domain="[('code', '=', 'poynt')]",
ondelete='restrict',
)
bank_statement_line_id = fields.Many2one(
'account.bank.statement.line',
string="Bank Statement Line",
help="The Elavon deposit line on the bank statement.",
ondelete='set null',
)
line_ids = fields.One2many(
'poynt.settlement.line',
'batch_id',
string="Settlement Lines",
)
state = fields.Selection([
('draft', "Draft"),
('matched', "Matched"),
('reconciled', "Reconciled"),
('error', "Error"),
], string="Status", required=True, default='draft', tracking=True)
currency_id = fields.Many2one(
'res.currency',
string="Currency",
required=True,
default=lambda self: self.env.company.currency_id,
)
poynt_total = fields.Monetary(
string="Poynt Total",
currency_field='currency_id',
compute='_compute_totals',
store=True,
help="Sum of all Poynt transactions (sales - refunds) for this batch.",
)
elavon_deposit = fields.Monetary(
string="Elavon Deposit",
currency_field='currency_id',
help="The amount Elavon deposited into the bank account.",
)
fee_amount = fields.Monetary(
string="Processing Fees",
currency_field='currency_id',
compute='_compute_totals',
store=True,
help="Difference between Poynt total and Elavon deposit (Elavon processing fees).",
)
sale_count = fields.Integer(
string="Sales",
compute='_compute_totals',
store=True,
)
refund_count = fields.Integer(
string="Refunds",
compute='_compute_totals',
store=True,
)
matched_count = fields.Integer(
string="Matched to Customers",
compute='_compute_totals',
store=True,
)
notes = fields.Text(string="Notes")
_sql_constraints = [
('unique_provider_txn_date', 'unique(provider_id, transaction_date)',
'A settlement batch already exists for this provider and transaction date.'),
]
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if vals.get('name', '/') == '/':
vals['name'] = self.env['ir.sequence'].next_by_code(
'poynt.settlement.batch'
) or '/'
return super().create(vals_list)
@api.depends('line_ids.amount', 'line_ids.action', 'line_ids.partner_id', 'elavon_deposit')
def _compute_totals(self):
for batch in self:
sales = sum(
line.amount for line in batch.line_ids if line.action == 'SALE'
)
refunds = sum(
line.amount for line in batch.line_ids if line.action == 'REFUND'
)
net = sales - refunds
batch.poynt_total = net
batch.fee_amount = net - batch.elavon_deposit if batch.elavon_deposit else 0.0
batch.sale_count = len(batch.line_ids.filtered(lambda l: l.action == 'SALE'))
batch.refund_count = len(batch.line_ids.filtered(lambda l: l.action == 'REFUND'))
batch.matched_count = len(batch.line_ids.filtered(lambda l: l.partner_id))
# === BUSINESS METHODS === #
def action_fetch_transactions(self):
"""Fetch Poynt transactions for this batch's transaction date."""
self.ensure_one()
if self.line_ids:
raise UserError(_("This batch already has transaction lines. Clear them first."))
provider = self.provider_id
transactions = provider._poynt_fetch_settlement_transactions(
self.transaction_date, self.transaction_date,
)
lines_vals = []
existing_txn_ids = set()
for txn in transactions:
txn_id = txn.get('id', '')
if txn_id in existing_txn_ids:
continue
existing_txn_ids.add(txn_id)
action = txn.get('action', '')
if action not in ('SALE', 'REFUND'):
continue
status = txn.get('processorResponse', {}).get('status', '')
settlement = txn.get('settlementStatus', '')
if status != 'Approved' and settlement != 'SETTLED':
continue
amounts = txn.get('amounts', {})
amount_cents = amounts.get('transactionAmount', 0)
amount = amount_cents / 100.0
card = txn.get('fundingSource', {}).get('card', {})
# Convert ISO 8601 timestamp (2025-03-05T19:19:10Z) to Odoo format
created_at = txn.get('createdAt', '')
if created_at:
created_at = created_at.replace('T', ' ').replace('Z', '')
lines_vals.append({
'batch_id': self.id,
'poynt_transaction_id': txn_id,
'poynt_order_id': txn.get('context', {}).get('orderId', ''),
'transaction_date': created_at,
'amount': amount,
'card_brand': card.get('type', ''),
'card_last4': card.get('numberLast4', ''),
'card_holder_name': card.get('cardHolderFullName', ''),
'action': action,
'state': 'fetched',
})
if lines_vals:
self.env['poynt.settlement.line'].create(lines_vals)
_logger.info(
"Poynt settlement batch %s: fetched %d transactions for %s",
self.name, len(lines_vals), self.transaction_date,
)
return True
def action_match_deposit(self):
"""Match this batch to an Elavon bank statement line."""
self.ensure_one()
if not self.line_ids:
raise UserError(_("No transaction lines to match. Fetch transactions first."))
# Look for Elavon deposit on the settlement date (or ±1 day for timing)
StmtLine = self.env['account.bank.statement.line']
domain = [
('journal_id.name', 'ilike', 'Scotia'),
('date', '>=', self.settlement_date - timedelta(days=1)),
('date', '<=', self.settlement_date + timedelta(days=1)),
('amount', '>', 0),
('payment_ref', 'ilike', 'ELAVON'),
('is_reconciled', '=', False),
]
candidates = StmtLine.search(domain, order='date asc')
if not candidates:
self.notes = f"No unreconciled Elavon deposit found near {self.settlement_date}"
return False
# Try to find the closest match by amount
net_amount = self.poynt_total
best_match = None
best_diff = float('inf')
for line in candidates:
diff = abs(line.amount - net_amount)
# Allow up to 5% tolerance for processing fees
if diff < best_diff and diff <= net_amount * 0.05:
best_diff = diff
best_match = line
if best_match:
self.write({
'bank_statement_line_id': best_match.id,
'elavon_deposit': best_match.amount,
'settlement_date': best_match.date,
'state': 'matched',
})
_logger.info(
"Poynt batch %s matched to bank line %s (deposit $%.2f, fees $%.2f)",
self.name, best_match.id, best_match.amount, self.fee_amount,
)
return True
else:
self.notes = (
f"No matching Elavon deposit found. "
f"Poynt net: ${net_amount:.2f}, "
f"closest candidate: ${candidates[0].amount:.2f}"
)
return False
def action_match_customers(self):
"""Attempt to match settlement lines to Odoo customers and invoices."""
self.ensure_one()
matched = 0
for line in self.line_ids.filtered(lambda l: not l.partner_id and l.action == 'SALE'):
if line._match_to_customer():
matched += 1
_logger.info(
"Poynt batch %s: matched %d/%d lines to customers",
self.name, matched, len(self.line_ids),
)
return True
def action_create_payments(self):
"""Create account.payment records for matched settlement lines."""
self.ensure_one()
if self.state == 'reconciled':
raise UserError(_("This batch is already reconciled."))
payable_lines = self.line_ids.filtered(
lambda l: l.partner_id and l.action == 'SALE' and l.state in ('fetched', 'matched') and not l.payment_id
)
if not payable_lines:
raise UserError(_("No matched lines available for payment creation."))
for line in payable_lines:
line._create_customer_payment()
# Check if all lines are processed
all_paid = all(
l.state in ('paid', 'error') or l.action == 'REFUND'
for l in self.line_ids
)
if all_paid:
self.state = 'reconciled'
return True
def action_reset_to_draft(self):
"""Reset batch to draft state."""
self.ensure_one()
self.write({'state': 'draft'})
return True
# === CRON === #
@api.model
def _cron_daily_settlement_sync(self):
"""Daily cron: fetch yesterday's transactions, match to today's deposit."""
provider = self.env['payment.provider'].search([
('code', '=', 'poynt'),
('state', '=', 'enabled'),
], limit=1)
if not provider:
_logger.info("Poynt settlement cron: no active Poynt provider found.")
return
yesterday = fields.Date.today() - timedelta(days=1)
today = fields.Date.today()
# Check if batch already exists
existing = self.search([
('provider_id', '=', provider.id),
('transaction_date', '=', yesterday),
])
if existing:
_logger.info("Poynt settlement cron: batch for %s already exists.", yesterday)
return
# Handle weekend: if today is Monday, fetch Fri+Sat+Sun
weekday = yesterday.weekday() # 0=Monday, 6=Sunday
if weekday == 6: # Sunday → fetch Fri-Sun, deposit Monday
txn_date_from = yesterday - timedelta(days=2) # Friday
elif weekday == 5: # Saturday → skip, will be batched with Sunday
_logger.info("Poynt settlement cron: Saturday — will batch with Sunday/Monday.")
return
else:
txn_date_from = yesterday
batch = self.create({
'provider_id': provider.id,
'transaction_date': txn_date_from,
'settlement_date': today,
})
try:
# Fetch all transactions for the date range
transactions = provider._poynt_fetch_settlement_transactions(
txn_date_from, yesterday,
)
lines_vals = []
seen = set()
for txn in transactions:
txn_id = txn.get('id', '')
if txn_id in seen:
continue
seen.add(txn_id)
action = txn.get('action', '')
if action not in ('SALE', 'REFUND'):
continue
status = txn.get('processorResponse', {}).get('status', '')
settlement = txn.get('settlementStatus', '')
if status != 'Approved' and settlement != 'SETTLED':
continue
amounts = txn.get('amounts', {})
amount = amounts.get('transactionAmount', 0) / 100.0
card = txn.get('fundingSource', {}).get('card', {})
# Convert ISO 8601 timestamp to Odoo format
created_at = txn.get('createdAt', '')
if created_at:
created_at = created_at.replace('T', ' ').replace('Z', '')
lines_vals.append({
'batch_id': batch.id,
'poynt_transaction_id': txn_id,
'poynt_order_id': txn.get('context', {}).get('orderId', ''),
'transaction_date': created_at,
'amount': amount,
'card_brand': card.get('type', ''),
'card_last4': card.get('numberLast4', ''),
'card_holder_name': card.get('cardHolderFullName', ''),
'action': action,
'state': 'fetched',
})
if lines_vals:
self.env['poynt.settlement.line'].create(lines_vals)
# Try to match to bank deposit
batch.action_match_deposit()
# Try to match customers
batch.action_match_customers()
_logger.info(
"Poynt settlement cron: created batch %s with %d lines for %s%s",
batch.name, len(lines_vals), txn_date_from, yesterday,
)
except Exception as e:
batch.write({'state': 'error', 'notes': str(e)})
_logger.error("Poynt settlement cron failed: %s", e)
class PoyntSettlementLine(models.Model):
_name = 'poynt.settlement.line'
_description = 'Poynt Settlement Line'
_order = 'transaction_date desc, id desc'
batch_id = fields.Many2one(
'poynt.settlement.batch',
string="Settlement Batch",
required=True,
ondelete='cascade',
index=True,
)
poynt_transaction_id = fields.Char(
string="Poynt Transaction ID",
required=True,
index=True,
)
poynt_order_id = fields.Char(string="Poynt Order ID")
transaction_date = fields.Datetime(string="Transaction Date")
amount = fields.Monetary(
string="Amount",
currency_field='currency_id',
required=True,
)
currency_id = fields.Many2one(
related='batch_id.currency_id',
store=True,
)
card_brand = fields.Char(string="Card Brand")
card_last4 = fields.Char(string="Card Last 4", size=4)
card_holder_name = fields.Char(string="Cardholder Name")
partner_id = fields.Many2one(
'res.partner',
string="Customer",
ondelete='set null',
)
invoice_id = fields.Many2one(
'account.move',
string="Matched Invoice",
domain="[('move_type', '=', 'out_invoice')]",
ondelete='set null',
)
payment_id = fields.Many2one(
'account.payment',
string="Payment",
readonly=True,
ondelete='set null',
)
action = fields.Selection([
('SALE', "Sale"),
('REFUND', "Refund"),
('VOID', "Void"),
], string="Action", required=True)
state = fields.Selection([
('fetched', "Fetched"),
('matched', "Matched"),
('paid', "Payment Created"),
('error', "Error"),
], string="Status", required=True, default='fetched')
match_method = fields.Char(
string="Match Method",
help="How this line was matched to a customer (e.g., 'odoo_txn', 'card_token', 'invoice_amount', 'name').",
)
notes = fields.Text(string="Notes")
_sql_constraints = [
('unique_poynt_txn', 'unique(poynt_transaction_id)',
'This Poynt transaction has already been recorded.'),
]
# === CUSTOMER MATCHING === #
def _match_to_customer(self):
"""Attempt to match this settlement line to an Odoo customer/invoice.
Matching strategy (in priority order):
1. Check poynt_transaction_id in payment.transaction (direct Odoo payment)
2. Match by card_last4 against payment.token records
3. Match by amount against open invoices within ±2 days
4. Match by card_holder_name fuzzy search against res.partner
:return: True if matched, False otherwise.
"""
self.ensure_one()
if self.partner_id:
return True
# Strategy 1: Direct Odoo payment transaction
PaymentTxn = self.env['payment.transaction']
odoo_txn = PaymentTxn.search([
('poynt_transaction_id', '=', self.poynt_transaction_id),
], limit=1)
if odoo_txn and odoo_txn.partner_id:
self.write({
'partner_id': odoo_txn.partner_id.id,
'invoice_id': odoo_txn.invoice_ids[:1].id if odoo_txn.invoice_ids else False,
'match_method': 'odoo_txn',
'state': 'matched',
})
return True
# Strategy 2: Card token match
if self.card_last4:
token = self.env['payment.token'].search([
('payment_details', 'ilike', self.card_last4),
('provider_id.code', '=', 'poynt'),
], limit=1)
if token and token.partner_id:
self.write({
'partner_id': token.partner_id.id,
'match_method': 'card_token',
'state': 'matched',
})
# Try to find matching invoice
self._match_invoice()
return True
# Strategy 3: Amount match against open invoices
if self.amount and self.transaction_date:
date = self.transaction_date.date() if self.transaction_date else fields.Date.today()
invoices = self.env['account.move'].search([
('move_type', '=', 'out_invoice'),
('state', '=', 'posted'),
('payment_state', 'in', ('not_paid', 'partial')),
('amount_residual', '=', self.amount),
('invoice_date', '>=', date - timedelta(days=7)),
('invoice_date', '<=', date + timedelta(days=2)),
], limit=1)
if invoices:
self.write({
'partner_id': invoices.partner_id.id,
'invoice_id': invoices.id,
'match_method': 'invoice_amount',
'state': 'matched',
})
return True
# Strategy 4: Cardholder name fuzzy match
if self.card_holder_name:
name = self.card_holder_name.strip()
if len(name) >= 3:
partners = self.env['res.partner'].search([
'|',
('name', 'ilike', name),
('name', 'ilike', name.split()[-1] if ' ' in name else name),
], limit=5)
if len(partners) == 1:
self.write({
'partner_id': partners.id,
'match_method': 'name',
'state': 'matched',
})
self._match_invoice()
return True
return False
def _match_invoice(self):
"""Try to find a matching open invoice for this line's partner and amount."""
self.ensure_one()
if self.invoice_id or not self.partner_id:
return
invoices = self.env['account.move'].search([
('partner_id', '=', self.partner_id.id),
('move_type', '=', 'out_invoice'),
('state', '=', 'posted'),
('payment_state', 'in', ('not_paid', 'partial')),
('amount_residual', '=', self.amount),
], limit=1, order='invoice_date desc')
if invoices:
self.invoice_id = invoices.id
# === PAYMENT CREATION === #
def _create_customer_payment(self):
"""Create an account.payment for this matched settlement line."""
self.ensure_one()
if not self.partner_id:
self.write({'state': 'error', 'notes': 'No customer matched'})
return False
if self.payment_id:
return True
try:
# Use the provider's journal (Poynt payment journal)
journal = self.batch_id.provider_id.journal_id
if not journal:
# Fall back to first bank journal
journal = self.env['account.journal'].search([
('type', '=', 'bank'),
('company_id', '=', self.env.company.id),
], limit=1)
payment_vals = {
'partner_id': self.partner_id.id,
'amount': self.amount,
'currency_id': self.currency_id.id,
'journal_id': journal.id,
'payment_type': 'inbound',
'partner_type': 'customer',
'payment_method_line_id': journal.inbound_payment_method_line_ids[:1].id,
'memo': f"Poynt {self.card_brand or 'Card'} ****{self.card_last4 or '????'} - {self.batch_id.name}",
}
payment = self.env['account.payment'].create(payment_vals)
payment.action_post()
self.write({
'payment_id': payment.id,
'state': 'paid',
})
# Reconcile with invoice if matched
if self.invoice_id and self.invoice_id.payment_state in ('not_paid', 'partial'):
try:
(payment.move_id.line_ids + self.invoice_id.line_ids).filtered(
lambda l: l.account_id.account_type == 'asset_receivable' and not l.reconciled
).reconcile()
except Exception as e:
_logger.warning(
"Could not auto-reconcile payment %s with invoice %s: %s",
payment.name, self.invoice_id.name, e,
)
return True
except Exception as e:
self.write({'state': 'error', 'notes': str(e)})
_logger.error(
"Failed to create payment for settlement line %s: %s",
self.poynt_transaction_id, e,
)
return False

View File

@@ -8,3 +8,7 @@ access_poynt_refund_wizard_admin,poynt.refund.wizard.admin,model_poynt_refund_wi
access_payment_provider_poynt_user,payment.provider.poynt.user,payment.model_payment_provider,group_fusion_poynt_user,1,0,0,0
access_payment_transaction_poynt_user,payment.transaction.poynt.user,payment.model_payment_transaction,group_fusion_poynt_user,1,1,1,0
access_payment_method_poynt_user,payment.method.poynt.user,payment.model_payment_method,group_fusion_poynt_user,1,0,0,0
access_poynt_settlement_batch_user,poynt.settlement.batch.user,model_poynt_settlement_batch,group_fusion_poynt_user,1,0,0,0
access_poynt_settlement_batch_admin,poynt.settlement.batch.admin,model_poynt_settlement_batch,group_fusion_poynt_admin,1,1,1,1
access_poynt_settlement_line_user,poynt.settlement.line.user,model_poynt_settlement_line,group_fusion_poynt_user,1,0,0,0
access_poynt_settlement_line_admin,poynt.settlement.line.admin,model_poynt_settlement_line,group_fusion_poynt_admin,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
8 access_payment_provider_poynt_user payment.provider.poynt.user payment.model_payment_provider group_fusion_poynt_user 1 0 0 0
9 access_payment_transaction_poynt_user payment.transaction.poynt.user payment.model_payment_transaction group_fusion_poynt_user 1 1 1 0
10 access_payment_method_poynt_user payment.method.poynt.user payment.model_payment_method group_fusion_poynt_user 1 0 0 0
11 access_poynt_settlement_batch_user poynt.settlement.batch.user model_poynt_settlement_batch group_fusion_poynt_user 1 0 0 0
12 access_poynt_settlement_batch_admin poynt.settlement.batch.admin model_poynt_settlement_batch group_fusion_poynt_admin 1 1 1 1
13 access_poynt_settlement_line_user poynt.settlement.line.user model_poynt_settlement_line group_fusion_poynt_user 1 0 0 0
14 access_poynt_settlement_line_admin poynt.settlement.line.admin model_poynt_settlement_line group_fusion_poynt_admin 1 1 1 1

View File

@@ -0,0 +1,231 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ==================== SETTLEMENT BATCH ==================== -->
<!-- Tree View -->
<record id="poynt_settlement_batch_view_list" model="ir.ui.view">
<field name="name">poynt.settlement.batch.list</field>
<field name="model">poynt.settlement.batch</field>
<field name="arch" type="xml">
<list decoration-info="state == 'draft'" decoration-success="state == 'reconciled'" decoration-warning="state == 'matched'" decoration-danger="state == 'error'">
<field name="name"/>
<field name="transaction_date"/>
<field name="settlement_date"/>
<field name="sale_count"/>
<field name="refund_count"/>
<field name="poynt_total" sum="Total"/>
<field name="elavon_deposit" sum="Total"/>
<field name="fee_amount" sum="Total"/>
<field name="matched_count"/>
<field name="state" widget="badge"
decoration-info="state == 'draft'"
decoration-success="state == 'reconciled'"
decoration-warning="state == 'matched'"
decoration-danger="state == 'error'"/>
</list>
</field>
</record>
<!-- Form View -->
<record id="poynt_settlement_batch_view_form" model="ir.ui.view">
<field name="name">poynt.settlement.batch.form</field>
<field name="model">poynt.settlement.batch</field>
<field name="arch" type="xml">
<form>
<header>
<button name="action_fetch_transactions" type="object"
string="Fetch Transactions" class="btn-primary"
invisible="line_ids or state != 'draft'"/>
<button name="action_match_deposit" type="object"
string="Match Bank Deposit" class="btn-primary"
invisible="state != 'draft' or not line_ids"/>
<button name="action_match_customers" type="object"
string="Match Customers" class="btn-secondary"
invisible="state not in ('draft', 'matched')"/>
<button name="action_create_payments" type="object"
string="Create Payments" class="btn-primary"
invisible="state not in ('matched',)"
confirm="This will create customer payment records for all matched lines. Continue?"/>
<button name="action_reset_to_draft" type="object"
string="Reset to Draft" class="btn-secondary"
invisible="state in ('draft', 'reconciled')"/>
<field name="state" widget="statusbar" statusbar_visible="draft,matched,reconciled"/>
</header>
<sheet>
<div class="oe_title">
<h1>
<field name="name" readonly="1"/>
</h1>
</div>
<group>
<group>
<field name="transaction_date"/>
<field name="settlement_date"/>
<field name="provider_id"/>
</group>
<group>
<field name="poynt_total"/>
<field name="elavon_deposit"/>
<field name="fee_amount"/>
<field name="currency_id" invisible="1"/>
<field name="bank_statement_line_id"/>
</group>
</group>
<group>
<group>
<field name="sale_count"/>
<field name="refund_count"/>
<field name="matched_count"/>
</group>
</group>
<notebook>
<page string="Transaction Lines" name="lines">
<field name="line_ids">
<list editable="bottom"
decoration-success="state == 'paid'"
decoration-warning="state == 'matched'"
decoration-danger="state == 'error'">
<field name="transaction_date"/>
<field name="action"/>
<field name="amount" sum="Total"/>
<field name="card_brand"/>
<field name="card_last4"/>
<field name="card_holder_name"/>
<field name="partner_id"/>
<field name="invoice_id"/>
<field name="payment_id"/>
<field name="match_method"/>
<field name="state" widget="badge"
decoration-success="state == 'paid'"
decoration-warning="state == 'matched'"
decoration-danger="state == 'error'"/>
<field name="currency_id" column_invisible="1"/>
</list>
</field>
</page>
<page string="Notes" name="notes">
<field name="notes"/>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<!-- Search View -->
<record id="poynt_settlement_batch_view_search" model="ir.ui.view">
<field name="name">poynt.settlement.batch.search</field>
<field name="model">poynt.settlement.batch</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<field name="transaction_date"/>
<field name="settlement_date"/>
<filter name="filter_draft" string="Draft" domain="[('state', '=', 'draft')]"/>
<filter name="filter_matched" string="Matched" domain="[('state', '=', 'matched')]"/>
<filter name="filter_reconciled" string="Reconciled" domain="[('state', '=', 'reconciled')]"/>
<filter name="filter_error" string="Errors" domain="[('state', '=', 'error')]"/>
<separator/>
<filter name="group_state" string="Status" context="{'group_by': 'state'}" domain="[]"/>
<filter name="group_settlement_date" string="Settlement Date" context="{'group_by': 'settlement_date:month'}" domain="[]"/>
</search>
</field>
</record>
<!-- Action -->
<record id="action_poynt_settlement_batch" model="ir.actions.act_window">
<field name="name">Settlement Batches</field>
<field name="res_model">poynt.settlement.batch</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="poynt_settlement_batch_view_search"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No settlement batches yet
</p>
<p>
Settlement batches are created automatically by the daily cron job,
or you can create one manually to fetch Poynt terminal transactions
for a specific date.
</p>
</field>
</record>
<!-- ==================== SETTLEMENT LINE ==================== -->
<!-- Tree View (standalone) -->
<record id="poynt_settlement_line_view_list" model="ir.ui.view">
<field name="name">poynt.settlement.line.list</field>
<field name="model">poynt.settlement.line</field>
<field name="arch" type="xml">
<list decoration-success="state == 'paid'" decoration-warning="state == 'matched'" decoration-danger="state == 'error'">
<field name="batch_id"/>
<field name="transaction_date"/>
<field name="action"/>
<field name="amount" sum="Total"/>
<field name="card_brand"/>
<field name="card_last4"/>
<field name="card_holder_name"/>
<field name="partner_id"/>
<field name="invoice_id"/>
<field name="payment_id"/>
<field name="match_method"/>
<field name="state" widget="badge"
decoration-success="state == 'paid'"
decoration-warning="state == 'matched'"
decoration-danger="state == 'error'"/>
</list>
</field>
</record>
<!-- Search View -->
<record id="poynt_settlement_line_view_search" model="ir.ui.view">
<field name="name">poynt.settlement.line.search</field>
<field name="model">poynt.settlement.line</field>
<field name="arch" type="xml">
<search>
<field name="card_holder_name"/>
<field name="card_last4"/>
<field name="partner_id"/>
<field name="poynt_transaction_id"/>
<filter name="filter_unmatched" string="Unmatched" domain="[('partner_id', '=', False), ('action', '=', 'SALE')]"/>
<filter name="filter_matched" string="Matched" domain="[('state', '=', 'matched')]"/>
<filter name="filter_paid" string="Paid" domain="[('state', '=', 'paid')]"/>
<filter name="filter_errors" string="Errors" domain="[('state', '=', 'error')]"/>
<separator/>
<filter name="group_batch" string="Batch" context="{'group_by': 'batch_id'}" domain="[]"/>
<filter name="group_partner" string="Customer" context="{'group_by': 'partner_id'}" domain="[]"/>
<filter name="group_card_brand" string="Card Brand" context="{'group_by': 'card_brand'}" domain="[]"/>
<filter name="group_state" string="Status" context="{'group_by': 'state'}" domain="[]"/>
</search>
</field>
</record>
<!-- Action -->
<record id="action_poynt_settlement_line" model="ir.actions.act_window">
<field name="name">Settlement Transactions</field>
<field name="res_model">poynt.settlement.line</field>
<field name="view_mode">list</field>
<field name="search_view_id" ref="poynt_settlement_line_view_search"/>
</record>
<!-- ==================== MENUS ==================== -->
<menuitem id="menu_poynt_settlement_root"
name="Settlements"
parent="account.root_payment_menu"
sequence="30"/>
<menuitem id="menu_poynt_settlement_batches"
name="Settlement Batches"
parent="menu_poynt_settlement_root"
action="action_poynt_settlement_batch"
sequence="10"/>
<menuitem id="menu_poynt_settlement_lines"
name="All Transactions"
parent="menu_poynt_settlement_root"
action="action_poynt_settlement_line"
sequence="20"/>
</odoo>

View File

@@ -0,0 +1,74 @@
import logging
from datetime import timedelta
AML = env['account.move.line'].sudo()
# Outstanding Receipts (493) has:
# - CREDITS from Elavon bank deposits (reconcile model posted them)
# - DEBITS from Poynt customer payments (we just created them)
# These need to be matched against each other
# Get all unreconciled lines on account 493
credits = AML.search([
('account_id', '=', 493),
('reconciled', '=', False),
('balance', '<', 0), # Credits (Elavon deposits)
('parent_state', '=', 'posted'),
], order='date asc')
debits = AML.search([
('account_id', '=', 493),
('reconciled', '=', False),
('balance', '>', 0), # Debits (Poynt payments + other)
('parent_state', '=', 'posted'),
], order='date asc')
print(f'Unreconciled on Outstanding Receipts (493):', flush=True)
print(f' Credits (Elavon deposits etc): {len(credits)}, total: {sum(c.balance for c in credits):.2f}', flush=True)
print(f' Debits (Poynt payments etc): {len(debits)}, total: {sum(d.balance for d in debits):.2f}', flush=True)
# Strategy: match credits and debits by exact amount + close date
# Build index of debits by amount
from collections import defaultdict
debit_by_amount = defaultdict(list)
for d in debits:
debit_by_amount[round(d.balance, 2)].append(d)
matched = 0
used_debit_ids = set()
for credit in credits:
credit_amount = round(abs(credit.balance), 2)
candidates = [d for d in debit_by_amount.get(credit_amount, []) if d.id not in used_debit_ids]
if not candidates:
continue
# Pick closest date
best = min(candidates, key=lambda d: abs((d.date - credit.date).days))
gap = abs((best.date - credit.date).days)
# Match if within 30 days
if gap <= 30:
try:
(credit + best).reconcile()
used_debit_ids.add(best.id)
matched += 1
except Exception as e:
if matched < 3:
print(f' Error matching {credit.id} + {best.id}: {e}', flush=True)
if matched % 100 == 0 and matched > 0:
env.cr.commit()
print(f' Progress: {matched} matched', flush=True)
env.cr.commit()
print(f'\nMatched: {matched} pairs on Outstanding Receipts', flush=True)
# Check remaining
remaining = AML.search_count([
('account_id', '=', 493),
('reconciled', '=', False),
('parent_state', '=', 'posted'),
])
print(f'Remaining unreconciled on 493: {remaining}', flush=True)

123
match_poynt_customers.py Normal file
View File

@@ -0,0 +1,123 @@
import logging
import re
_logger = logging.getLogger('poynt_match')
SL = env['poynt.settlement.line'].sudo()
Partner = env['res.partner'].sudo()
Invoice = env['account.move'].sudo()
lines = SL.search([('state', '=', 'fetched'), ('action', '=', 'SALE')])
print(f'Unmatched SALE lines: {len(lines)}', flush=True)
matched_by_name = 0
matched_by_amount = 0
no_match = 0
for sl in lines:
name = (sl.card_holder_name or '').strip()
amount = sl.amount
txn_date = sl.transaction_date.date() if sl.transaction_date else None
partner = None
# --- Strategy 1: Match by cardholder name ---
if name and name != '/':
# Parse LASTNAME/FIRSTNAME or FIRSTNAME LASTNAME
if '/' in name:
parts = name.split('/')
last_name = parts[0].strip().title()
first_name = parts[1].strip().title() if len(parts) > 1 else ''
else:
parts = name.split()
first_name = parts[0].title() if parts else ''
last_name = parts[-1].title() if len(parts) > 1 else ''
if last_name and first_name:
# Exact match: "Lastname, Firstname" or "Firstname Lastname"
candidates = Partner.search([
('customer_rank', '>', 0),
'|',
('name', 'ilike', f'{last_name}, {first_name}'),
('name', 'ilike', f'{first_name} {last_name}'),
], limit=5)
# If still ambiguous (>1), try to narrow by matching amount to open invoices
if len(candidates) == 1:
partner = candidates[0]
elif len(candidates) > 1:
# Pick the one with an open invoice matching the amount
for c in candidates:
inv = Invoice.search([
('partner_id', '=', c.id),
('move_type', '=', 'out_invoice'),
('payment_state', 'in', ('not_paid', 'partial')),
('amount_residual', '>=', amount - 1),
('amount_residual', '<=', amount + 1),
], limit=1)
if inv:
partner = c
break
if not partner:
partner = candidates[0] # Take first if no invoice match
# --- Strategy 2: Match by amount against open invoices (if no name match) ---
if not partner and txn_date and amount > 50:
# Look for open invoices with matching amount within ±30 days
invs = Invoice.search([
('move_type', '=', 'out_invoice'),
('payment_state', 'in', ('not_paid', 'partial')),
('amount_residual', '>=', amount - 0.50),
('amount_residual', '<=', amount + 0.50),
('invoice_date', '>=', str(txn_date - __import__('datetime').timedelta(days=60))),
('invoice_date', '<=', str(txn_date + __import__('datetime').timedelta(days=5))),
], limit=3)
if len(invs) == 1:
partner = invs[0].partner_id
sl.invoice_id = invs[0].id
# --- Write match ---
if partner:
vals = {'partner_id': partner.id, 'state': 'matched'}
if name and name != '/':
vals['match_method'] = 'cardholder_name'
matched_by_name += 1
else:
vals['match_method'] = 'invoice_amount'
matched_by_amount += 1
# Also try to find matching invoice if not already set
if not sl.invoice_id and txn_date:
inv = Invoice.search([
('partner_id', '=', partner.id),
('move_type', '=', 'out_invoice'),
('payment_state', 'in', ('not_paid', 'partial')),
('amount_residual', '>=', amount - 1),
('amount_residual', '<=', amount + 1),
], limit=1)
if inv:
vals['invoice_id'] = inv.id
sl.write(vals)
else:
no_match += 1
if (matched_by_name + matched_by_amount + no_match) % 100 == 0:
env.cr.commit()
env.cr.commit()
total_matched = matched_by_name + matched_by_amount
print(f'\nRESULTS:', flush=True)
print(f' Matched by name: {matched_by_name}', flush=True)
print(f' Matched by invoice amount: {matched_by_amount}', flush=True)
print(f' No match: {no_match}', flush=True)
print(f' Total matched: {total_matched}/{len(lines)} ({round(100*total_matched/len(lines),1) if lines else 0}%)', flush=True)
# Summary
all_lines = SL.search([])
print(f'\nOVERALL:', flush=True)
print(f' Total lines: {len(all_lines)}', flush=True)
print(f' Matched: {SL.search_count([("state", "=", "matched")])}', flush=True)
print(f' With invoice: {SL.search_count([("invoice_id", "!=", False)])}', flush=True)
print(f' Fetched (unmatched): {SL.search_count([("state", "=", "fetched")])}', flush=True)

96
match_poynt_refunds.py Normal file
View File

@@ -0,0 +1,96 @@
from datetime import timedelta
SL = env['poynt.settlement.line'].sudo()
Invoice = env['account.move'].sudo()
refunds = SL.search([('state', '=', 'fetched'), ('action', '=', 'REFUND')])
print(f'Unmatched REFUND lines: {len(refunds)}', flush=True)
matched = 0
matched_by_card = 0
no_match = 0
for sl in refunds:
amount = abs(sl.amount)
txn_date = sl.transaction_date.date() if sl.transaction_date else None
if not txn_date:
no_match += 1
continue
partner = None
invoice = None
# Strategy 1: Find the original SALE with same card_last4 and same amount
if sl.card_last4:
original_sale = SL.search([
('card_last4', '=', sl.card_last4),
('action', '=', 'SALE'),
('amount', '>=', amount - 0.50),
('amount', '<=', amount + 0.50),
('state', '=', 'matched'),
('partner_id', '!=', False),
], limit=1, order='transaction_date desc')
if original_sale:
partner = original_sale.partner_id
matched_by_card += 1
# Strategy 2: Match against credit notes by amount
if not partner:
credit_notes = Invoice.search([
('move_type', '=', 'out_refund'),
('amount_total', '>=', amount - 0.50),
('amount_total', '<=', amount + 0.50),
('invoice_date', '>=', str(txn_date - timedelta(days=30))),
('invoice_date', '<=', str(txn_date + timedelta(days=5))),
], limit=3)
if len(credit_notes) == 1:
partner = credit_notes[0].partner_id
invoice = credit_notes[0]
elif len(credit_notes) > 1:
best = min(credit_notes, key=lambda i: abs((i.invoice_date - txn_date).days))
partner = best.partner_id
invoice = best
# Strategy 3: Match against regular invoices (might be a partial refund)
if not partner:
invs = Invoice.search([
('move_type', '=', 'out_invoice'),
('amount_total', '>=', amount - 0.50),
('amount_total', '<=', amount + 0.50),
('invoice_date', '>=', str(txn_date - timedelta(days=60))),
('invoice_date', '<=', str(txn_date + timedelta(days=5))),
], limit=3)
if len(invs) == 1:
partner = invs[0].partner_id
invoice = invs[0]
elif len(invs) > 1:
best = min(invs, key=lambda i: abs((i.invoice_date - txn_date).days))
partner = best.partner_id
invoice = best
if partner:
vals = {
'partner_id': partner.id,
'state': 'matched',
'match_method': 'refund_card_match' if matched_by_card else 'refund_invoice',
}
if invoice:
vals['invoice_id'] = invoice.id
sl.write(vals)
matched += 1
else:
no_match += 1
env.cr.commit()
print(f'Matched refunds: {matched} (by card: {matched_by_card})', flush=True)
print(f'No match: {no_match}', flush=True)
# Final
print(f'\nFINAL:', flush=True)
for state in ['matched', 'fetched']:
print(f' {state}: {SL.search_count([("state", "=", state)])}', flush=True)
print(f' With invoice: {SL.search_count([("invoice_id", "!=", False)])}', flush=True)

135
match_poynt_v2.py Normal file
View File

@@ -0,0 +1,135 @@
import logging
from datetime import timedelta
SL = env['poynt.settlement.line'].sudo()
Invoice = env['account.move'].sudo()
# Reset name-matched ones that don't have invoices — they might be wrong
bad_name_matches = SL.search([
('state', '=', 'matched'),
('match_method', '=', 'cardholder_name'),
('invoice_id', '=', False),
])
if bad_name_matches:
bad_name_matches.write({'state': 'fetched', 'partner_id': False, 'match_method': False})
print(f'Reset {len(bad_name_matches)} name-only matches without invoices', flush=True)
env.cr.commit()
lines = SL.search([('state', '=', 'fetched'), ('action', '=', 'SALE')])
print(f'Unmatched SALE lines to process: {len(lines)}', flush=True)
matched_invoice = 0
matched_card_history = 0
no_match = 0
# --- Build card history: which card_last4 has paid for which partner before? ---
# From already-matched lines
card_partner_map = {}
matched_lines = SL.search([('state', '=', 'matched'), ('partner_id', '!=', False)])
for ml in matched_lines:
if ml.card_last4:
key = (ml.card_brand, ml.card_last4)
card_partner_map.setdefault(key, set()).add(ml.partner_id.id)
# Also from payment.transaction (Odoo-processed payments with card tokens)
env.cr.execute("""
SELECT pt.payment_method_code, pt.provider_reference, pt.partner_id,
tok.provider_ref
FROM payment_transaction pt
LEFT JOIN payment_token tok ON pt.token_id = tok.id
WHERE pt.provider_code = 'poynt' AND pt.state = 'done' AND pt.partner_id IS NOT NULL
""")
for row in env.cr.fetchall():
pass # Token data doesn't reliably have last4
print(f'Card history: {len(card_partner_map)} unique cards mapped to partners', flush=True)
for sl in lines:
amount = sl.amount
txn_date = sl.transaction_date.date() if sl.transaction_date else None
if not txn_date:
no_match += 1
continue
partner = None
invoice = None
# --- Strategy 1: Match by exact amount against open invoices ---
if amount > 20:
invs = Invoice.search([
('move_type', '=', 'out_invoice'),
('payment_state', 'in', ('not_paid', 'partial')),
('amount_residual', '>=', amount - 0.50),
('amount_residual', '<=', amount + 0.50),
('invoice_date', '>=', str(txn_date - timedelta(days=90))),
('invoice_date', '<=', str(txn_date + timedelta(days=5))),
], limit=5)
if len(invs) == 1:
# Unique match — confident
partner = invs[0].partner_id
invoice = invs[0]
elif len(invs) > 1:
# Multiple invoices with same amount — try card history to pick one
card_key = (sl.card_brand, sl.card_last4)
known_partners = card_partner_map.get(card_key, set())
for inv in invs:
if inv.partner_id.id in known_partners:
partner = inv.partner_id
invoice = inv
break
# --- Strategy 2: Card history (same card paid before for same partner) ---
if not partner and sl.card_last4:
card_key = (sl.card_brand, sl.card_last4)
known_partners = card_partner_map.get(card_key, set())
if len(known_partners) == 1:
pid = list(known_partners)[0]
# Check if this partner has any open invoice close to this amount
invs = Invoice.search([
('partner_id', '=', pid),
('move_type', '=', 'out_invoice'),
('payment_state', 'in', ('not_paid', 'partial')),
('amount_residual', '>=', amount - 5),
('amount_residual', '<=', amount + 5),
], limit=1)
if invs:
partner = invs[0].partner_id
invoice = invs[0]
matched_card_history += 1
if partner:
vals = {
'partner_id': partner.id,
'state': 'matched',
'match_method': 'invoice_amount' if invoice else 'card_history',
}
if invoice:
vals['invoice_id'] = invoice.id
matched_invoice += 1
sl.write(vals)
# Update card history
if sl.card_last4:
card_key = (sl.card_brand, sl.card_last4)
card_partner_map.setdefault(card_key, set()).add(partner.id)
else:
no_match += 1
if (matched_invoice + matched_card_history + no_match) % 100 == 0:
env.cr.commit()
env.cr.commit()
print(f'\nRESULTS:', flush=True)
print(f' Matched by invoice amount: {matched_invoice}', flush=True)
print(f' Matched by card history: {matched_card_history}', flush=True)
print(f' No match: {no_match}', flush=True)
# Final stats
print(f'\nFINAL STATE:', flush=True)
for state in ['matched', 'fetched']:
cnt = SL.search_count([('state', '=', state)])
print(f' {state}: {cnt}', flush=True)
inv_cnt = SL.search_count([('invoice_id', '!=', False)])
print(f' With invoice linked: {inv_cnt}', flush=True)

65
match_poynt_v3.py Normal file
View File

@@ -0,0 +1,65 @@
import logging
from datetime import timedelta
SL = env['poynt.settlement.line'].sudo()
Invoice = env['account.move'].sudo()
Payment = env['account.payment'].sudo()
lines = SL.search([('state', '=', 'fetched'), ('action', '=', 'SALE')])
print(f'Unmatched SALE lines: {len(lines)}', flush=True)
matched = 0
no_match = 0
for sl in lines:
amount = sl.amount
txn_date = sl.transaction_date.date() if sl.transaction_date else None
if not txn_date or amount < 10:
no_match += 1
continue
# Strategy: Match against ALL invoices (not just open) by exact amount + close date
# This catches invoices that were already paid by this terminal transaction
invs = Invoice.search([
('move_type', '=', 'out_invoice'),
('amount_total', '>=', amount - 0.50),
('amount_total', '<=', amount + 0.50),
('invoice_date', '>=', str(txn_date - timedelta(days=30))),
('invoice_date', '<=', str(txn_date + timedelta(days=5))),
], limit=5)
if len(invs) == 1:
sl.write({
'partner_id': invs[0].partner_id.id,
'invoice_id': invs[0].id,
'state': 'matched',
'match_method': 'invoice_total',
})
matched += 1
elif len(invs) > 1:
# Multiple — try to pick the one closest in date
best = min(invs, key=lambda i: abs((i.invoice_date - txn_date).days))
sl.write({
'partner_id': best.partner_id.id,
'invoice_id': best.id,
'state': 'matched',
'match_method': 'invoice_total',
})
matched += 1
else:
no_match += 1
if (matched + no_match) % 100 == 0:
env.cr.commit()
env.cr.commit()
print(f'Matched by invoice total: {matched}', flush=True)
print(f'No match: {no_match}', flush=True)
# Final stats
print(f'\nFINAL STATE:', flush=True)
for state in ['matched', 'fetched']:
cnt = SL.search_count([('state', '=', state)])
print(f' {state}: {cnt}', flush=True)
print(f' With invoice: {SL.search_count([("invoice_id", "!=", False)])}', flush=True)

78
match_rbc_transfers.py Normal file
View File

@@ -0,0 +1,78 @@
import logging
AML = env['account.move.line'].sudo()
BSL = env['account.bank.statement.line'].sudo()
company_partner_id = env.company.partner_id.id
# RBC Visa (28) incoming CC payments - match against Outstanding Payments (77 + 494)
cc_lines = BSL.search([
('journal_id', '=', 28),
('is_reconciled', '=', False),
('amount', '>', 0),
('company_id', '=', env.company.id),
])
# Filter to CC payment patterns only
transfer_lines = cc_lines.filtered(
lambda l: any(p in (l.payment_ref or '').lower() for p in [
'payment - thank you', 'credit card payment', 'rbc credit card',
'payment to credit card', 'paiement - merci',
'pay rbc credit card', 'credit card pay'
])
)
print(f'RBC Visa incoming CC payments: {len(transfer_lines)}', flush=True)
# Get outstanding entries on accounts 77 and 494
all_outstanding = AML.search([
('account_id', 'in', [77, 494]),
('partner_id', '=', company_partner_id),
('reconciled', '=', False),
('amount_residual', '>', 0),
], order='date asc')
# Index by amount
by_amount = {}
for o in all_outstanding:
by_amount.setdefault(round(o.amount_residual, 2), []).append(o)
print(f'Outstanding entries available: {len(all_outstanding)}', flush=True)
reconciled = 0
no_match = 0
used_ids = set()
for line in sorted(transfer_lines, key=lambda l: l.move_id.date):
amount = round(line.amount, 2)
candidates = [c for c in by_amount.get(amount, []) if c.id not in used_ids]
if not candidates:
no_match += 1
continue
# Pick closest date
best = min(candidates, key=lambda c: abs((c.date - line.move_id.date).days))
try:
line.partner_id = company_partner_id
line.set_line_bank_statement_line(best.ids)
used_ids.add(best.id)
reconciled += 1
except Exception as e:
if reconciled < 3:
print(f' Error: line {line.id} (${amount}): {e}', flush=True)
if reconciled % 50 == 0 and reconciled > 0:
env.cr.commit()
env.cr.commit()
print(f'Reconciled: {reconciled}', flush=True)
print(f'No outstanding match: {no_match}', flush=True)
# For remaining with no outstanding entry, create writeoff models
remaining = BSL.search_count([
('journal_id', '=', 28),
('is_reconciled', '=', False),
('amount', '>', 0),
])
print(f'RBC Visa remaining incoming unreconciled: {remaining}', flush=True)

107
merge_models.sql Normal file
View File

@@ -0,0 +1,107 @@
BEGIN;
-- ============================================================
-- MERGE: Add partner_id from partner_mapping into the original model,
-- then archive the duplicate partner_mapping
-- ============================================================
-- Ability Members: merge 68 → 20
UPDATE account_reconcile_model SET mapped_partner_id = (SELECT mapped_partner_id FROM account_reconcile_model WHERE id = 68) WHERE id = 20;
UPDATE account_reconcile_model SET active = false WHERE id = 68;
-- ADT Security: merge 73 → 25
UPDATE account_reconcile_model SET mapped_partner_id = (SELECT mapped_partner_id FROM account_reconcile_model WHERE id = 73) WHERE id = 25;
UPDATE account_reconcile_model SET active = false WHERE id = 73;
-- ATM fee: merge 54 → 3
UPDATE account_reconcile_model SET mapped_partner_id = (SELECT mapped_partner_id FROM account_reconcile_model WHERE id = 54) WHERE id = 3;
UPDATE account_reconcile_model SET active = false WHERE id = 54;
-- BC FEE: merge 53 → 2
UPDATE account_reconcile_model SET mapped_partner_id = (SELECT mapped_partner_id FROM account_reconcile_model WHERE id = 53) WHERE id = 2;
UPDATE account_reconcile_model SET active = false WHERE id = 53;
-- Canada Computer: merge 67 → 19
UPDATE account_reconcile_model SET mapped_partner_id = (SELECT mapped_partner_id FROM account_reconcile_model WHERE id = 67) WHERE id = 19;
UPDATE account_reconcile_model SET active = false WHERE id = 67;
-- Circle K: merge 59 → 14
UPDATE account_reconcile_model SET mapped_partner_id = (SELECT mapped_partner_id FROM account_reconcile_model WHERE id = 59) WHERE id = 14;
UPDATE account_reconcile_model SET active = false WHERE id = 59;
-- De Lage: merge 74 → 26
UPDATE account_reconcile_model SET mapped_partner_id = (SELECT mapped_partner_id FROM account_reconcile_model WHERE id = 74) WHERE id = 26;
UPDATE account_reconcile_model SET active = false WHERE id = 74;
-- Enbridge: merge 60 → 32
UPDATE account_reconcile_model SET mapped_partner_id = (SELECT mapped_partner_id FROM account_reconcile_model WHERE id = 60) WHERE id = 32;
UPDATE account_reconcile_model SET active = false WHERE id = 60;
-- Home Depot: merge 58 → 12
UPDATE account_reconcile_model SET mapped_partner_id = (SELECT mapped_partner_id FROM account_reconcile_model WHERE id = 58) WHERE id = 12;
UPDATE account_reconcile_model SET active = false WHERE id = 58;
-- IFS Insurance: merge 71 → 23
UPDATE account_reconcile_model SET mapped_partner_id = (SELECT mapped_partner_id FROM account_reconcile_model WHERE id = 71) WHERE id = 23;
UPDATE account_reconcile_model SET active = false WHERE id = 71;
-- MB-CREDIT CARD: merge 63 → 38
UPDATE account_reconcile_model SET mapped_partner_id = (SELECT mapped_partner_id FROM account_reconcile_model WHERE id = 63) WHERE id = 38;
UPDATE account_reconcile_model SET active = false WHERE id = 63;
-- Monthly fee: merge 57 → 6
UPDATE account_reconcile_model SET mapped_partner_id = (SELECT mapped_partner_id FROM account_reconcile_model WHERE id = 57) WHERE id = 6;
UPDATE account_reconcile_model SET active = false WHERE id = 57;
-- NSF fee: merge 55 → 4
UPDATE account_reconcile_model SET mapped_partner_id = (SELECT mapped_partner_id FROM account_reconcile_model WHERE id = 55) WHERE id = 4;
UPDATE account_reconcile_model SET active = false WHERE id = 55;
-- Odoo: merge 52 → 30, archive both 52 and 78
UPDATE account_reconcile_model SET mapped_partner_id = (SELECT mapped_partner_id FROM account_reconcile_model WHERE id = 52) WHERE id = 30;
UPDATE account_reconcile_model SET active = false WHERE id = 52;
UPDATE account_reconcile_model SET active = false WHERE id = 78;
-- Online Banking transfer: merge 56 → 35
UPDATE account_reconcile_model SET mapped_partner_id = (SELECT mapped_partner_id FROM account_reconcile_model WHERE id = 56) WHERE id = 35;
UPDATE account_reconcile_model SET active = false WHERE id = 56;
-- Pay Employee-Vendor: merge 75 → 27
UPDATE account_reconcile_model SET mapped_partner_id = (SELECT mapped_partner_id FROM account_reconcile_model WHERE id = 75) WHERE id = 27;
UPDATE account_reconcile_model SET active = false WHERE id = 75;
-- Petro Canada: merge 64 → 15
UPDATE account_reconcile_model SET mapped_partner_id = (SELECT mapped_partner_id FROM account_reconcile_model WHERE id = 64) WHERE id = 15;
UPDATE account_reconcile_model SET active = false WHERE id = 64;
-- P M Products / Pride: merge 72 → 24
UPDATE account_reconcile_model SET mapped_partner_id = (SELECT mapped_partner_id FROM account_reconcile_model WHERE id = 72) WHERE id = 24;
UPDATE account_reconcile_model SET active = false WHERE id = 72;
-- Purchase Interest: merge 77 → 29
UPDATE account_reconcile_model SET mapped_partner_id = (SELECT mapped_partner_id FROM account_reconcile_model WHERE id = 77) WHERE id = 29;
UPDATE account_reconcile_model SET active = false WHERE id = 77;
-- Shell: merge 66 → 13
UPDATE account_reconcile_model SET mapped_partner_id = (SELECT mapped_partner_id FROM account_reconcile_model WHERE id = 66) WHERE id = 13;
UPDATE account_reconcile_model SET active = false WHERE id = 66;
-- Superpass: merge 65 → 17
UPDATE account_reconcile_model SET mapped_partner_id = (SELECT mapped_partner_id FROM account_reconcile_model WHERE id = 65) WHERE id = 17;
UPDATE account_reconcile_model SET active = false WHERE id = 65;
-- Wawanesa: merge 76 → 28
UPDATE account_reconcile_model SET mapped_partner_id = (SELECT mapped_partner_id FROM account_reconcile_model WHERE id = 76) WHERE id = 28;
UPDATE account_reconcile_model SET active = false WHERE id = 76;
-- Scotia Visa Payment: 62 has no original counterpart with lines — KEEP as is
-- Permobil: 69 has no original counterpart with lines — KEEP as is
-- ============================================================
-- Also rename the originals that had poor names
-- ============================================================
UPDATE account_reconcile_model SET name = '{"en_US": "Home Depot - Tools & Supplies"}' WHERE id = 12;
UPDATE account_reconcile_model SET name = '{"en_US": "Pride Mobility - Vendor Bills"}' WHERE id = 24;
UPDATE account_reconcile_model SET name = '{"en_US": "Odoo S.A. - Subscription"}' WHERE id = 30;
COMMIT;

48
process_poynt_batches.py Normal file
View File

@@ -0,0 +1,48 @@
import logging
_logger = logging.getLogger('poynt_process')
Batch = env['poynt.settlement.batch'].sudo()
batches = Batch.search([('state', '=', 'draft')], order='transaction_date asc')
print(f'Processing {len(batches)} batches', flush=True)
total_paid = 0
total_errors = 0
processed = 0
for batch in batches:
payable = batch.line_ids.filtered(
lambda l: l.partner_id and l.action == 'SALE' and l.state == 'matched' and not l.payment_id
)
if not payable:
processed += 1
continue
try:
batch.action_create_payments()
paid = len(batch.line_ids.filtered(lambda l: l.state == 'paid'))
errs = len(batch.line_ids.filtered(lambda l: l.state == 'error'))
total_paid += paid
total_errors += errs
except Exception as e:
total_errors += len(payable)
if total_errors <= 5:
print(f' Batch {batch.name} error: {e}', flush=True)
processed += 1
if processed % 30 == 0:
env.cr.commit()
print(f' Progress: {processed}/{len(batches)}, paid={total_paid}, errors={total_errors}', flush=True)
env.cr.commit()
print(f'\nDONE:', flush=True)
print(f' Batches processed: {processed}', flush=True)
print(f' Payments created: {total_paid}', flush=True)
print(f' Errors: {total_errors}', flush=True)
# Final state
for state in ['draft', 'matched', 'reconciled', 'error']:
cnt = Batch.search_count([('state', '=', state)])
if cnt:
print(f' Batches {state}: {cnt}', flush=True)

90
rename_models.sql Normal file
View File

@@ -0,0 +1,90 @@
BEGIN;
-- Rename partner_mapping models to descriptive names
-- Format: "Vendor/Function - Keyword" so it's clear what each does
UPDATE account_reconcile_model SET name = '{"en_US": "ADP Program Payments"}'
WHERE id = 51; -- ADPTPS → ADP
UPDATE account_reconcile_model SET name = '{"en_US": "Odoo Subscription"}'
WHERE id = 52; -- Odoo → Odoo S.A.
UPDATE account_reconcile_model SET name = '{"en_US": "RBC Branch Fee (BC FEE)"}'
WHERE id = 53; -- BC FEE → RBC
UPDATE account_reconcile_model SET name = '{"en_US": "RBC ATM Deposit Fee"}'
WHERE id = 54; -- ATM cash deposited fee → RBC
UPDATE account_reconcile_model SET name = '{"en_US": "RBC NSF Fee"}'
WHERE id = 55; -- NSF item fee → RBC
UPDATE account_reconcile_model SET name = '{"en_US": "Westin Internal Transfer - Online Banking"}'
WHERE id = 56; -- Online Banking transfer → Westin Healthcare
UPDATE account_reconcile_model SET name = '{"en_US": "RBC Monthly Account Fee"}'
WHERE id = 57; -- Monthly fee → RBC
UPDATE account_reconcile_model SET name = '{"en_US": "Home Depot Purchases"}'
WHERE id = 58; -- Home Depot → The Home Depot Inc
UPDATE account_reconcile_model SET name = '{"en_US": "Esso Circle K Fuel"}'
WHERE id = 59; -- CIRCLE K → ESSO CIRCLE K
UPDATE account_reconcile_model SET name = '{"en_US": "Enbridge Gas Utility"}'
WHERE id = 60; -- Enbridge → Enbridge
UPDATE account_reconcile_model SET name = '{"en_US": "Westin Visa Payment Received"}'
WHERE id = 61; -- PAYMENT - THANK YOU → Westin Healthcare
UPDATE account_reconcile_model SET name = '{"en_US": "Scotia Visa Payment (Card X0*78)"}'
WHERE id = 62; -- 00*7814 → Westin Healthcare
UPDATE account_reconcile_model SET name = '{"en_US": "Scotia CC/LOC Payment (MB-CREDIT)"}'
WHERE id = 63; -- MB-CREDIT CARD → Westin Healthcare
UPDATE account_reconcile_model SET name = '{"en_US": "Petro-Canada Fuel"}'
WHERE id = 64; -- PETRO CANADA → Petro-Canada
UPDATE account_reconcile_model SET name = '{"en_US": "Petro-Canada Superpass Fleet"}'
WHERE id = 65; -- SUPERPASS → Petro-Canada
UPDATE account_reconcile_model SET name = '{"en_US": "Shell Canada Fuel"}'
WHERE id = 66; -- SHELL → Shell Canada
UPDATE account_reconcile_model SET name = '{"en_US": "Canada Computers Purchases"}'
WHERE id = 67; -- Canada Computer → Canada Computers
UPDATE account_reconcile_model SET name = '{"en_US": "AMG Membership - Ability Members"}'
WHERE id = 68; -- Ability Members → AMG
UPDATE account_reconcile_model SET name = '{"en_US": "Permobil Canada"}'
WHERE id = 69; -- Permobil → Permobil Canada
UPDATE account_reconcile_model SET name = '{"en_US": "Rogers Telecom"}'
WHERE id = 70; -- Rogers → Rogers Canada
UPDATE account_reconcile_model SET name = '{"en_US": "Billyard Insurance (IFS)"}'
WHERE id = 71; -- IFS PREMIUM → Billyard Insurance Group
UPDATE account_reconcile_model SET name = '{"en_US": "Pride Mobility Products"}'
WHERE id = 72; -- P M PRODUCTS → Pride Mobility
UPDATE account_reconcile_model SET name = '{"en_US": "Telus / ADT Security"}'
WHERE id = 73; -- ADT Security → Telus Mobility
UPDATE account_reconcile_model SET name = '{"en_US": "De Lage Landen Equipment Lease"}'
WHERE id = 74; -- DE LAGE → DE LAGE
UPDATE account_reconcile_model SET name = '{"en_US": "RBC Payroll EFT Fee"}'
WHERE id = 75; -- Pay Employee-Vendor → RBC
UPDATE account_reconcile_model SET name = '{"en_US": "Wawanesa Insurance Premium"}'
WHERE id = 76; -- Wawanesa Insurance → Wawanesa Insurance
UPDATE account_reconcile_model SET name = '{"en_US": "RBC Visa Interest Charges"}'
WHERE id = 77; -- PURCHASE INTEREST → RBC
UPDATE account_reconcile_model SET name = '{"en_US": "Odoo Subscription (duplicate)"}'
WHERE id = 78; -- Odoo → Odoo S.A. (duplicate of 52)
COMMIT;

107
run_poynt_sync.py Normal file
View File

@@ -0,0 +1,107 @@
from datetime import date, timedelta
import logging
_logger = logging.getLogger('poynt_sync')
Batch = env['poynt.settlement.batch']
provider = env['payment.provider'].search([('code', '=', 'poynt'), ('state', '=', 'enabled')], limit=1)
if not provider:
print('ERROR: No active Poynt provider found', flush=True)
else:
print(f'Provider: {provider.name} (ID {provider.id})', flush=True)
print(f'Business ID: {provider.poynt_business_id}', flush=True)
# Process each day from 2024-01-01 to today
# Skip weekends (Saturday batched with Friday→Monday)
start_date = date(2024, 1, 1)
end_date = date.today() - timedelta(days=1) # yesterday
current = start_date
batches_created = 0
batches_with_txns = 0
total_lines = 0
errors = []
while current <= end_date:
weekday = current.weekday()
# Skip Saturday — batched with Sunday→Monday
if weekday == 5:
current += timedelta(days=1)
continue
# For Sunday, fetch Fri+Sat+Sun
if weekday == 6:
txn_from = current - timedelta(days=2) # Friday
else:
txn_from = current
settlement_date = current + timedelta(days=1)
# If settlement falls on weekend, push to Monday
if settlement_date.weekday() == 5:
settlement_date += timedelta(days=2)
elif settlement_date.weekday() == 6:
settlement_date += timedelta(days=1)
# Check if batch already exists
existing = Batch.search([
('provider_id', '=', provider.id),
('transaction_date', '=', txn_from),
])
if existing:
current += timedelta(days=1)
continue
try:
batch = Batch.create({
'provider_id': provider.id,
'transaction_date': txn_from,
'settlement_date': settlement_date,
})
batch.action_fetch_transactions()
batches_created += 1
line_count = len(batch.line_ids)
if line_count > 0:
batches_with_txns += 1
total_lines += line_count
# Try to match deposit
try:
batch.action_match_deposit()
except Exception:
pass
# Try to match customers
try:
batch.action_match_customers()
except Exception:
pass
else:
# No transactions — delete empty batch
batch.unlink()
batches_created -= 1
except Exception as e:
err_msg = str(e)[:100]
if err_msg not in [e[:100] for e in errors]:
errors.append(str(e))
print(f' Error on {current}: {err_msg}', flush=True)
if batches_created % 30 == 0 and batches_created > 0:
env.cr.commit()
print(f' Progress: {batches_created} batches, {total_lines} lines, date={current}', flush=True)
current += timedelta(days=1)
env.cr.commit()
print(f'\nDONE:', flush=True)
print(f' Batches created: {batches_created}', flush=True)
print(f' Batches with transactions: {batches_with_txns}', flush=True)
print(f' Total transaction lines: {total_lines}', flush=True)
print(f' Errors: {len(errors)}', flush=True)
# Summary of batch states
all_batches = Batch.search([('provider_id', '=', provider.id)])
matched = all_batches.filtered(lambda b: b.state == 'matched')
print(f' Matched to deposits: {len(matched)} batches', flush=True)

77
run_transfer_reconcile.py Normal file
View File

@@ -0,0 +1,77 @@
import logging
AML = env['account.move.line'].sudo()
BSL = env['account.bank.statement.line'].sudo()
company_partner_id = env.company.partner_id.id
# No date limit this time — match oldest outstanding first
TRANSFER_PAIRS = [
(50, 51, 493), # Scotia Current -> Passport Visa
(53, 28, 493), # RBC Chequing -> RBC Visa
]
total_reconciled = 0
for source_jid, cc_jid, outstanding_acct_id in TRANSFER_PAIRS:
cc_lines = BSL.search([
('journal_id', '=', cc_jid),
('is_reconciled', '=', False),
('amount', '>', 0),
('company_id', '=', env.company.id),
])
# Filter to only transfer-like patterns (round amounts, payment from)
transfer_lines = cc_lines.filtered(
lambda l: (l.payment_ref or '').lower().startswith(('payment from', 'from'))
)
if not transfer_lines:
print(f'Journal {cc_jid}: no transfer lines found', flush=True)
continue
journal_name = transfer_lines[0].journal_id.name
print(f'Processing {journal_name}: {len(transfer_lines)} transfer lines (no date limit)', flush=True)
# Pre-load all outstanding entries for this account/partner, sorted by date
all_outstanding = AML.search([
('account_id', '=', outstanding_acct_id),
('partner_id', '=', company_partner_id),
('reconciled', '=', False),
('amount_residual', '>', 0),
], order='date asc')
# Index by amount for fast lookup
by_amount = {}
for o in all_outstanding:
by_amount.setdefault(o.amount_residual, []).append(o)
reconciled = 0
no_match = 0
used_ids = set()
# Sort lines by date (oldest first)
for line in sorted(transfer_lines, key=lambda l: l.move_id.date):
amount = line.amount
candidates = [c for c in by_amount.get(amount, []) if c.id not in used_ids]
if not candidates:
no_match += 1
continue
# Take the first (oldest) candidate
best = candidates[0]
try:
line.partner_id = company_partner_id
line.set_line_bank_statement_line(best.ids)
used_ids.add(best.id)
reconciled += 1
except Exception as e:
print(f' Error: line {line.id} (${amount}): {e}', flush=True)
if reconciled % 50 == 0 and reconciled > 0:
env.cr.commit()
env.cr.commit()
total_reconciled += reconciled
print(f'DONE {journal_name}: reconciled={reconciled}, no_match={no_match}', flush=True)
print(f'TOTAL: {total_reconciled} transfer lines reconciled', flush=True)

2
session7.txt Normal file

File diff suppressed because one or more lines are too long

27
test_transfer_match.py Normal file
View File

@@ -0,0 +1,27 @@
line = env['account.bank.statement.line'].browse(20261) # $5000 on 2026-03-30
print(f'Line: {line.id}, amt={line.amount}, date={line.move_id.date}, recon={line.is_reconciled}')
# Set partner
line.partner_id = 1
# Find matching outstanding entry on account 493 (Outstanding Receipts - All Banks)
outstanding = env['account.move.line'].search([
('account_id', '=', 493),
('partner_id', '=', 1),
('reconciled', '=', False),
('amount_residual', '=', line.amount),
], limit=5)
print(f'Outstanding matches: {[(o.id, o.amount_residual, o.date) for o in outstanding]}')
if outstanding:
aml = outstanding[0]
try:
line.set_line_bank_statement_line(aml.ids)
env.cr.commit()
line.invalidate_recordset()
print(f'RECONCILED: {line.is_reconciled}')
except Exception as e:
print(f'Error: {e}')
env.cr.rollback()
else:
print('No matching outstanding entry found')

74
visa_models.sql Normal file
View File

@@ -0,0 +1,74 @@
BEGIN;
-- ============================================================
-- Scotia Passport Visa recurring transaction models
-- ============================================================
-- SmartStop Storage → 6010 Building Rent + HST (partner 11463)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "SmartStop Storage Rent"}', 1, 'auto_reconcile', 'contains', 'smartstop', 11463, true, 130, true, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
VALUES (currval('account_reconcile_model_id_seq'), 1, 560, 'percentage', 100, '100', '{"en_US": "SmartStop Storage Unit Rent"}', 10, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line_account_tax_rel (account_reconcile_model_line_id, account_tax_id)
VALUES (currval('account_reconcile_model_line_id_seq'), 20);
-- QuickBooks / Intuit → 6050 IT Expenses + HST (partner 5830)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "QuickBooks / Intuit Subscription"}', 1, 'auto_reconcile', 'contains', 'quickbooks', 5830, true, 131, true, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
VALUES (currval('account_reconcile_model_id_seq'), 1, 495, 'percentage', 100, '100', '{"en_US": "QuickBooks Online Subscription"}', 10, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line_account_tax_rel (account_reconcile_model_line_id, account_tax_id)
VALUES (currval('account_reconcile_model_line_id_seq'), 20);
-- Also catch the "qbooks" variant
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "QBooks Online (Intuit)"}', 1, 'auto_reconcile', 'contains', 'qbooks', 5830, true, 132, true, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
VALUES (currval('account_reconcile_model_id_seq'), 1, 495, 'percentage', 100, '100', '{"en_US": "QuickBooks Online Subscription"}', 10, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line_account_tax_rel (account_reconcile_model_line_id, account_tax_id)
VALUES (currval('account_reconcile_model_line_id_seq'), 20);
-- Vistaprint → 6270 Stationery and Printing + HST (partner 5027)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Vistaprint Printing"}', 1, 'auto_reconcile', 'contains', 'vistaprint', 5027, true, 133, true, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
VALUES (currval('account_reconcile_model_id_seq'), 1, 519, 'percentage', 100, '100', '{"en_US": "Vistaprint Business Cards/Printing"}', 10, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line_account_tax_rel (account_reconcile_model_line_id, account_tax_id)
VALUES (currval('account_reconcile_model_line_id_seq'), 20);
-- OpenRouter (AI API) → 6050 IT Expenses, no HST (US company)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "OpenRouter AI API"}', 1, 'auto_reconcile', 'contains', 'openrouter', true, 134, true, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
VALUES (currval('account_reconcile_model_id_seq'), 1, 495, 'percentage', 100, '100', '{"en_US": "OpenRouter AI API Usage"}', 10, 2, 2, NOW(), NOW());
-- Crunchyroll → 6070 Dues and Subscriptions, no HST (US company)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Crunchyroll Subscription"}', 1, 'auto_reconcile', 'contains', 'crunchyroll', true, 135, true, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
VALUES (currval('account_reconcile_model_id_seq'), 1, 501, 'percentage', 100, '100', '{"en_US": "Crunchyroll Streaming Subscription"}', 10, 2, 2, NOW(), NOW());
-- Punjabi Community Health → 6490 Donation, no HST
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Punjabi Community Health Donation"}', 1, 'auto_reconcile', 'contains', 'punjabi community', true, 136, true, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
VALUES (currval('account_reconcile_model_id_seq'), 1, 528, 'percentage', 100, '100', '{"en_US": "Punjabi Community Health Centre Donation"}', 10, 2, 2, NOW(), NOW());
-- Canada Worldwide Services → need to check account... shipping/customs likely
-- Using 8010 Shipping & Delivery + HST as best guess
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Canada Worldwide Services - Shipping"}', 1, 'auto_reconcile', 'contains', 'canada worldwide', true, 137, true, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
VALUES (currval('account_reconcile_model_id_seq'), 1, 518, 'percentage', 100, '100', '{"en_US": "Canada Worldwide Shipping/Customs"}', 10, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line_account_tax_rel (account_reconcile_model_line_id, account_tax_id)
VALUES (currval('account_reconcile_model_line_id_seq'), 20);
-- Apple Bill → 6050 IT Expenses + HST (already have model 50 for APPLE.COM but not "apple bill")
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Apple Bill - iCloud/Services"}', 1, 'auto_reconcile', 'contains', 'apple bill', true, 138, true, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
VALUES (currval('account_reconcile_model_id_seq'), 1, 495, 'percentage', 100, '100', '{"en_US": "Apple iCloud/Services Subscription"}', 10, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line_account_tax_rel (account_reconcile_model_line_id, account_tax_id)
VALUES (currval('account_reconcile_model_line_id_seq'), 20);
COMMIT;

99
visa_models2.sql Normal file
View File

@@ -0,0 +1,99 @@
BEGIN;
-- ============================================================
-- Scotia Visa — Tech/SaaS recurring subscriptions
-- ============================================================
-- Cursor AI IDE → 6050 IT Expenses, no HST (US company)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Cursor AI IDE Subscription"}', 1, 'auto_reconcile', 'contains', 'CURSOR', true, 140, true, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
VALUES (currval('account_reconcile_model_id_seq'), 1, 495, 'percentage', 100, '100', '{"en_US": "Cursor AI IDE Subscription"}', 10, 2, 2, NOW(), NOW());
-- VibeCode App → 6050 IT Expenses, no HST (US)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "VibeCode App"}', 1, 'auto_reconcile', 'contains', 'VIBECODEAPP', true, 141, true, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
VALUES (currval('account_reconcile_model_id_seq'), 1, 495, 'percentage', 100, '100', '{"en_US": "VibeCode App Subscription"}', 10, 2, 2, NOW(), NOW());
-- Authorize.net → 6050 IT/Payment Processing, no HST (US)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Authorize.net Payment Gateway"}', 1, 'auto_reconcile', 'contains', 'AUTHORIZE.NET', true, 142, true, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
VALUES (currval('account_reconcile_model_id_seq'), 1, 495, 'percentage', 100, '100', '{"en_US": "Authorize.net Gateway Fee"}', 10, 2, 2, NOW(), NOW());
-- OpenAI / ChatGPT → 6050 IT Expenses, no HST (US)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "OpenAI / ChatGPT Subscription"}', 1, 'auto_reconcile', 'contains', 'OPENAI', true, 143, true, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
VALUES (currval('account_reconcile_model_id_seq'), 1, 495, 'percentage', 100, '100', '{"en_US": "OpenAI ChatGPT/API Subscription"}', 10, 2, 2, NOW(), NOW());
-- Claude AI / Anthropic → 6050 IT Expenses, no HST (US)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Claude AI / Anthropic Subscription"}', 1, 'auto_reconcile', 'contains', 'ANTHROPIC', true, 144, true, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
VALUES (currval('account_reconcile_model_id_seq'), 1, 495, 'percentage', 100, '100', '{"en_US": "Anthropic Claude AI Subscription"}', 10, 2, 2, NOW(), NOW());
-- Also catch CLAUDE.AI variant
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Claude AI Subscription"}', 1, 'auto_reconcile', 'contains', 'CLAUDE.AI', true, 145, true, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
VALUES (currval('account_reconcile_model_id_seq'), 1, 495, 'percentage', 100, '100', '{"en_US": "Claude AI Subscription"}', 10, 2, 2, NOW(), NOW());
-- DataTrail Corp → 6028 Car/Van Expenses + HST (GPS tracking for vehicles)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "DataTrail GPS Tracking"}', 1, 'auto_reconcile', 'contains', 'DATATRAIL', true, 146, true, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
VALUES (currval('account_reconcile_model_id_seq'), 1, 497, 'percentage', 100, '100', '{"en_US": "DataTrail Vehicle GPS Tracking"}', 10, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line_account_tax_rel (account_reconcile_model_line_id, account_tax_id)
VALUES (currval('account_reconcile_model_line_id_seq'), 20);
-- Abacus AI → 6050 IT Expenses, no HST (US)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Abacus AI Platform"}', 1, 'auto_reconcile', 'contains', 'ABACUS', true, 147, true, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
VALUES (currval('account_reconcile_model_id_seq'), 1, 495, 'percentage', 100, '100', '{"en_US": "Abacus AI Platform Subscription"}', 10, 2, 2, NOW(), NOW());
-- GTmetrix → 6050 IT Expenses + HST (Canadian company - Lyon is billing address)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "GTmetrix Website Monitoring"}', 1, 'auto_reconcile', 'contains', 'GTMETRIX', true, 148, true, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
VALUES (currval('account_reconcile_model_id_seq'), 1, 495, 'percentage', 100, '100', '{"en_US": "GTmetrix Website Performance Monitoring"}', 10, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line_account_tax_rel (account_reconcile_model_line_id, account_tax_id)
VALUES (currval('account_reconcile_model_line_id_seq'), 20);
-- Icons8 → 6050 IT Expenses, no HST (German company)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Icons8 Design Assets"}', 1, 'auto_reconcile', 'contains', 'ICONS8', true, 149, true, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
VALUES (currval('account_reconcile_model_id_seq'), 1, 495, 'percentage', 100, '100', '{"en_US": "Icons8 Design Asset Subscription"}', 10, 2, 2, NOW(), NOW());
-- DigitalOcean → 6050 IT Expenses, no HST (US, but bills from NB — still no ON HST)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "DigitalOcean Cloud Hosting"}', 1, 'auto_reconcile', 'contains', 'DIGITALOCEAN', true, 150, true, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
VALUES (currval('account_reconcile_model_id_seq'), 1, 495, 'percentage', 100, '100', '{"en_US": "DigitalOcean Server Hosting"}', 10, 2, 2, NOW(), NOW());
-- AliExpress → 6160 Office Expense, no HST (Chinese marketplace)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "AliExpress Purchases"}', 1, 'auto_reconcile', 'contains', 'aliexpress', true, 151, true, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
VALUES (currval('account_reconcile_model_id_seq'), 1, 507, 'percentage', 100, '100', '{"en_US": "AliExpress Online Purchase"}', 10, 2, 2, NOW(), NOW());
-- Costco (online) → 6160 Office Expense + HST
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Costco Online Purchase"}', 1, 'auto_reconcile', 'contains', 'COSTCO', true, 152, true, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
VALUES (currval('account_reconcile_model_id_seq'), 1, 507, 'percentage', 100, '100', '{"en_US": "Costco Purchase"}', 10, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line_account_tax_rel (account_reconcile_model_line_id, account_tax_id)
VALUES (currval('account_reconcile_model_line_id_seq'), 20);
-- Ad-free Prime Video → 6070 Dues and Subscriptions + HST
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Prime Video Ad-Free"}', 1, 'auto_reconcile', 'contains', 'Ad free for PrimeVideo', true, 153, true, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
VALUES (currval('account_reconcile_model_id_seq'), 1, 501, 'percentage', 100, '100', '{"en_US": "Amazon Prime Video Ad-Free"}', 10, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line_account_tax_rel (account_reconcile_model_line_id, account_tax_id)
VALUES (currval('account_reconcile_model_line_id_seq'), 20);
COMMIT;