diff --git a/supabase/functions/process-email-queue/index.ts b/supabase/functions/process-email-queue/index.ts index 80defa6..98780f9 100644 --- a/supabase/functions/process-email-queue/index.ts +++ b/supabase/functions/process-email-queue/index.ts @@ -5,11 +5,9 @@ import { sendViaHostingerMail, type HostingerMailConfig } from '../_shared/hosti // Which sender identity automated email is sent from. Must be an active row in // public.email_senders that the SMTP account is authorized to send as. Override // per-environment with the AUTOMATED_EMAIL_FROM secret. -// NOTE: no-reply@avriamail.com (Office365) is the sender with currently-valid -// SMTP credentials — it is the address that has actually been delivering mail. -// The Hostinger mail@avriacam.com mailbox rejects its stored password (SMTP 535) -// and must have its password re-entered in Email Settings before it can be used. -const DEFAULT_AUTOMATED_FROM = 'no-reply@avriamail.com' +// notifications@avriamail.com (Hostinger) is the single sender of record for all +// automated/transactional mail. The retired mail@avriacam.com mailbox was removed. +const DEFAULT_AUTOMATED_FROM = 'notifications@avriamail.com' // Load (and shape) the SMTP sender used for all queued automated email. async function loadAutomatedSender( diff --git a/supabase/migrations/20260616160000_import_register_dup_guard.sql b/supabase/migrations/20260616160000_import_register_dup_guard.sql new file mode 100644 index 0000000..76ed632 --- /dev/null +++ b/supabase/migrations/20260616160000_import_register_dup_guard.sql @@ -0,0 +1,104 @@ +-- Import-mode register double-count guard. +-- +-- Background: import-mode companies (gl_auto_post=false) get their GL from the +-- Buildium GL pull (external_source='buildium_gl'). buildium-gl-sync materializes +-- a bank-register row per GL bank line WITH journal_entry_line_id set, so those +-- are recognized as "already in GL" and never re-post. But register rows created +-- by an ad-hoc materialization (e.g. Village Woods' finishing step) were left +-- UNLINKED (journal_entry_line_id null, exclude_from_gl false). They sat harmless +-- until the "categorize register transactions from Buildium GL lines" op +-- (buildium-payee-backfill partymap) assigned them a vendor/customer/coa — which +-- gave post_transaction_gl a counter account and made it post them, DUPLICATING +-- the Buildium GL pull (A/P, A/R and bank all double-counted). +-- +-- This migration: +-- #1 Freezes any remaining unguarded register row whose exact bank movement +-- already exists in buildium_gl, across ALL import-mode companies. Rows that +-- are genuinely missing from Buildium (intentionally-posted manual items) +-- have no matching buildium_gl line and are left postable. +-- #2 Adds a backstop inside post_transaction_gl: it never posts a register row +-- whose exact bank movement (bank account + date + amount + direction) is +-- already recorded in buildium_gl — regardless of what a later categorization +-- run assigns. This enforces the rule at the GL choke point (the ad-hoc +-- apply step that writes party/coa onto the register has no committed home). +-- +-- Match key = (company, bank account_id, date, amount, direction). For a register +-- row, type 'credit' = money in = bank debit; otherwise = money out = bank credit. +-- Genuinely-missing manual items don't match (verified against Bent Oak's +-- insurance/electric/water and Bridgewater's misc-income donations), so they keep +-- posting. A coincidental same-account/date/amount/direction collision with an +-- unrelated buildium_gl movement is rare and resolves conservatively (no double +-- count); such a row can still be surfaced and posted as an explicit manual JE. + +create or replace function accounting.post_transaction_gl(_txn_id uuid) + returns void language plpgsql security definer set search_path to 'public', 'accounting' +as $function$ +declare t accounting.transactions%rowtype; _counter uuid; _je uuid; _amt numeric; +begin + select * into t from accounting.transactions where id=_txn_id; + if not found then return; end if; + perform accounting._gl_clear(t.company_id, 'acmacc_txn', t.id::text); + if coalesce(t.voided,false) then return; end if; + if t.journal_entry_line_id is not null then return; end if; -- already in GL via Buildium pull + if coalesce(t.exclude_from_gl,false) then return; end if; -- frozen legacy/duplicate register row + if t.transfer_id is not null or t.deposit_id is not null then return; end if; + if t.account_id is null then return; end if; + _amt := coalesce(t.amount,0); + if _amt = 0 then return; end if; + + -- Backstop: if buildium_gl already records this exact bank movement, this + -- register row is a duplicate of the Buildium GL pull. Never double-post it, + -- no matter what party/category a categorization run later assigns. + if exists ( + select 1 + from accounting.journal_entries je + join accounting.journal_entry_lines l on l.journal_entry_id = je.id + where je.company_id = t.company_id + and je.external_source = 'buildium_gl' + and je.date = t.date + and l.account_id = t.account_id + and ((t.type = 'credit' and l.debit = _amt) or (t.type <> 'credit' and l.credit = _amt)) + ) then + return; + end if; + + _counter := case + when t.customer_id is not null then accounting.coa_ar(t.company_id) + when t.coa_account_id is not null then t.coa_account_id + when t.vendor_id is not null then accounting.coa_ap(t.company_id) + else null end; + if _counter is null then return; end if; + + insert into accounting.journal_entries (company_id, date, description, reference, external_source, external_id) + values (t.company_id, t.date, coalesce(nullif(t.description,''), 'Bank transaction'), t.reference, 'acmacc_txn', t.id::text) + returning id into _je; + if t.type = 'credit' then + insert into accounting.journal_entry_lines (journal_entry_id, account_id, debit, credit, description) values + (_je, t.account_id, _amt, 0, t.description), (_je, _counter, 0, _amt, t.description); + else + insert into accounting.journal_entry_lines (journal_entry_id, account_id, debit, credit, description) values + (_je, _counter, _amt, 0, t.description), (_je, t.account_id, 0, _amt, t.description); + end if; +end$function$; + +-- One-time cleanup: freeze remaining unguarded buildium-overlapping register rows +-- across all import-mode companies. The UPDATE fires trg_acct_txn_gl, which clears +-- any duplicate acmacc_txn JE these rows had posted. Idempotent (already-frozen and +-- already-linked rows are skipped; intentionally-posted manual items don't match). +update accounting.transactions t +set exclude_from_gl = true +from accounting.companies c +where t.company_id = c.id + and coalesce(c.gl_auto_post, true) = false + and t.journal_entry_line_id is null + and coalesce(t.exclude_from_gl, false) = false + and t.deposit_id is null and t.transfer_id is null + and t.account_id is not null + and coalesce(t.voided, false) = false + and exists ( + select 1 from accounting.journal_entries je + join accounting.journal_entry_lines l on l.journal_entry_id = je.id + where je.company_id = t.company_id and je.external_source = 'buildium_gl' and je.date = t.date + and l.account_id = t.account_id + and ((t.type = 'credit' and l.debit = t.amount) or (t.type <> 'credit' and l.credit = t.amount)) + );