changes
This commit is contained in:
129
batch3_models.sql
Normal file
129
batch3_models.sql
Normal 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
53
batch4_models.sql
Normal 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
188
batch5_models.sql
Normal 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
22
batch6_transfers.sql
Normal 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
124
batch7_rbc.sql
Normal 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
91
batch8_fixes.sql
Normal 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
18
batch9_rbc_visa.sql
Normal 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
30
batch_reconcile.py
Normal 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
73
cleanup_duplicates.py
Normal 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
63
debug_reconcile.py
Normal 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)
|
||||||
@@ -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
145
fix_elavon.py
Normal 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
27
fix_from_lines.py
Normal 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
17
fix_no_tax.sql
Normal 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
105
fix_po_vendor_models.sql
Normal 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
170
fix_reconcile_models.sql
Normal 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;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"reason":"idle timeout","timestamp":1775192388322}
|
||||||
@@ -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;">✓ 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;">⚑ 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;">+ 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;">✍ 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>
|
||||||
@@ -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 -$14,917.95 <span style="color:#fbbf24">HST ITC?</span> <span style="font-size:10px; opacity:0.6">Click to expand ▼</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 -$10,173.00 <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>
|
||||||
@@ -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>
|
||||||
@@ -6,19 +6,20 @@ An AI agent (Claude/GPT with tool-calling) embedded in Odoo 19 Enterprise Accoun
|
|||||||
## Architecture
|
## Architecture
|
||||||
```
|
```
|
||||||
fusion_accounting/
|
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/
|
├── services/
|
||||||
│ ├── agent.py AI orchestrator (prompt assembly, tool dispatch loop)
|
│ ├── agent.py AI orchestrator (prompt assembly, tool dispatch loop)
|
||||||
│ ├── adapters/ Claude + OpenAI adapters with native tool-calling
|
│ ├── 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
|
│ ├── prompts/ System prompt builder + 12 domain-specific prompts
|
||||||
│ └── scoring.py Confidence scoring + tier promotion logic
|
│ └── scoring.py Confidence scoring + tier promotion logic
|
||||||
├── controllers/ 8 JSON-RPC endpoints
|
├── controllers/ 10 JSON-RPC endpoints
|
||||||
├── wizards/ Rule creation wizard
|
├── wizards/ Rule creation wizard
|
||||||
├── static/src/ OWL dashboard + chat panel + approval cards
|
├── static/src/ OWL dashboard + chat panel + approval cards
|
||||||
├── views/ List/form/search views, menus, settings
|
├── views/ List/form/search views, menus, settings
|
||||||
├── security/ 3 groups (User/Manager/Admin), record rules, ACLs
|
├── 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
|
└── report/ Audit report QWeb template
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -26,24 +27,62 @@ fusion_accounting/
|
|||||||
|
|
||||||
### AI Provider Integration
|
### 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
|
- 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`)
|
- 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 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
|
### Tool Tiering
|
||||||
- **Tier 1** (Free): Read-only, execute immediately — 60+ tools
|
- **Tier 1** (Free): Read-only, execute immediately — 60+ tools
|
||||||
- **Tier 2** (Auto-approved): Low-risk writes, logged — ~10 tools
|
- **Tier 2** (Auto-approved): Low-risk writes, logged — ~10 tools
|
||||||
- **Tier 3** (Requires approval): Financial writes, user must approve — ~15 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
|
### Menu Location
|
||||||
- **Parent**: `accountant.menu_accounting` (NOT `account.menu_finance` — that's Community Edition only)
|
- **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
|
- 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
|
### Session Persistence
|
||||||
- Chat sessions stored in `fusion.accounting.session` with `message_ids_json` (JSON text field)
|
- 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
|
- 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
|
- "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)
|
## Odoo 19 Gotchas (Learned the Hard Way)
|
||||||
|
|
||||||
@@ -57,6 +96,12 @@ fusion_accounting/
|
|||||||
- Components registered as client actions receive props: `action`, `actionId`, `updateActionState`, `className`
|
- Components registered as client actions receive props: `action`, `actionId`, `updateActionState`, `className`
|
||||||
- Must use `static props = ["*"]` (accept any) — NOT `static props = []` (accept none)
|
- 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
|
### Cron Safe Eval
|
||||||
- NO `import` statements (forbidden opcode `IMPORT_NAME`)
|
- NO `import` statements (forbidden opcode `IMPORT_NAME`)
|
||||||
- `datetime` module available as `datetime` (use `datetime.datetime.now()`, `datetime.timedelta()`)
|
- `datetime` module available as `datetime` (use `datetime.datetime.now()`, `datetime.timedelta()`)
|
||||||
@@ -65,14 +110,20 @@ fusion_accounting/
|
|||||||
### read_group Deprecated
|
### read_group Deprecated
|
||||||
- `read_group()` is deprecated in Odoo 19 — use `_read_group()` instead
|
- `read_group()` is deprecated in Odoo 19 — use `_read_group()` instead
|
||||||
- Still works but throws DeprecationWarning
|
- 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
|
### 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`
|
- 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
|
### Field Label Conflicts
|
||||||
- Odoo warns if two fields on the same model have the same `string` label
|
- 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"
|
- 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
|
### Group Assignment
|
||||||
- `implied_ids` on groups only applies to NEWLY added users, not existing ones
|
- `implied_ids` on groups only applies to NEWLY added users, not existing ones
|
||||||
@@ -85,28 +136,36 @@ fusion_accounting/
|
|||||||
ON CONFLICT DO NOTHING;
|
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 Details
|
||||||
- **Server**: odoo-westin (192.168.1.40, SSH via `ssh odoo-westin`)
|
- **Server**: odoo-westin (192.168.1.40, SSH via `ssh odoo-westin`)
|
||||||
- **Container**: odoo-dev-app (Odoo), odoo-dev-db (PostgreSQL)
|
- **Container**: odoo-dev-app (Odoo), odoo-dev-db (PostgreSQL)
|
||||||
- **Database**: westin-v19
|
- **Database**: westin-v19
|
||||||
- **Module path**: `/mnt/extra-addons/fusion_accounting/`
|
- **Module path**: `/mnt/extra-addons/fusion_accounting/`
|
||||||
- **Python deps**: anthropic (v0.88.0), openai (v2.30.0) — installed with `--break-system-packages`
|
- **Python deps**: anthropic (v0.88.0), openai (v2.30.0) — installed with `--break-system-packages`
|
||||||
|
- **URL**: erp.westinhealthcare.ca
|
||||||
|
|
||||||
## Deployment Commands
|
## Deployment Commands
|
||||||
```bash
|
```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"
|
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
|
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"
|
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"
|
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"
|
ssh odoo-westin "docker restart odoo-dev-app"
|
||||||
|
|
||||||
# Check logs
|
# Check logs
|
||||||
ssh odoo-westin "docker logs odoo-dev-app --tail 100"
|
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
|
## 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
|
Auto-assigned: `account.group_account_user` → User, `account.group_account_manager` → Admin
|
||||||
|
|
||||||
## Models
|
## Controller Endpoints
|
||||||
| Model | Type | Purpose |
|
| Route | Auth | Purpose |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `fusion.accounting.session` | Model | Chat sessions with message JSON storage |
|
| `/fusion_accounting/session/create` | user | Create new chat session |
|
||||||
| `fusion.accounting.match.history` | Model | Every AI tool call + decision (approved/rejected/pending) |
|
| `/fusion_accounting/session/close` | user (ownership check) | Close active session |
|
||||||
| `fusion.accounting.rule` | Model | Fusion Rules engine with versioning and auto-promotion |
|
| `/fusion_accounting/session/latest` | user (own sessions only) | Load most recent active session + messages |
|
||||||
| `fusion.accounting.tool` | Model | Tool registry (82 tools seeded from XML) |
|
| `/fusion_accounting/session/history` | user (ownership check, managers see all) | Load specific session messages |
|
||||||
| `fusion.accounting.dashboard` | TransientModel | Computed health metrics (use `.new()` not `.create()`) |
|
| `/fusion_accounting/chat` | user (ownership check) | Send message, get AI response |
|
||||||
| `fusion.accounting.agent` | AbstractModel | AI orchestrator |
|
| `/fusion_accounting/approve` | user + manager group check | Approve single Tier 3 action |
|
||||||
| `fusion.accounting.adapter.claude` | AbstractModel | Claude tool-calling adapter |
|
| `/fusion_accounting/reject` | user + manager group check | Reject single Tier 3 action |
|
||||||
| `fusion.accounting.adapter.openai` | AbstractModel | OpenAI tool-calling adapter |
|
| `/fusion_accounting/approve_all` | user + manager group check | Batch approve multiple actions |
|
||||||
| `fusion.accounting.scoring` | AbstractModel | Confidence scoring |
|
| `/fusion_accounting/reject_all` | user + manager group check | Batch reject multiple actions |
|
||||||
| `account.move` (inherit) | Model | Post-action audit hook |
|
| `/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
|
## AI Models Available
|
||||||
**Claude** (default: claude-sonnet-4-6):
|
**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
|
- 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
|
- 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)`
|
- 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
|
## Known Issues / Future Work
|
||||||
- `read_group()` deprecation warnings — migrate to `_read_group()` when format is documented
|
- `read_group()` deprecation warnings in `accounting_dashboard.py` — migrate to `_read_group()` when the new API format is stable
|
||||||
- `verify_source_deductions`, `generate_t4`, `generate_roe` are stubs pointing to fusion_payroll (by design — Phase 2)
|
- `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
|
- `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
|
- 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
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ Built by Nexa Systems Inc.
|
|||||||
'views/match_history_views.xml',
|
'views/match_history_views.xml',
|
||||||
'views/rule_views.xml',
|
'views/rule_views.xml',
|
||||||
'views/dashboard_views.xml',
|
'views/dashboard_views.xml',
|
||||||
|
'views/vendor_tax_profile_views.xml',
|
||||||
|
'views/recurring_pattern_views.xml',
|
||||||
'views/menus.xml',
|
'views/menus.xml',
|
||||||
# Wizards
|
# Wizards
|
||||||
'wizards/rule_wizard.xml',
|
'wizards/rule_wizard.xml',
|
||||||
|
|||||||
@@ -9,6 +9,14 @@ _logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
class FusionAccountingChatController(http.Controller):
|
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')
|
@http.route('/fusion_accounting/session/create', type='jsonrpc', auth='user')
|
||||||
def create_session(self, context_domain=None, **kwargs):
|
def create_session(self, context_domain=None, **kwargs):
|
||||||
session = request.env['fusion.accounting.session'].create({
|
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')
|
@http.route('/fusion_accounting/session/close', type='jsonrpc', auth='user')
|
||||||
def close_session(self, session_id, **kwargs):
|
def close_session(self, session_id, **kwargs):
|
||||||
session = request.env['fusion.accounting.session'].browse(int(session_id))
|
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()
|
session.action_close_session()
|
||||||
return {'status': 'closed'}
|
return {'status': 'closed'}
|
||||||
|
|
||||||
@@ -29,6 +43,12 @@ class FusionAccountingChatController(http.Controller):
|
|||||||
def chat(self, session_id, message, context=None, **kwargs):
|
def chat(self, session_id, message, context=None, **kwargs):
|
||||||
if not message:
|
if not message:
|
||||||
return {'error': 'Message is required'}
|
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']
|
agent = request.env['fusion.accounting.agent']
|
||||||
result = agent.chat(int(session_id), message, context=context)
|
result = agent.chat(int(session_id), message, context=context)
|
||||||
return result
|
return result
|
||||||
@@ -51,6 +71,8 @@ class FusionAccountingChatController(http.Controller):
|
|||||||
|
|
||||||
@http.route('/fusion_accounting/dashboard/data', type='jsonrpc', auth='user')
|
@http.route('/fusion_accounting/dashboard/data', type='jsonrpc', auth='user')
|
||||||
def dashboard_data(self, **kwargs):
|
def dashboard_data(self, **kwargs):
|
||||||
|
# E2: Wrap in try/except so dashboard doesn't return 500
|
||||||
|
try:
|
||||||
dashboard = request.env['fusion.accounting.dashboard'].new({
|
dashboard = request.env['fusion.accounting.dashboard'].new({
|
||||||
'company_id': request.env.company.id,
|
'company_id': request.env.company.id,
|
||||||
})
|
})
|
||||||
@@ -61,6 +83,22 @@ class FusionAccountingChatController(http.Controller):
|
|||||||
'hst': {'balance': dashboard.hst_balance},
|
'hst': {'balance': dashboard.hst_balance},
|
||||||
'audit': {'score': dashboard.audit_score, 'flags': dashboard.audit_flag_count},
|
'audit': {'score': dashboard.audit_score, 'flags': dashboard.audit_flag_count},
|
||||||
'month_end': {'status': dashboard.month_end_status, 'open_items': dashboard.month_end_open_items},
|
'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')
|
@http.route('/fusion_accounting/approve_all', type='jsonrpc', auth='user')
|
||||||
@@ -74,7 +112,9 @@ class FusionAccountingChatController(http.Controller):
|
|||||||
result = agent.approve_action(int(mid))
|
result = agent.approve_action(int(mid))
|
||||||
results.append({'id': mid, 'status': 'approved', 'result': result})
|
results.append({'id': mid, 'status': 'approved', 'result': result})
|
||||||
except Exception as e:
|
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}
|
return {'results': results}
|
||||||
|
|
||||||
@http.route('/fusion_accounting/reject_all', type='jsonrpc', auth='user')
|
@http.route('/fusion_accounting/reject_all', type='jsonrpc', auth='user')
|
||||||
@@ -86,19 +126,58 @@ class FusionAccountingChatController(http.Controller):
|
|||||||
for mid in match_history_ids:
|
for mid in match_history_ids:
|
||||||
try:
|
try:
|
||||||
result = agent.reject_action(int(mid), reason)
|
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:
|
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}
|
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')
|
@http.route('/fusion_accounting/session/latest', type='jsonrpc', auth='user')
|
||||||
def session_latest(self, **kwargs):
|
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),
|
('user_id', '=', request.env.user.id),
|
||||||
('state', '=', 'active'),
|
('state', '=', 'active'),
|
||||||
], limit=1, order='create_date desc')
|
], order='write_date desc', limit=10)
|
||||||
if not session:
|
if not sessions:
|
||||||
return {'session_id': None, 'messages': [], 'name': None}
|
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 '[]')
|
messages = json.loads(session.message_ids_json or '[]')
|
||||||
display_messages = []
|
display_messages = []
|
||||||
for msg in messages:
|
for msg in messages:
|
||||||
@@ -119,6 +198,10 @@ class FusionAccountingChatController(http.Controller):
|
|||||||
session = request.env['fusion.accounting.session'].browse(int(session_id))
|
session = request.env['fusion.accounting.session'].browse(int(session_id))
|
||||||
if not session.exists():
|
if not session.exists():
|
||||||
return {'error': 'Session not found'}
|
return {'error': 'Session not found'}
|
||||||
|
# S1: Ownership check
|
||||||
|
error = self._check_session_ownership(session)
|
||||||
|
if error:
|
||||||
|
return error
|
||||||
return {
|
return {
|
||||||
'messages': json.loads(session.message_ids_json or '[]'),
|
'messages': json.loads(session.message_ids_json or '[]'),
|
||||||
'session_id': session.id,
|
'session_id': session.id,
|
||||||
|
|||||||
@@ -36,4 +36,37 @@ for rule in model.search([('active', '=', True), ('approval_tier', '=', 'needs_a
|
|||||||
<field name="interval_type">days</field>
|
<field name="interval_type">days</field>
|
||||||
<field name="active">True</field>
|
<field name="active">True</field>
|
||||||
</record>
|
</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>
|
</odoo>
|
||||||
|
|||||||
@@ -65,7 +65,7 @@
|
|||||||
<record id="tool_sum_payments_by_date" model="fusion.accounting.tool">
|
<record id="tool_sum_payments_by_date" model="fusion.accounting.tool">
|
||||||
<field name="name">sum_payments_by_date</field>
|
<field name="name">sum_payments_by_date</field>
|
||||||
<field name="display_name_field">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="domain">bank_reconciliation</field>
|
||||||
<field name="tier">1</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>
|
<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="tier">1</field>
|
||||||
<field name="parameters_schema">{"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}}</field>
|
<field name="parameters_schema">{"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}}</field>
|
||||||
</record>
|
</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>
|
</odoo>
|
||||||
|
|||||||
@@ -5,3 +5,5 @@ from . import accounting_match_history
|
|||||||
from . import accounting_rule
|
from . import accounting_rule
|
||||||
from . import accounting_dashboard
|
from . import accounting_dashboard
|
||||||
from . import account_move_hook
|
from . import account_move_hook
|
||||||
|
from . import vendor_tax_profile
|
||||||
|
from . import recurring_pattern
|
||||||
|
|||||||
@@ -34,9 +34,14 @@ class AccountMoveAuditHook(models.Model):
|
|||||||
for line in move.line_ids:
|
for line in move.line_ids:
|
||||||
if not line.account_id:
|
if not line.account_id:
|
||||||
issues.append(f'Line missing account: {line.name}')
|
issues.append(f'Line missing account: {line.name}')
|
||||||
if line.product_id and not line.tax_ids:
|
# M6: Only flag missing tax when the product has taxes configured
|
||||||
if move.move_type in ('out_invoice', 'out_refund', 'in_invoice', 'in_refund'):
|
# (avoids false positives for HST-exempt healthcare services)
|
||||||
issues.append(f'Missing tax on product line: {line.product_id.name}')
|
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:
|
if not move.line_ids:
|
||||||
issues.append('Entry has no lines')
|
issues.append('Entry has no lines')
|
||||||
|
|||||||
@@ -153,11 +153,15 @@ class FusionAccountingDashboard(models.TransientModel):
|
|||||||
if balance > 0.01:
|
if balance > 0.01:
|
||||||
issues += 1
|
issues += 1
|
||||||
|
|
||||||
|
# M4: Guard against made_sequence_gap field not existing
|
||||||
|
try:
|
||||||
gaps = self.env['account.move'].search_count([
|
gaps = self.env['account.move'].search_count([
|
||||||
('state', '=', 'posted'),
|
('state', '=', 'posted'),
|
||||||
('company_id', '=', rec.company_id.id),
|
('company_id', '=', rec.company_id.id),
|
||||||
('made_sequence_gap', '=', True),
|
('made_sequence_gap', '=', True),
|
||||||
])
|
])
|
||||||
|
except (ValueError, KeyError):
|
||||||
|
gaps = 0
|
||||||
issues += gaps
|
issues += gaps
|
||||||
|
|
||||||
pending_approvals = self.env['fusion.accounting.match.history'].search_count([
|
pending_approvals = self.env['fusion.accounting.match.history'].search_count([
|
||||||
@@ -267,7 +271,7 @@ class FusionAccountingDashboard(models.TransientModel):
|
|||||||
rec.recent_activity_json = json.dumps([{
|
rec.recent_activity_json = json.dumps([{
|
||||||
'tool': r.tool_name,
|
'tool': r.tool_name,
|
||||||
'decision': r.decision,
|
'decision': r.decision,
|
||||||
'date': str(r.proposed_at),
|
'date': r.proposed_at.isoformat() if r.proposed_at else '',
|
||||||
'amount': r.amount,
|
'amount': r.amount,
|
||||||
} for r in recent])
|
} for r in recent])
|
||||||
|
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ class FusionAccountingRule(models.Model):
|
|||||||
if (rec.approval_tier == 'needs_approval'
|
if (rec.approval_tier == 'needs_approval'
|
||||||
and rec.total_uses >= rec.min_sample_size
|
and rec.total_uses >= rec.min_sample_size
|
||||||
and rec.confidence_score >= rec.promotion_threshold):
|
and rec.confidence_score >= rec.promotion_threshold):
|
||||||
rec.approval_tier = 'auto'
|
rec.write({'approval_tier': 'auto'})
|
||||||
_logger.info(
|
_logger.info(
|
||||||
"Rule '%s' promoted to auto-approved (confidence=%.2f, uses=%d)",
|
"Rule '%s' promoted to auto-approved (confidence=%.2f, uses=%d)",
|
||||||
rec.name, rec.confidence_score, rec.total_uses,
|
rec.name, rec.confidence_score, rec.total_uses,
|
||||||
@@ -116,5 +116,6 @@ class FusionAccountingRule(models.Model):
|
|||||||
def action_rollback(self):
|
def action_rollback(self):
|
||||||
for rec in self:
|
for rec in self:
|
||||||
if rec.parent_rule_id:
|
if rec.parent_rule_id:
|
||||||
rec.active = False
|
# M5: Use write() to trigger tracking on tracked fields
|
||||||
rec.parent_rule_id.active = True
|
rec.write({'active': False})
|
||||||
|
rec.parent_rule_id.write({'active': True})
|
||||||
|
|||||||
216
fusion_accounting/models/recurring_pattern.py
Normal file
216
fusion_accounting/models/recurring_pattern.py
Normal 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}
|
||||||
221
fusion_accounting/models/vendor_tax_profile.py
Normal file
221
fusion_accounting/models/vendor_tax_profile.py
Normal 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}
|
||||||
@@ -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_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_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_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,12 +1,21 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
from odoo import models, fields, api, _
|
from odoo import models, fields, api, _
|
||||||
from odoo.exceptions import UserError
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_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):
|
class FusionAccountingAgent(models.AbstractModel):
|
||||||
_name = 'fusion.accounting.agent'
|
_name = 'fusion.accounting.agent'
|
||||||
@@ -41,9 +50,14 @@ class FusionAccountingAgent(models.AbstractModel):
|
|||||||
def _build_tool_definitions(self, tools):
|
def _build_tool_definitions(self, tools):
|
||||||
definitions = []
|
definitions = []
|
||||||
for tool in tools:
|
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 = {
|
defn = {
|
||||||
'name': tool.name,
|
'name': tool.name,
|
||||||
'description': tool.description,
|
'description': desc,
|
||||||
}
|
}
|
||||||
if tool.parameters_schema:
|
if tool.parameters_schema:
|
||||||
try:
|
try:
|
||||||
@@ -117,6 +131,21 @@ class FusionAccountingAgent(models.AbstractModel):
|
|||||||
raise UserError(_("Session not found."))
|
raise UserError(_("Session not found."))
|
||||||
|
|
||||||
adapter = self._get_adapter()
|
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()
|
tools = self._get_tools_for_user()
|
||||||
tool_definitions = self._build_tool_definitions(tools)
|
tool_definitions = self._build_tool_definitions(tools)
|
||||||
rules = self._load_rules()
|
rules = self._load_rules()
|
||||||
@@ -132,6 +161,7 @@ class FusionAccountingAgent(models.AbstractModel):
|
|||||||
total_tokens_in = 0
|
total_tokens_in = 0
|
||||||
total_tokens_out = 0
|
total_tokens_out = 0
|
||||||
response = {'text': '', 'tool_calls': None}
|
response = {'text': '', 'tool_calls': None}
|
||||||
|
has_pending_tier3 = False
|
||||||
|
|
||||||
for turn in range(max_turns):
|
for turn in range(max_turns):
|
||||||
response = adapter.call_with_tools(
|
response = adapter.call_with_tools(
|
||||||
@@ -151,6 +181,7 @@ class FusionAccountingAgent(models.AbstractModel):
|
|||||||
tier = tool_rec.tier if tool_rec else '1'
|
tier = tool_rec.tier if tool_rec else '1'
|
||||||
|
|
||||||
if tier == '3':
|
if tier == '3':
|
||||||
|
has_pending_tier3 = True
|
||||||
history_rec = self._log_match_history(
|
history_rec = self._log_match_history(
|
||||||
session, tool_name, tool_params, None,
|
session, tool_name, tool_params, None,
|
||||||
reasoning=tc.get('reasoning', ''),
|
reasoning=tc.get('reasoning', ''),
|
||||||
@@ -184,7 +215,29 @@ class FusionAccountingAgent(models.AbstractModel):
|
|||||||
messages_json = adapter.append_tool_results(
|
messages_json = adapter.append_tool_results(
|
||||||
messages_json, response, 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:
|
else:
|
||||||
assistant_text = response.get('text', '')
|
assistant_text = response.get('text', '')
|
||||||
messages_json.append({'role': 'assistant', 'content': assistant_text})
|
messages_json.append({'role': 'assistant', 'content': assistant_text})
|
||||||
@@ -210,7 +263,7 @@ class FusionAccountingAgent(models.AbstractModel):
|
|||||||
'message_ids_json': json.dumps(messages_json),
|
'message_ids_json': json.dumps(messages_json),
|
||||||
'token_count_in': session.token_count_in + total_tokens_in,
|
'token_count_in': session.token_count_in + total_tokens_in,
|
||||||
'token_count_out': session.token_count_out + total_tokens_out,
|
'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(),
|
'ai_model': adapter._get_model_name(),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -249,6 +302,15 @@ class FusionAccountingAgent(models.AbstractModel):
|
|||||||
if history.rule_id:
|
if history.rule_id:
|
||||||
history.rule_id.sudo()._record_decision(approved=True)
|
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
|
return result
|
||||||
|
|
||||||
def _check_rule_proposal(self, tool_name, params, session):
|
def _check_rule_proposal(self, tool_name, params, session):
|
||||||
@@ -312,4 +374,133 @@ class FusionAccountingAgent(models.AbstractModel):
|
|||||||
if history.rule_id:
|
if history.rule_id:
|
||||||
history.rule_id.sudo()._record_decision(approved=False)
|
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)
|
||||||
|
|||||||
@@ -18,6 +18,54 @@ You are helping with Canadian HST/GST tax management.
|
|||||||
- Net HST = Collected - ITCs. Positive means owing to CRA.
|
- Net HST = Collected - ITCs. Positive means owing to CRA.
|
||||||
- Quarterly filing periods. Check for missing tax on invoices/bills.
|
- Quarterly filing periods. Check for missing tax on invoices/bills.
|
||||||
- All vendor bills should have ITCs unless explicitly exempt.
|
- 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': """
|
'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):
|
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
|
||||||
|
|||||||
@@ -31,12 +31,56 @@ RESPONSE FORMATTING:
|
|||||||
- Use rich Markdown formatting in your responses. The chat renders Markdown as HTML.
|
- Use rich Markdown formatting in your responses. The chat renders Markdown as HTML.
|
||||||
- Use **bold** for account names, amounts, and key terms.
|
- Use **bold** for account names, amounts, and key terms.
|
||||||
- Use ## and ### headers to organize sections in longer responses.
|
- 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 bullet lists (- item) for findings, issues, and action items.
|
||||||
- Use numbered lists (1. item) for sequential steps or ranked items.
|
- Use numbered lists (1. item) for sequential steps or ranked items.
|
||||||
- Use `code` for account codes, reference numbers, and technical IDs.
|
- Use `code` for account codes, reference numbers, and technical IDs.
|
||||||
- Use --- horizontal rules to separate sections in long reports.
|
- 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:
|
LINKING TO ODOO RECORDS:
|
||||||
- When referencing specific records, include clickable Odoo links.
|
- When referencing specific records, include clickable Odoo links.
|
||||||
- Journal entries: [INV/2026/00123](/odoo/accounting/123) where 123 is the move ID.
|
- 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:
|
for rule in rules:
|
||||||
priority = 'ADMIN' if rule.created_by == 'admin' else 'AI'
|
priority = 'ADMIN' if rule.created_by == 'admin' else 'AI'
|
||||||
tier = 'auto' if rule.approval_tier == 'auto' else 'needs-approval'
|
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(
|
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"}'
|
f'{rule.description or rule.match_logic or "No description"}'
|
||||||
)
|
)
|
||||||
if rule.match_logic:
|
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)
|
return '\n'.join(lines)
|
||||||
|
|
||||||
|
|
||||||
@@ -73,7 +119,9 @@ def _build_history_section(history):
|
|||||||
if not history:
|
if not history:
|
||||||
return ''
|
return ''
|
||||||
lines = ['RECENT MATCH HISTORY (learn from these patterns):']
|
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
|
status = h.decision
|
||||||
reason = ''
|
reason = ''
|
||||||
if h.rejection_reason:
|
if h.rejection_reason:
|
||||||
|
|||||||
@@ -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 = {
|
TOOLS = {
|
||||||
'get_ap_aging': get_ap_aging,
|
'get_ap_aging': get_ap_aging,
|
||||||
'find_duplicate_bills': find_duplicate_bills,
|
'find_duplicate_bills': find_duplicate_bills,
|
||||||
@@ -147,4 +399,8 @@ TOOLS = {
|
|||||||
'get_unpaid_bills': get_unpaid_bills,
|
'get_unpaid_bills': get_unpaid_bills,
|
||||||
'verify_bill_taxes': verify_bill_taxes,
|
'verify_bill_taxes': verify_bill_taxes,
|
||||||
'get_payment_schedule': get_payment_schedule,
|
'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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,7 +69,11 @@ def flag_entry(env, params):
|
|||||||
|
|
||||||
|
|
||||||
def get_audit_status(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 {
|
return {
|
||||||
'statuses': [{
|
'statuses': [{
|
||||||
'id': s.id,
|
'id': s.id,
|
||||||
@@ -81,9 +85,13 @@ def get_audit_status(env, params):
|
|||||||
|
|
||||||
|
|
||||||
def set_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'])
|
status_id = int(params['status_id'])
|
||||||
new_status = params['status']
|
new_status = params['status']
|
||||||
rec = env['account.audit.account.status'].browse(status_id)
|
rec = AuditStatus.browse(status_id)
|
||||||
if not rec.exists():
|
if not rec.exists():
|
||||||
return {'error': 'Audit status record not found'}
|
return {'error': 'Audit status record not found'}
|
||||||
rec.status = new_status
|
rec.status = new_status
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from odoo import fields
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -139,6 +140,10 @@ def get_reconcile_suggestions(env, params):
|
|||||||
|
|
||||||
|
|
||||||
def sum_payments_by_date(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_from = params.get('date_from')
|
||||||
date_to = params.get('date_to')
|
date_to = params.get('date_to')
|
||||||
if not date_from or not 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_from),
|
||||||
('date', '<=', date_to),
|
('date', '<=', date_to),
|
||||||
]
|
]
|
||||||
|
scope = 'all journals'
|
||||||
if journal_ids:
|
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)
|
lines = env['account.move.line'].search(domain)
|
||||||
total_debit = sum(l.debit for l in lines)
|
total_debit = sum(l.debit for l in lines)
|
||||||
total_credit = sum(l.credit for l in lines)
|
total_credit = sum(l.credit for l in lines)
|
||||||
return {
|
|
||||||
|
result = {
|
||||||
'date_from': date_from,
|
'date_from': date_from,
|
||||||
'date_to': date_to,
|
'date_to': date_to,
|
||||||
'total_debit': total_debit,
|
'total_debit': total_debit,
|
||||||
'total_credit': total_credit,
|
'total_credit': total_credit,
|
||||||
'net': total_debit - total_credit,
|
'net': total_debit - total_credit,
|
||||||
'line_count': len(lines),
|
'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,
|
'unmatch_bank_line': unmatch_bank_line,
|
||||||
'get_reconcile_suggestions': get_reconcile_suggestions,
|
'get_reconcile_suggestions': get_reconcile_suggestions,
|
||||||
'sum_payments_by_date': sum_payments_by_date,
|
'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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,12 +15,22 @@ def calculate_hst_balance(env, params):
|
|||||||
if date_to:
|
if date_to:
|
||||||
base_domain.append(('date', '<=', date_to))
|
base_domain.append(('date', '<=', date_to))
|
||||||
|
|
||||||
|
# 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([
|
collected_accounts = env['account.account'].search([
|
||||||
('code', '=like', '2005%'), ('company_id', '=', env.company.id),
|
('code', '=like', '2005%'), ('company_id', '=', env.company.id),
|
||||||
])
|
])
|
||||||
itc_accounts = env['account.account'].search([
|
itc_accounts = env['account.account'].search([
|
||||||
('code', '=like', '2006%'), ('company_id', '=', env.company.id),
|
('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(
|
collected_lines = env['account.move.line'].search(
|
||||||
base_domain + [('account_id', 'in', collected_accounts.ids)]
|
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):
|
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),
|
('company_id', '=', env.company.id),
|
||||||
], order='date_start desc', limit=10)
|
], order='date_start desc', limit=10)
|
||||||
return {
|
return {
|
||||||
@@ -140,7 +154,11 @@ def get_tax_return_status(env, params):
|
|||||||
|
|
||||||
def generate_tax_return(env, params):
|
def generate_tax_return(env, params):
|
||||||
try:
|
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
|
company=env.company
|
||||||
)
|
)
|
||||||
return {'status': 'generated', 'message': 'Tax returns refreshed successfully.'}
|
return {'status': 'generated', 'message': 'Tax returns refreshed successfully.'}
|
||||||
@@ -149,8 +167,12 @@ def generate_tax_return(env, params):
|
|||||||
|
|
||||||
|
|
||||||
def validate_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'])
|
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():
|
if not tax_return.exists():
|
||||||
return {'error': 'Tax return not found'}
|
return {'error': 'Tax return not found'}
|
||||||
try:
|
try:
|
||||||
@@ -160,6 +182,111 @@ def validate_tax_return(env, params):
|
|||||||
return {'error': str(e)}
|
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 = {
|
TOOLS = {
|
||||||
'calculate_hst_balance': calculate_hst_balance,
|
'calculate_hst_balance': calculate_hst_balance,
|
||||||
'get_tax_report': get_tax_report,
|
'get_tax_report': get_tax_report,
|
||||||
@@ -168,4 +295,5 @@ TOOLS = {
|
|||||||
'get_tax_return_status': get_tax_return_status,
|
'get_tax_return_status': get_tax_return_status,
|
||||||
'generate_tax_return': generate_tax_return,
|
'generate_tax_return': generate_tax_return,
|
||||||
'validate_tax_return': validate_tax_return,
|
'validate_tax_return': validate_tax_return,
|
||||||
|
'create_expense_entry': create_expense_entry,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,48 @@ import { Component, useState, useRef, onWillStart, onMounted, onPatched } from "
|
|||||||
import { rpc } from "@web/core/network/rpc";
|
import { rpc } from "@web/core/network/rpc";
|
||||||
import { FusionApprovalCard } from "./approval_card";
|
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) {
|
function mdToHtml(text) {
|
||||||
if (!text) return "";
|
if (!text) return "";
|
||||||
|
|
||||||
@@ -150,6 +192,8 @@ export class FusionChatPanel extends Component {
|
|||||||
setup() {
|
setup() {
|
||||||
this.inputRef = useRef("chatInput");
|
this.inputRef = useRef("chatInput");
|
||||||
this.messagesRef = useRef("messages");
|
this.messagesRef = useRef("messages");
|
||||||
|
// Track parsed table data per message index for interactive tables
|
||||||
|
this._parsedTables = {};
|
||||||
this.state = useState({
|
this.state = useState({
|
||||||
messages: [],
|
messages: [],
|
||||||
pendingApprovals: [],
|
pendingApprovals: [],
|
||||||
@@ -158,6 +202,11 @@ export class FusionChatPanel extends Component {
|
|||||||
loading: true,
|
loading: true,
|
||||||
internalSessionId: null,
|
internalSessionId: null,
|
||||||
sessionName: null,
|
sessionName: null,
|
||||||
|
// Interactive tables extracted from AI messages, keyed by msg index
|
||||||
|
interactiveTables: {},
|
||||||
|
// Session history picker
|
||||||
|
showSessionPicker: false,
|
||||||
|
sessionList: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
onWillStart(async () => {
|
onWillStart(async () => {
|
||||||
@@ -181,6 +230,27 @@ export class FusionChatPanel extends Component {
|
|||||||
const idx = parseInt(div.dataset.idx);
|
const idx = parseInt(div.dataset.idx);
|
||||||
const msg = this.state.messages[idx];
|
const msg = this.state.messages[idx];
|
||||||
if (msg && msg.role === "assistant" && msg.content) {
|
if (msg && msg.role === "assistant" && msg.content) {
|
||||||
|
// 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);
|
const html = mdToHtml(msg.content);
|
||||||
if (div.innerHTML !== html) {
|
if (div.innerHTML !== html) {
|
||||||
div.innerHTML = html;
|
div.innerHTML = html;
|
||||||
@@ -188,6 +258,211 @@ export class FusionChatPanel extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_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() {
|
get sessionId() {
|
||||||
return this.state.internalSessionId || this.props.sessionId;
|
return this.state.internalSessionId || this.props.sessionId;
|
||||||
@@ -209,17 +484,87 @@ export class FusionChatPanel extends Component {
|
|||||||
this.scrollToBottom();
|
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() {
|
async onNewChat() {
|
||||||
|
// Close current session first — must succeed before creating new one
|
||||||
if (this.sessionId) {
|
if (this.sessionId) {
|
||||||
try {
|
try {
|
||||||
await rpc("/fusion_accounting/session/close", { session_id: this.sessionId });
|
const closeResult = await rpc("/fusion_accounting/session/close", { session_id: this.sessionId });
|
||||||
} catch (e) { /* not critical */ }
|
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", {});
|
const session = await rpc("/fusion_accounting/session/create", {});
|
||||||
this.state.internalSessionId = session.session_id;
|
this.state.internalSessionId = session.session_id;
|
||||||
this.state.sessionName = session.name;
|
this.state.sessionName = session.name;
|
||||||
this.state.messages = [];
|
this.state.messages = [];
|
||||||
this.state.pendingApprovals = [];
|
this.state.pendingApprovals = [];
|
||||||
|
this._parsedTables = {};
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to create new session:", e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendMessage() {
|
async sendMessage() {
|
||||||
@@ -258,6 +603,66 @@ export class FusionChatPanel extends Component {
|
|||||||
this.scrollToBottom();
|
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) {
|
onKeyDown(ev) {
|
||||||
if (ev.key === "Enter" && !ev.shiftKey) {
|
if (ev.key === "Enter" && !ev.shiftKey) {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
|
|||||||
@@ -3,15 +3,59 @@
|
|||||||
<t t-name="fusion_accounting.ChatPanel">
|
<t t-name="fusion_accounting.ChatPanel">
|
||||||
<div class="fusion_chat_panel card h-100 d-flex flex-column">
|
<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 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>
|
<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"/>
|
<small class="text-muted ms-2" t-if="state.sessionName" t-esc="state.sessionName"/>
|
||||||
</div>
|
</div>
|
||||||
|
<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"
|
<button class="btn btn-outline-secondary btn-sm" t-on-click="onNewChat"
|
||||||
title="Start a new conversation">
|
title="Start a new conversation">
|
||||||
<i class="fa fa-plus me-1"/>New Chat
|
<i class="fa fa-plus me-1"/>New Chat
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 -->
|
<!-- Messages -->
|
||||||
<div class="fusion_chat_messages flex-grow-1 overflow-auto p-3" t-ref="messages">
|
<div class="fusion_chat_messages flex-grow-1 overflow-auto p-3" t-ref="messages">
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -17,8 +17,14 @@
|
|||||||
</t>
|
</t>
|
||||||
|
|
||||||
<t t-else="">
|
<t t-else="">
|
||||||
<!-- Health Cards -->
|
<!-- Main layout: Left panel (cards + needs attention) | Right panel (chat) -->
|
||||||
<div class="fusion_health_cards d-flex flex-wrap gap-3 p-3">
|
<div class="fusion_main_layout d-flex">
|
||||||
|
|
||||||
|
<!-- 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">
|
<t t-foreach="cards" t-as="card" t-key="card.domain">
|
||||||
<FusionHealthCard
|
<FusionHealthCard
|
||||||
title="card.title"
|
title="card.title"
|
||||||
@@ -30,22 +36,33 @@
|
|||||||
</t>
|
</t>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Action Centre + Chat -->
|
<!-- Needs Attention Panel -->
|
||||||
<div class="d-flex gap-3 p-3" style="min-height: 500px;">
|
<div class="card fusion_attention_card">
|
||||||
<!-- Action Centre -->
|
<div class="card-header py-2">
|
||||||
<div class="flex-grow-1">
|
<h5 class="mb-0"><i class="fa fa-exclamation-triangle me-2 text-warning"/>Needs Attention</h5>
|
||||||
<div class="card h-100">
|
|
||||||
<div class="card-header">
|
|
||||||
<h5 class="mb-0">Needs Attention</h5>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body overflow-auto">
|
<div class="card-body overflow-auto p-2">
|
||||||
<p class="text-muted">AI-prioritised items will appear here after the first audit scan.</p>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Chat Panel (720px = original 400 + 80%) -->
|
<!-- RIGHT SIDE: Chat Panel (full height, input pinned to bottom) -->
|
||||||
<div style="width: 720px; min-width: 600px;">
|
<div class="fusion_right_panel border-start">
|
||||||
<FusionChatPanel sessionId="state.chatSessionId"/>
|
<FusionChatPanel sessionId="state.chatSessionId"/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 {
|
.fusion_chat_input {
|
||||||
|
flex-shrink: 0;
|
||||||
textarea {
|
textarea {
|
||||||
resize: none;
|
resize: none;
|
||||||
}
|
}
|
||||||
@@ -69,4 +82,83 @@
|
|||||||
.fusion_approval_card {
|
.fusion_approval_card {
|
||||||
border-left: 3px solid var(--bs-warning);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,41 @@
|
|||||||
.fusion_accounting_dashboard {
|
.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 {
|
.fusion_dashboard_header {
|
||||||
border-bottom: 1px solid var(--o-border-color);
|
border-bottom: 1px solid var(--o-border-color);
|
||||||
background: var(--o-view-background-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 {
|
.fusion_health_cards {
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
.fusion_health_card {
|
.fusion_health_card {
|
||||||
|
flex: 0 0 calc(33.333% - 6px);
|
||||||
|
min-width: 150px;
|
||||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||||
&:hover {
|
&:hover {
|
||||||
transform: translateY(-2px);
|
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%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,22 @@
|
|||||||
sequence="40"
|
sequence="40"
|
||||||
groups="group_fusion_accounting_manager"/>
|
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) -->
|
<!-- Configuration (link to settings) -->
|
||||||
<menuitem id="menu_fusion_config"
|
<menuitem id="menu_fusion_config"
|
||||||
name="Configuration"
|
name="Configuration"
|
||||||
|
|||||||
86
fusion_accounting/views/recurring_pattern_views.xml
Normal file
86
fusion_accounting/views/recurring_pattern_views.xml
Normal 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>
|
||||||
95
fusion_accounting/views/vendor_tax_profile_views.xml
Normal file
95
fusion_accounting/views/vendor_tax_profile_views.xml
Normal 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>
|
||||||
@@ -24,10 +24,12 @@
|
|||||||
'views/account_move_views.xml',
|
'views/account_move_views.xml',
|
||||||
'views/sale_order_views.xml',
|
'views/sale_order_views.xml',
|
||||||
'views/res_config_settings_views.xml',
|
'views/res_config_settings_views.xml',
|
||||||
|
'views/poynt_settlement_views.xml',
|
||||||
'wizard/poynt_payment_wizard_views.xml',
|
'wizard/poynt_payment_wizard_views.xml',
|
||||||
'wizard/poynt_refund_wizard_views.xml',
|
'wizard/poynt_refund_wizard_views.xml',
|
||||||
|
|
||||||
'data/payment_provider_data.xml',
|
'data/payment_provider_data.xml',
|
||||||
|
'data/poynt_settlement_data.xml',
|
||||||
'data/poynt_receipt_email_template.xml',
|
'data/poynt_receipt_email_template.xml',
|
||||||
],
|
],
|
||||||
'post_init_hook': 'post_init_hook',
|
'post_init_hook': 'post_init_hook',
|
||||||
|
|||||||
24
fusion_poynt/data/poynt_settlement_data.xml
Normal file
24
fusion_poynt/data/poynt_settlement_data.xml
Normal 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>
|
||||||
@@ -4,6 +4,7 @@ from . import account_move
|
|||||||
from . import payment_provider
|
from . import payment_provider
|
||||||
from . import payment_token
|
from . import payment_token
|
||||||
from . import payment_transaction
|
from . import payment_transaction
|
||||||
|
from . import poynt_settlement
|
||||||
from . import poynt_terminal
|
from . import poynt_terminal
|
||||||
from . import res_config_settings
|
from . import res_config_settings
|
||||||
from . import sale_order
|
from . import sale_order
|
||||||
|
|||||||
@@ -539,6 +539,61 @@ class PaymentProvider(models.Model):
|
|||||||
)
|
)
|
||||||
return None
|
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'):
|
def _poynt_notification(self, message, notification_type='info'):
|
||||||
"""Return a display_notification action.
|
"""Return a display_notification action.
|
||||||
|
|
||||||
|
|||||||
632
fusion_poynt/models/poynt_settlement.py
Normal file
632
fusion_poynt/models/poynt_settlement.py
Normal 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
|
||||||
@@ -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_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_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_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
|
||||||
|
|||||||
|
231
fusion_poynt/views/poynt_settlement_views.xml
Normal file
231
fusion_poynt/views/poynt_settlement_views.xml
Normal 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>
|
||||||
74
match_outstanding_receipts.py
Normal file
74
match_outstanding_receipts.py
Normal 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
123
match_poynt_customers.py
Normal 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
96
match_poynt_refunds.py
Normal 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
135
match_poynt_v2.py
Normal 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
65
match_poynt_v3.py
Normal 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
78
match_rbc_transfers.py
Normal 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
107
merge_models.sql
Normal 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
48
process_poynt_batches.py
Normal 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
90
rename_models.sql
Normal 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
107
run_poynt_sync.py
Normal 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
77
run_transfer_reconcile.py
Normal 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
2
session7.txt
Normal file
File diff suppressed because one or more lines are too long
27
test_transfer_match.py
Normal file
27
test_transfer_match.py
Normal 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
74
visa_models.sql
Normal 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
99
visa_models2.sql
Normal 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;
|
||||||
Reference in New Issue
Block a user