diff --git a/.gitignore b/.gitignore index 5c76a0c..85ddf4d 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,8 @@ api/dist/ site/dist/ site/.astro/ mcp/dist/ +data/hc_warmup*.csv + +# Rendered from monitoring/alertmanager.yml.template by deploy.sh (contains secrets) +monitoring/alertmanager.yml +docs/justino suit.jpg diff --git a/api/migrations/089_paper_filing_batches.sql b/api/migrations/089_paper_filing_batches.sql new file mode 100644 index 0000000..632d07d --- /dev/null +++ b/api/migrations/089_paper_filing_batches.sql @@ -0,0 +1,49 @@ +-- 089: Daily paper-filing batch tracking for the Standard (no-login) CMS filing path. +-- +-- When a provider e-signs a CMS-855 (or CMS-10114), the signed PDF is mailed to +-- the destination agency (the provider's MAC for 855s; the NPI Enumerator in +-- Fargo for NPPES updates). To save postage and handling we batch all signed, +-- not-yet-mailed filings each postal working-day morning, group them by +-- destination agency, and mail one Priority Mail envelope per agency. +-- +-- This migration records, per signed filing, which daily batch it went out in, +-- so the batch worker is idempotent (never re-mails) and we can audit/track. + +-- A paper-filing batch = one Priority Mail envelope to one destination agency +-- on one mailing day. +CREATE TABLE IF NOT EXISTS paper_filing_batches ( + id SERIAL PRIMARY KEY, + batch_date DATE NOT NULL, -- the postal working day mailed + destination_key TEXT NOT NULL, -- mac_routing key, e.g. noridian_je / npi_enumerator + destination_name TEXT NOT NULL DEFAULT '', -- human-readable MAC/agency name + destination_address TEXT NOT NULL DEFAULT '', -- full mailing address block + item_count INTEGER NOT NULL DEFAULT 0, -- number of filings enclosed + cover_sheet_key TEXT, -- MinIO key for the batch cover sheet + merged_pdf_key TEXT, -- MinIO key for the merged print job + tracking_number TEXT, -- USPS tracking, filled when mailed + status TEXT NOT NULL DEFAULT 'prepared' + CHECK (status IN ('prepared', 'mailed', 'cancelled')), + created_at TIMESTAMPTZ DEFAULT NOW(), + mailed_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_paper_batch_date ON paper_filing_batches(batch_date); +CREATE UNIQUE INDEX IF NOT EXISTS idx_paper_batch_day_dest + ON paper_filing_batches(batch_date, destination_key); + +-- Link each signed esign filing to the batch it shipped in. NULL = not yet +-- batched (the worker picks these up). Set once -> idempotent (worker skips +-- anything already assigned a batch). +ALTER TABLE esign_records + ADD COLUMN IF NOT EXISTS paper_batch_id INTEGER REFERENCES paper_filing_batches(id); + +-- The destination MAC/agency for this filing, derived from the provider's +-- practice state at sign time (snapshot so later routing-table changes don't +-- retroactively move historical filings). +ALTER TABLE esign_records + ADD COLUMN IF NOT EXISTS filing_destination_key TEXT; + +-- Index the work queue: signed, paper-path filings not yet batched. +CREATE INDEX IF NOT EXISTS idx_esign_unbatched_signed + ON esign_records(status) + WHERE status = 'signed' AND paper_batch_id IS NULL; diff --git a/api/migrations/090_esign_signature_vector.sql b/api/migrations/090_esign_signature_vector.sql new file mode 100644 index 0000000..6a04e20 --- /dev/null +++ b/api/migrations/090_esign_signature_vector.sql @@ -0,0 +1,26 @@ +-- 090: Capture the vector (stroke-path) form of a drawn signature. +-- +-- Today esign_records.signature_data holds a base64 PNG of the drawn signature, +-- which is fine as a raster copy, but a resolution-independent vector form of the +-- strokes is more faithful and reusable for downstream rendering. +-- +-- We store the captured strokes as JSON so the same signing event yields both: +-- * signature_data -- base64 PNG (raster copy, audit trail) +-- * signature_vector -- stroke paths (high-fidelity vector form) +-- +-- Format (normalized into a 0..1 box, origin top-left, matching canvas capture): +-- { +-- "v": 1, +-- "w": , "h": , +-- "strokes": [ [ {"x":0.12,"y":0.40,"t":12}, ... ], ... ] +-- } +-- x/y are fractions of the capture box (resolution-independent); t is ms since +-- stroke start (optional, for future pressure/speed modeling). + +ALTER TABLE esign_records + ADD COLUMN IF NOT EXISTS signature_vector JSONB; + +COMMENT ON COLUMN esign_records.signature_vector IS + 'Stroke-path (vector) form of a drawn signature (normalized 0..1, origin ' + 'top-left). NULL for typed signatures or signatures captured before this ' + 'column existed.'; diff --git a/api/migrations/091_admin_todos.sql b/api/migrations/091_admin_todos.sql new file mode 100644 index 0000000..d224880 --- /dev/null +++ b/api/migrations/091_admin_todos.sql @@ -0,0 +1,37 @@ +-- 091: admin_todos — operator fulfillment task queue. +-- +-- Service handlers (npi_provider, mcs150_update, state_trucking, boc3_filing, +-- hazmat_phmsa, mailbox_setup, carrier_closeout, ein_application, …) insert a +-- row here whenever a filing needs human action. This table was referenced by +-- the workers but never had a CREATE migration, so the inserts silently failed +-- in any environment without the table. This migration defines it. +-- +-- The shared helper scripts/workers/telegram_notify.create_admin_todo() and the +-- inlined INSERTs all use the same column set: +-- (title, category, priority, order_number, service_slug, description, data, status) + +CREATE TABLE IF NOT EXISTS admin_todos ( + id SERIAL PRIMARY KEY, + title TEXT NOT NULL, + category TEXT NOT NULL DEFAULT 'filing', -- filing, provisioning, review, … + priority TEXT NOT NULL DEFAULT 'normal', -- low, normal, high, urgent + order_number TEXT, -- e.g. CO-ABCD1234 (nullable for ad-hoc tasks) + service_slug TEXT, -- which service generated this task + description TEXT NOT NULL DEFAULT '', + data JSONB NOT NULL DEFAULT '{}', -- structured intake/context payload + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'in_progress', 'done', 'cancelled')), + assigned_to TEXT, -- operator email/handle (optional) + notes TEXT, -- operator working notes + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + completed_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_admin_todos_status ON admin_todos(status) WHERE status IN ('pending', 'in_progress'); +CREATE INDEX IF NOT EXISTS idx_admin_todos_order ON admin_todos(order_number); +CREATE INDEX IF NOT EXISTS idx_admin_todos_priority ON admin_todos(priority); +CREATE INDEX IF NOT EXISTS idx_admin_todos_created ON admin_todos(created_at DESC); + +COMMENT ON TABLE admin_todos IS + 'Operator fulfillment task queue; one row per filing that needs human action.'; diff --git a/api/migrations/092_esign_sign_consent.sql b/api/migrations/092_esign_sign_consent.sql new file mode 100644 index 0000000..c9a1495 --- /dev/null +++ b/api/migrations/092_esign_sign_consent.sql @@ -0,0 +1,26 @@ +-- 092: Per-document signing authorization on signature records. +-- +-- On the Standard (no-login) CMS filing path the signer gives an EXPLICIT, +-- per-document authorization to use their drawn signature to complete and submit +-- the filing on their behalf. These columns capture that authorization at +-- signing time, alongside the existing perjury attestation. They are only +-- meaningful for drawn signatures on documents that require it +-- (metadata.require_sign_consent = true); other docs leave them false/NULL. +-- +-- NB: the column names use the ink_consent* prefix for historical/migration +-- compatibility; they store the generic signing authorization described above. +-- +-- Idempotent. + +ALTER TABLE esign_records + ADD COLUMN IF NOT EXISTS ink_consent BOOLEAN DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS ink_consent_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS ink_consent_text TEXT; + +COMMENT ON COLUMN esign_records.ink_consent IS + 'TRUE when the signer expressly authorized using their drawn signature to ' + 'complete and submit this filing. Captured at signing time.'; +COMMENT ON COLUMN esign_records.ink_consent_at IS + 'When the signing authorization was given (signer-side timestamp).'; +COMMENT ON COLUMN esign_records.ink_consent_text IS + 'Verbatim authorization language the signer agreed to (for the audit trail).'; diff --git a/api/migrations/093_mcs150_awaiting_intake_status.sql b/api/migrations/093_mcs150_awaiting_intake_status.sql new file mode 100644 index 0000000..f24bc08 --- /dev/null +++ b/api/migrations/093_mcs150_awaiting_intake_status.sql @@ -0,0 +1,12 @@ +-- Add 'awaiting_intake' fulfillment status for MCS-150/DOT orders that are +-- held waiting on the customer to confirm operational details the FMCSA +-- census cannot supply (operation classification, cargo, current mileage, +-- email). The worker sets this state and emails a census-pre-filled intake +-- link; the order auto-resumes when the customer submits. +ALTER TABLE compliance_orders DROP CONSTRAINT IF EXISTS compliance_orders_fulfillment_status_check; +ALTER TABLE compliance_orders ADD CONSTRAINT compliance_orders_fulfillment_status_check + CHECK (fulfillment_status IS NULL OR fulfillment_status = ANY (ARRAY[ + 'authorization_required','authorization_signed','awaiting_customer_delegation', + 'awaiting_secure_credentials','awaiting_government_fee_approval','awaiting_insurance_filing', + 'awaiting_intake','ready_to_file','filed_waiting_state','completed' + ])); diff --git a/api/migrations/094_fmcsa_ifta_reminded.sql b/api/migrations/094_fmcsa_ifta_reminded.sql new file mode 100644 index 0000000..bcd46f4 --- /dev/null +++ b/api/migrations/094_fmcsa_ifta_reminded.sql @@ -0,0 +1,13 @@ +-- Track IFTA quarterly-return reminder touches per interstate carrier so the +-- multi-touch cadence (10/7/4 business days before deadline) never repeats a +-- touch and escalates correctly. Reset each new quarter by the IFTA builder. +-- ifta_reminded_at : timestamp of the most recent IFTA touch (any) +-- ifta_touch_no : highest touch number sent this cycle (1=10d, 2=7d, 3=4d) + +ALTER TABLE fmcsa_carriers + ADD COLUMN IF NOT EXISTS ifta_reminded_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS ifta_touch_no SMALLINT; + +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_fmcsa_carriers_ifta_reminded + ON fmcsa_carriers (ifta_touch_no) + WHERE carrier_operation = 'A'; diff --git a/api/migrations/095_fmcsa_ifta_self_filed.sql b/api/migrations/095_fmcsa_ifta_self_filed.sql new file mode 100644 index 0000000..6a533b3 --- /dev/null +++ b/api/migrations/095_fmcsa_ifta_self_filed.sql @@ -0,0 +1,12 @@ +-- "I already filed it" suppression for IFTA quarterly reminders. +-- When a carrier clicks the one-click "I already filed it" link in a reminder +-- email, we record it here: it stops further touches THIS cycle (the IFTA +-- builder excludes self-filed carriers) and gives us DIY-vs-prospect signal. +-- Reset each new quarter alongside ifta_reminded_at. + +ALTER TABLE fmcsa_carriers + ADD COLUMN IF NOT EXISTS ifta_self_filed_at TIMESTAMPTZ; + +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_fmcsa_carriers_ifta_self_filed + ON fmcsa_carriers (ifta_self_filed_at) + WHERE ifta_self_filed_at IS NULL; diff --git a/api/migrations/096_fmcsa_ucr_reminder.sql b/api/migrations/096_fmcsa_ucr_reminder.sql new file mode 100644 index 0000000..d11ee6e --- /dev/null +++ b/api/migrations/096_fmcsa_ucr_reminder.sql @@ -0,0 +1,15 @@ +-- UCR annual-renewal reminder tracking (mirrors IFTA): per-carrier touch number, +-- last-touch timestamp, and "I already did it" self-filed suppression. +-- Reset each year by build_ucr_annual_campaign.py. +-- ucr_reminded_at : timestamp of the most recent UCR touch +-- ucr_touch_no : highest touch number sent this cycle (1=30bd,2=12bd,3=4bd) +-- ucr_self_filed_at: clicked "I already registered" -> stop reminding this cycle + +ALTER TABLE fmcsa_carriers + ADD COLUMN IF NOT EXISTS ucr_reminded_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS ucr_touch_no SMALLINT, + ADD COLUMN IF NOT EXISTS ucr_self_filed_at TIMESTAMPTZ; + +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_fmcsa_carriers_ucr_touch + ON fmcsa_carriers (ucr_touch_no) + WHERE carrier_operation = 'A'; diff --git a/api/migrations/097_fmcsa_mx_provider.sql b/api/migrations/097_fmcsa_mx_provider.sql new file mode 100644 index 0000000..be72678 --- /dev/null +++ b/api/migrations/097_fmcsa_mx_provider.sql @@ -0,0 +1,13 @@ +-- Per-MX-operator throttling for the trucking/main warmup pool. +-- Sender reputation is tracked by the RECEIVING mail operator (Microsoft 365, +-- Google Workspace, Proofpoint, ...), not by recipient domain. The Jun 13-14 +-- Gmail + Outlook block storm came from hammering Google/MS-Workspace-hosted +-- business domains. mx_provider lets the builder exclude those during warmup and +-- cap volume per operator (mirrors the HC pool). Populated by mx_tag_carriers.py. + +ALTER TABLE fmcsa_carriers + ADD COLUMN IF NOT EXISTS mx_provider TEXT; + +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_fmcsa_carriers_mx_provider + ON fmcsa_carriers (mx_provider) + WHERE mx_provider IS NOT NULL; diff --git a/api/migrations/098_audit_log_compliance_type.sql b/api/migrations/098_audit_log_compliance_type.sql new file mode 100644 index 0000000..a4725ce --- /dev/null +++ b/api/migrations/098_audit_log_compliance_type.sql @@ -0,0 +1,17 @@ +-- 098: Allow compliance order types in order_audit_log. +-- +-- order_audit_log was created (migration 004) for formation orders only, with a +-- CHECK constraint limiting order_type to ('formation','service','quote'). The +-- admin compliance-orders dashboard writes audit entries for compliance orders +-- (approve-for-submission, intake re-arm), which were rejected by that check and +-- surfaced as "Approve failed" 500s. Add the compliance order types. +-- +-- Idempotent: drops and recreates the constraint with the expanded value set. + +ALTER TABLE order_audit_log DROP CONSTRAINT IF EXISTS order_audit_log_order_type_check; + +ALTER TABLE order_audit_log + ADD CONSTRAINT order_audit_log_order_type_check + CHECK (order_type IN ( + 'formation', 'service', 'quote', 'compliance', 'compliance_batch' + )); diff --git a/api/migrations/099_gov_fee_child_orders.sql b/api/migrations/099_gov_fee_child_orders.sql new file mode 100644 index 0000000..349ed43 --- /dev/null +++ b/api/migrations/099_gov_fee_child_orders.sql @@ -0,0 +1,20 @@ +-- 099: Government-fee child orders for at-cost compliance services. +-- +-- At-cost services (IRP, IFTA, intrastate authority, etc.) collect only our +-- SERVICE fee at checkout; the actual government/state fee is variable and +-- "billed at cost" afterward. To collect it we create a CHILD compliance_orders +-- row (service_fee_cents = 0, gov_fee_cents = the quoted state fee) that flows +-- through the EXISTING checkout/payment-picker/webhook machinery unchanged, and +-- email the customer a payment link with every payment method + correct +-- surcharges. parent_order_number links that child back to the original order so +-- the worker can resume filing once the fee is paid. +-- +-- Idempotent. + +ALTER TABLE compliance_orders + ADD COLUMN IF NOT EXISTS parent_order_number text; + +-- Look up a parent's gov-fee children quickly (and vice-versa). +CREATE INDEX IF NOT EXISTS idx_compliance_orders_parent + ON compliance_orders (parent_order_number) + WHERE parent_order_number IS NOT NULL; diff --git a/api/migrations/100_recurring_subscriptions.sql b/api/migrations/100_recurring_subscriptions.sql new file mode 100644 index 0000000..6834ff9 --- /dev/null +++ b/api/migrations/100_recurring_subscriptions.sql @@ -0,0 +1,25 @@ +-- 100: Recurring (Stripe Subscription) support for compliance services. +-- +-- The OIG/SAM exclusion screening is sold as a $79/month Stripe Subscription +-- (catalog billing_interval="month"). We track the Stripe Subscription id so the +-- invoice.paid renewal webhook can map a monthly charge back to the order and +-- re-run that cycle's screening. +-- +-- The Provider Compliance Bundle ($899/yr) includes only the FIRST OIG/SAM +-- screening; bundle buyers are converted to the $79/mo monitoring subscription +-- after that first cycle via an automated upsell email. bundle_upsell_sent_at +-- dedupes that send. +-- +-- Idempotent. (Mirrors ensureColumns() in api/src/routes/checkout.ts so a fresh +-- deploy and this migration converge on the same schema.) + +ALTER TABLE compliance_orders + ADD COLUMN IF NOT EXISTS stripe_subscription_id text; + +ALTER TABLE compliance_orders + ADD COLUMN IF NOT EXISTS bundle_upsell_sent_at timestamptz; + +-- Map a renewal invoice's subscription back to its order quickly. +CREATE INDEX IF NOT EXISTS idx_compliance_orders_subscription + ON compliance_orders (stripe_subscription_id) + WHERE stripe_subscription_id IS NOT NULL; diff --git a/api/migrations/101_mail_reputation_daily.sql b/api/migrations/101_mail_reputation_daily.sql new file mode 100644 index 0000000..e887141 --- /dev/null +++ b/api/migrations/101_mail_reputation_daily.sql @@ -0,0 +1,39 @@ +-- Daily mail-reputation snapshots parsed from the postfix logs. +-- +-- WHY: Sender reputation lives at the RECEIVING operator (Microsoft 365, Google, +-- Yahoo, ...), not at the recipient domain. The provider portals (SNDS, Postmaster, +-- Yahoo CFL) show this but each needs a login and lags 24-48h. Our own postfix logs +-- already contain the ground truth in real time: every send to a Microsoft tenant +-- returns 250 (accepted) / 451 4.7.500 (throttled, "server busy") / 5xx (rejected), +-- and the reject text tells us WHY (reputation 5.7.1, recipient unknown 5.1.1, etc.). +-- A 2026-06-19 audit found 80% of Microsoft sends were getting 451 4.7.500 throttles +-- on the warming IPs -- exactly the signal we need to watch ease as reputation builds. +-- +-- This table stores ONE row per (log_date, sending_ip, receiver_operator, outcome +-- class) so we can chart accepted% / deferred% / reject-reason mix per operator over +-- time without re-parsing logs (which rotate fast). Populated by +-- scripts/mail_reputation_monitor.py (idempotent upsert per day). + +CREATE TABLE IF NOT EXISTS mail_reputation_daily ( + id BIGSERIAL PRIMARY KEY, + log_date DATE NOT NULL, -- the calendar day of the log lines (server TZ) + sending_ip TEXT NOT NULL, -- our egress IP (207.174.124.94 / .107 / ...) + receiver TEXT NOT NULL, -- normalized receiving operator: microsoft|google|yahoo|proofpoint|other + outcome TEXT NOT NULL, -- accepted|throttled|reject_reputation|reject_recipient|reject_content|reject_other|deferred_other + reason_code TEXT, -- representative enhanced status / code (e.g. 4.7.500, 5.7.1, 5.1.1) + msg_count INTEGER NOT NULL DEFAULT 0, + sample_text TEXT, -- one representative raw response, for debugging + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (log_date, sending_ip, receiver, outcome, reason_code) +); + +CREATE INDEX IF NOT EXISTS idx_mail_rep_daily_date ON mail_reputation_daily (log_date); +CREATE INDEX IF NOT EXISTS idx_mail_rep_daily_receiver ON mail_reputation_daily (receiver, log_date); + +COMMENT ON TABLE mail_reputation_daily IS + 'Daily per-IP per-receiving-operator mail delivery outcome counts parsed from postfix logs (SNDS-equivalent reputation trend, no provider login needed). Populated by scripts/mail_reputation_monitor.py.'; +COMMENT ON COLUMN mail_reputation_daily.receiver IS + 'Normalized receiving operator: microsoft|google|yahoo|proofpoint|mimecast|other'; +COMMENT ON COLUMN mail_reputation_daily.outcome IS + 'accepted|throttled|reject_reputation|reject_recipient|reject_content|reject_other|deferred_other'; diff --git a/api/migrations/102_dmarc_aggregate.sql b/api/migrations/102_dmarc_aggregate.sql new file mode 100644 index 0000000..d3f3054 --- /dev/null +++ b/api/migrations/102_dmarc_aggregate.sql @@ -0,0 +1,66 @@ +-- DMARC aggregate (rua) report ingestion. +-- +-- WHY: DMARC aggregate reports (RFC 7489) are the authoritative, cross-operator +-- view of who is sending mail AS us (header_from = performancewest.net / its +-- subdomains) and whether that mail passes SPF + DKIM alignment. Every major +-- receiver (Google, Yahoo, Comcast, Cox, Bell, Mimecast, Cisco ESA, GMX, mail.com, +-- ...) emails one zipped/gzipped XML per day to rua=mailto:dmarc@performancewest.net. +-- Reading them by hand is hopeless (dozens/day). This turns them into queryable +-- per-source-IP / per-domain SPF+DKIM+DMARC pass-fail trends so we can SEE: +-- * our own senders (.94 bulk / .107 hcout / .71 transactional / .15 relay) all +-- passing alignment (DKIM d=send. selector send, d=root selector mail) -- the +-- deliverability fixes this session were exactly about this; and +-- * any UNKNOWN IP sending as us that fails -- i.e. spoofing or a forgotten relay, +-- which is reputation poison under p=reject. +-- +-- Populated by scripts/dmarc_report_parser.py (IMAP fetch dmarc@ -> unzip -> parse +-- XML -> upsert). Idempotent: each report is keyed by (org_name, report_id) and +-- re-parsing the same report is a no-op (ON CONFLICT DO NOTHING). + +-- One row per aggregate report (the + ). +CREATE TABLE IF NOT EXISTS dmarc_report ( + id BIGSERIAL PRIMARY KEY, + org_name TEXT NOT NULL, -- reporting operator (google.com, yahoo.com, ...) + org_email TEXT, -- contact email from the report + report_id TEXT NOT NULL, -- operator's unique report id + date_begin TIMESTAMPTZ, -- report window start (from epoch) + date_end TIMESTAMPTZ, -- report window end + policy_domain TEXT, -- + policy_p TEXT, -- published policy: none|quarantine|reject + policy_sp TEXT, -- subdomain policy + policy_adkim TEXT, -- DKIM alignment mode r|s + policy_aspf TEXT, -- SPF alignment mode r|s + policy_pct INTEGER, -- % of mail policy applies to + received_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (org_name, report_id) +); + +-- One row per inside a report (a distinct source_ip + auth result combo). +CREATE TABLE IF NOT EXISTS dmarc_record ( + id BIGSERIAL PRIMARY KEY, + report_id BIGINT NOT NULL REFERENCES dmarc_report(id) ON DELETE CASCADE, + source_ip TEXT NOT NULL, -- the IP that sent the mail + msg_count INTEGER NOT NULL DEFAULT 0, -- messages from this IP in the window + disposition TEXT, -- DMARC disposition applied: none|quarantine|reject + dkim_aligned TEXT, -- policy_evaluated/dkim: pass|fail + spf_aligned TEXT, -- policy_evaluated/spf: pass|fail + dmarc_pass BOOLEAN, -- derived: dkim_aligned=pass OR spf_aligned=pass + header_from TEXT, -- identifiers/header_from + envelope_from TEXT, -- identifiers/envelope_from + dkim_domain TEXT, -- auth_results/dkim/domain + dkim_selector TEXT, -- auth_results/dkim/selector + dkim_result TEXT, -- auth_results/dkim/result (raw) + spf_domain TEXT, -- auth_results/spf/domain + spf_result TEXT -- auth_results/spf/result (raw) +); + +CREATE INDEX IF NOT EXISTS idx_dmarc_report_window ON dmarc_report (date_begin); +CREATE INDEX IF NOT EXISTS idx_dmarc_report_org ON dmarc_report (org_name); +CREATE INDEX IF NOT EXISTS idx_dmarc_record_report ON dmarc_record (report_id); +CREATE INDEX IF NOT EXISTS idx_dmarc_record_ip ON dmarc_record (source_ip); +CREATE INDEX IF NOT EXISTS idx_dmarc_record_fail ON dmarc_record (dmarc_pass) WHERE dmarc_pass = false; + +COMMENT ON TABLE dmarc_report IS + 'DMARC aggregate (rua) report headers. One row per operator report, keyed (org_name, report_id). Populated by scripts/dmarc_report_parser.py.'; +COMMENT ON TABLE dmarc_record IS + 'Per-source-IP rows inside each DMARC aggregate report: SPF/DKIM alignment + raw auth results. dmarc_pass = dkim_aligned=pass OR spf_aligned=pass.'; diff --git a/api/src/config.ts b/api/src/config.ts index 7bf144b..985f813 100644 --- a/api/src/config.ts +++ b/api/src/config.ts @@ -99,7 +99,7 @@ function loadConfig(): Config { webhookSecret: optional("STRIPE_WEBHOOK_SECRET", ""), }, smtp: { - host: optional("SMTP_HOST", "mail.smtp2go.com"), + host: optional("SMTP_HOST", "co.carrierone.com"), port: parseInt(optional("SMTP_PORT", "587"), 10), user: optional("SMTP_USER", ""), pass: optional("SMTP_PASS", ""), diff --git a/api/src/email.ts b/api/src/email.ts index 23625f6..59f83bc 100644 --- a/api/src/email.ts +++ b/api/src/email.ts @@ -6,7 +6,7 @@ * - Portal access link emails (JWT-signed links) * * All transactional emails go through Carbonio: co.carrierone.com:587 - * (SMTP2GO is used only by Listmonk for mass-mail campaigns.) + * (Listmonk mass-mail relays through the local Postfix MTA, not this path.) * Env vars: SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_FROM */ @@ -34,16 +34,47 @@ function getTransporter(): nodemailer.Transporter { return _transporter; } +// ─── HTML → plaintext (dependency-free) ───────────────────────────────────── +// A multipart/alternative (which nodemailer builds when both html+text are +// given) with ONLY an HTML part is malformed, and HTML-only mail is a spam +// signal. When a caller doesn't supply `text`, derive a readable plaintext +// fallback from the HTML so every message ships a proper text/plain part. +export function htmlToText(html: string): string { + if (!html) return ""; + let s = html; + // Drop non-content blocks entirely. + s = s.replace(/<(script|style|head)[\s\S]*?<\/\1>/gi, ""); + // text -> text (url) + s = s.replace(/]*\bhref\s*=\s*["']?([^"'>\s]+)["']?[^>]*>([\s\S]*?)<\/a>/gi, + (_m, url, txt) => { + const t = txt.replace(/<[^>]+>/g, "").trim(); + return t && !url.startsWith("mailto:") && t !== url ? `${t} (${url})` : (t || url); + }); + // List items -> "- item"; block/line breaks -> newlines. + s = s.replace(/]*>/gi, "\n- "); + s = s.replace(/<\/(p|div|tr|h[1-6]|li|ul|ol|table)>/gi, "\n"); + s = s.replace(//gi, "\n"); + // Strip remaining tags, unescape common entities, collapse whitespace. + s = s.replace(/<[^>]+>/g, ""); + s = s.replace(/ /gi, " ").replace(/&/gi, "&").replace(/</gi, "<") + .replace(/>/gi, ">").replace(/"/gi, '"').replace(/'/gi, "'") + .replace(/→/gi, "->").replace(/·/gi, "-").replace(/§/gi, "Section"); + s = s.replace(/[ \t]+/g, " ").replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n"); + s = s.split("\n").map((line) => line.trim()).join("\n"); + return s.trim(); +} + // ─── Generic send ───────────────────────────────────────────────────────────── -export async function sendEmail(opts: { to: string; subject: string; html: string; text?: string }): Promise { +export async function sendEmail(opts: { to: string; subject: string; html: string; text?: string; cc?: string }): Promise { const t = getTransporter(); await t.sendMail({ from: SMTP_FROM, to: opts.to, + ...(opts.cc ? { cc: opts.cc } : {}), subject: opts.subject, html: opts.html, - text: opts.text || "", + text: opts.text || htmlToText(opts.html), }); } @@ -78,6 +109,20 @@ function htmlEmail(title: string, body: string): string { ${body} + + + + + +
+

Problem with your order? We're here to help.

+

Questions, a change, or something not right? Reach our team and we'll make it right, fast.

+ Get help with your order → +

Or email info@performancewest.net · call 1-888-411-0383

+
+ + diff --git a/api/src/erpnext-client.ts b/api/src/erpnext-client.ts index 608da0e..2e1305c 100644 --- a/api/src/erpnext-client.ts +++ b/api/src/erpnext-client.ts @@ -1011,6 +1011,48 @@ export async function setWebsiteUserPassword( }); } +/** + * Verify a customer's password against ERPNext — the single source of truth for + * customer credentials. The API portal does NOT keep its own password hash; it + * delegates the check here and (on success) mints its own session cookie. + * + * Uses Frappe's form login endpoint (`/api/method/login`, usr/pwd). That + * endpoint resolves the site by the Host header (NOT the X-Frappe-Site-Name + * token header), so we must send Host explicitly or Frappe 404s with + * " does not exist". A 200 means the password is correct; 401 means it + * is not. Network/other errors throw so the caller can fail closed. + * + * NB: this is a plain credential check — we discard any session cookie ERPNext + * returns; the API issues its own `pw_customer` cookie. + */ +export async function verifyWebsiteUserPassword( + email: string, + password: string, +): Promise { + const res = await fetch(`${ERPNEXT_URL}/api/method/login`, { + method: "POST", + headers: { + "Content-Type": "application/json", + // Frappe resolves the site for this endpoint from Host, not the token + // site header. Send both so it works regardless of routing. + Host: ERPNEXT_SITE_NAME, + "X-Frappe-Site-Name": ERPNEXT_SITE_NAME, + }, + body: JSON.stringify({ usr: email, pwd: password }), + }); + + if (res.status === 200) return true; + if (res.status === 401) return false; + // 4xx/5xx other than auth failure (e.g. user disabled, site error) — surface + // as an error so the route returns a 500 rather than a misleading "bad + // password". Read the body for diagnostics. + const body = await res.text().catch(() => ""); + throw new ERPNextError(res.status, { + message: `ERPNext login check failed (status ${res.status})`, + exception: body.slice(0, 500), + } as FrappeErrorResponse); +} + /** * Link a Frappe User to a Customer record (portal_user_name field). * This is required for the ERPNext portal to show the correct customer's data. diff --git a/api/src/index.ts b/api/src/index.ts index a1739fb..846a7ca 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -14,6 +14,7 @@ import ticketsRouter from "./routes/tickets.js"; import quotesRouter from "./routes/quotes.js"; import formationsRouter from "./routes/formations.js"; import discountsRouter from "./routes/discounts.js"; +import iftaRouter from "./routes/ifta.js"; import adminRouter from "./routes/admin.js"; import webhooksRouter from "./routes/webhooks.js"; import identityRouter from "./routes/identity.js"; @@ -92,6 +93,7 @@ app.use(ticketsRouter); app.use(quotesRouter); app.use(formationsRouter); app.use(discountsRouter); +app.use(iftaRouter); app.use(adminRouter); app.use(webhooksRouter); app.use(refundsRouter); diff --git a/api/src/middleware/admin-auth.ts b/api/src/middleware/admin-auth.ts index c14fe22..643e97c 100644 --- a/api/src/middleware/admin-auth.ts +++ b/api/src/middleware/admin-auth.ts @@ -39,3 +39,26 @@ export function requireAdmin(req: Request, res: Response, next: NextFunction): v res.status(401).json({ error: "Invalid or expired token." }); } } + +/** + * Verify admin JWT from EITHER the Authorization header OR a `?token=` query + * param. Needed for endpoints opened directly by the browser (e.g. a PDF in a + * new tab / + + + + + +
+
+
+
+
+
+ +
+
+
+
+ + +
+
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
Print Article
+
+
+
+
+
+
+
SHARE
+
+
+
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+
+
+
+
+
+
+ + +
+
+
+
+
+
+

The CommLaw Group recently found itself embroiled in an unfortunate public controversy involving statements published and circulated in Prescott-Martini’s weekly Martini Brief. Because the matter touched on CommLaw’s reputation, advisory practices, commitment to responsible innovation, and position on AI-assisted compliance tools, we believe it is important to set the record straight.

+

The controversy arose from commentary concerning The CommLaw Group, Performance West, AI-assisted compliance outreach, and Robocall Mitigation Database compliance practices. That commentary included or relied on allegations that The CommLaw Group had scraped the FCC’s Robocall Mitigation Database, pulled contact email addresses from that database, and mass-emailed the entire RMD list.

+

Those allegations were false.

+

To be clear: The CommLaw Group did not scrape the FCC’s Robocall Mitigation Database. We did not harvest contact information from the RMD. And we did not mass-email the RMD list.

+

The advisory at issue was sent to The CommLaw Group’s own long-standing Client Advisory distribution list — the same audience receiving this message today.

+

After CommLaw objected, Prescott-Martini published a retraction and correction acknowledging that it had not independently verified the disputed allegations and did not possess facts establishing that they were true. Prescott-Martini also withdrew and corrected any contrary statement or implication, as well as any suggestion that CommLaw is a “legacy” or technologically backward law firm opposed to artificial intelligence, automation, analytics, or responsible compliance technology.

+

Prescott-Martini’s retraction and The CommLaw Group’s full response can be reviewed here: Retraction and CommLaw Group Response.

+

We are not issuing this advisory to prolong a dispute. To the contrary, our purpose is to correct the record and draw attention to the broader issue that gave rise to the controversy in the first place: the growing role of AI, automation, and mass outreach in telecom compliance.

+

The CommLaw Group supports responsible AI adoption. We use technology. We believe AI-assisted tools, automation, analytics, and better compliance workflows will play an important role in the future of professional services. Properly used, these tools can help identify issues, organize facts, flag inconsistencies, reduce costs, and make compliance support more accessible.

+

But automation is not a substitute for factual accuracy, operational validation, legal judgment, or professional accountability.

+

That distinction matters. Telecom compliance filings, certifications, Robocall Mitigation Database submissions, mitigation plans, know-your-customer procedures, call-authentication obligations, numbering practices, and related regulatory representations can carry meaningful legal and enforcement consequences. Providers should be cautious when relying on automated outreach, AI-generated recommendations, or compliance tools that may not account for the provider’s actual operations, legal obligations, or risk profile.

+

The right question is not whether AI should be used in compliance. It should. The better question is where AI fits, who reviews the output, what assumptions are being made, and who is accountable when a recommendation affects a company’s legal or regulatory exposure.

+

That is why CommLaw’s position has always been grounded in responsible innovation. We do not believe every compliance task requires a lawyer. We do not believe every project should be handled at traditional law-firm rates. We do believe clients are best served when the right professionals are assigned to the right projects at the right prices.

+

Sometimes, the right professional is a software tool. Sometimes, it is an experienced compliance professional. Sometimes, it is a consultant who understands operational filings and regulatory processes. Sometimes, it is an attorney. Often, it is a coordinated combination of technology, compliance experience, operational review, and legal judgment.

+

As AI-assisted compliance offerings continue to develop, providers should ask practical questions before relying on automated recommendations:

+

Who reviewed the results? What data was used? What assumptions were made? Was the company’s actual operational reality considered? Is the recommendation legal advice, compliance consulting, software output, or something else? Who is accountable if the recommendation is wrong? What risk remains with the provider?

+

Those are not anti-technology questions. They are prudent business questions.

+

The future of telecom compliance will almost certainly include AI, automation, analytics, and more scalable service models. We welcome that future. We are helping build that future. But it must be a future grounded in facts, law, operational reality, transparency, and accountability.

+

That is the record we wanted to set straight.

+

On behalf of The CommLaw Group, PLLC,

+

JSM Signature
+Jonathan S. Marashlian

+

Managing Partner

+
+
+
+
+ +
+
+
+ + + +
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/billing.md b/docs/billing.md index 14e53ef..55cb2d9 100644 --- a/docs/billing.md +++ b/docs/billing.md @@ -1,6 +1,20 @@ # Billing & Payments Architecture -**Last updated:** 2026-04-05 +**Last updated:** 2026-06-18 + +> ⚠️ **Reality check (2026-06):** Large parts of this doc describe a *planned* +> "ERPNext owns all billing via Adyen" architecture that is **NOT live**. What is +> actually wired today: +> - **Live payment rail = Stripe Checkout** (card + ACH), plus **PayPal** (direct +> Orders v2) and **crypto** (SHKeeper) — all in `api/src/routes/checkout.ts`. +> **Klarna** runs via Stripe. +> - **Adyen is NOT integrated** (account approval never completed). The +> `Adyen-*` gateway names below are aspirational labels, not active gateways. +> - **Recurring billing = Stripe Subscriptions** (see "Stripe-native +> Subscriptions"), the only recurring billing actually shipping, used by +> `oig-sam-screening` ($79/mo). ERPNext `createSubscription()` is unused. +> ERPNext is still the system of record for invoices/accounting, but it is **not** +> the payment gateway. Treat Adyen/ERPNext-gateway sections as future plan. ## Principle: ERPNext Owns All Billing @@ -138,14 +152,111 @@ Sales Invoice: ``` ### Recurring Services (Subscriptions) -ERPNext Subscription DocType handles: + +> **Status:** the only recurring billing actually wired today is **Stripe-native +> Subscriptions** (see next section), used by `oig-sam-screening` ($79/mo). The +> ERPNext-Subscription / Adyen model below is **planned, not yet live** — Adyen +> is not integrated and ERPNext `createSubscription()` is currently unused. The +> services listed here are aspirational pricing, not active subscriptions. + +ERPNext Subscription DocType is intended to handle (NOT YET LIVE): - Registered Agent: $99/year per state (Wyoming: $49/year) - Annual Report Filing: $99/year per state - Canada CRTC Annual Maintenance: $349/year - US Formation Maintenance Bundle: $179/year (annual report + RA renewal) - CA Formation Maintenance Bundle: $179/year (annual return + AMB/RA renewal) -Subscriptions auto-generate invoices. Payment collected via Adyen (saved payment method) or manual payment link. +When built, subscriptions would auto-generate invoices (payment via a saved +payment method or manual payment link). + +### Stripe-native Subscriptions (healthcare monitoring) + +Some compliance services are sold as **Stripe Subscriptions** (the billing engine +is Stripe, not ERPNext). A service opts in via the catalog +(`api/src/service-catalog.ts`): + +```ts +"oig-sam-screening": { + name: "OIG/SAM Exclusion Screening (Monthly Monitoring)", + price_cents: 7900, // $79/month + billing_interval: "month", // -> checkout builds mode:"subscription" + allowed_methods: ["card", "ach"], // recurring needs off-session-capable rails + ... +} +``` + +Flow: +1. `checkout.ts` sees `billing_interval` -> creates a `mode:"subscription"` + Checkout Session with recurring `price_data`. The gateway surcharge is + **absorbed** (a subscription can't carry a one-time surcharge line) so the + customer is billed a clean `$79/month`. +2. `allowed_methods` filters the picker in `PaymentStep.astro` (PayPal/Klarna/ + crypto are one-time only and disappear) and is **re-validated server-side** + in `checkout.ts` (`METHOD_NOT_ALLOWED`). +3. `webhooks.ts` handles the subscription lifecycle: + - `checkout.session.completed` (mode=subscription) -> records + `compliance_orders.stripe_subscription_id`, then first fulfillment. + - `invoice.paid` with `billing_reason=subscription_cycle` -> re-dispatches the + service handler (`recurring_cycle:true`) to re-run the screening + deliver a + fresh dated certificate. (The first invoice is skipped here — already handled + by `checkout.session.completed`.) + - `invoice.payment_failed` -> admin alert + first-failure customer nudge. + - `customer.subscription.deleted` -> order marked `cancelled`, fulfillment stops. + +**Stripe webhook events (ENABLED on the live prod endpoint** `we_1THBjyB46qMvF2jnYyN8IfkK` += `https://api.performancewest.net/api/v1/webhooks/stripe`**):** +The 6 currently enabled events are `checkout.session.completed`, +`payment_intent.succeeded`, `payment_intent.payment_failed`, plus the three +subscription-lifecycle events added 2026-06-18: +- `invoice.paid` +- `invoice.payment_failed` +- `customer.subscription.deleted` + +Without these three the monthly cycles would charge but never fulfill/alert. +(Note: the code also *handles* `charge.dispute.created` and `balance.available` +but those are NOT yet enabled on the endpoint — enable them if/when needed.) + +To add events without clobbering existing ones, read `enabled_events`, union, +and PUT the union back via `POST /v1/webhook_endpoints/{id}` with repeated +`enabled_events[]=` params (re-running is idempotent). + +> **API-version caveat (important):** this endpoint has **no pinned +> `api_version`**, so it follows the Stripe *account default* (currently +> `2024-12-18.acacia`), NOT the `2026-03-25.dahlia` the SDK is pinned to. In +> acacia the subscription link is the **top-level `invoice.subscription`**; in +> dahlia it moved to `invoice.parent.subscription_details.subscription`. +> `invoiceSubscriptionId()` in `webhooks.ts` reads **both** shapes so renewals +> map back to the order regardless. If you ever pin the endpoint to dahlia, the +> handler still works; do NOT remove the legacy fallback while the endpoint is +> unpinned. + +The `provider-compliance-bundle` ($899/yr) includes **only the first** OIG/SAM +screening; customers are converted to the $79/mo monitoring subscription after +that first cycle (the standalone $948/yr of monitoring is no longer given away +inside the bundle). + +**Validation status (2026-06-18):** +- ✅ *Checkout half — proven against LIVE Stripe.* A dry-run created a real + `mode:subscription` Checkout Session with the exact production params + (recurring `price_data`, `unit_amount=7900`, `recurring.interval=month`, + `card`+`us_bank_account`, metadata) — Stripe returned `amount_total=7900`, + `type=recurring`, then the session was immediately **expired** (creating a + session never charges anyone; only a completed hosted page does). Net effect + on prod: zero. +- ✅ *Webhook subscription-id extraction* — `invoiceSubscriptionId()` unit tests + (31 in `api/tests/recurring-subscription.test.ts`) cover acacia (top-level) + AND dahlia (nested) invoice shapes, renewal-cycle gating, surcharge + suppression, recurring line-item build. +- ✅ *Worker renewal fulfillment* — `scripts/workers/services/test_npi_recurring.py` + (13 assertions) runs the real handler and asserts the `[Monthly cycle]` / + re-screen behaviour; passes locally + in the deployed workers container. +- ⏳ *Full end-to-end with a Stripe test clock* — NOT yet run. Requires + `STRIPE_TEST_SECRET_KEY` / `STRIPE_TEST_WEBHOOK_SECRET` in the server `.env` + (currently unset; prod is `NODE_ENV=production`). Once those exist: place a + test-card subscription, advance a billing cycle via a test clock, and confirm + the live `invoice.paid` (subscription_cycle) re-dispatches the screening + worker and a fresh certificate is issued. This is the last gap before a real + recurring charge should be marketed. ### Formation Maintenance Bundles diff --git a/docs/campaign-deliverability-plan.md b/docs/campaign-deliverability-plan.md new file mode 100644 index 0000000..1a788cb --- /dev/null +++ b/docs/campaign-deliverability-plan.md @@ -0,0 +1,120 @@ +# Campaign Deliverability — Diagnosis & List-Verification Plan + +_Created 2026-06-17 after trucking conversions went to zero._ + +## TL;DR + +Trucking conversions stopped on **June 9** not because campaigns stopped sending +(they send ~2,400/day with ~1,800 opens/3 days) but because a **filter bug was +blasting ~438k dead `mx_unreachable` domains**, producing a **~47% hard-bounce +rate (~1,100/day)** that blocklisted **half the 120k subscriber base** and +torched sender reputation, so real prospects never saw the offer. + +- **Fixed** (`build_trucking_campaigns.py`): send filter now keys only off + `email_verify_result` (never the broken `email_verified` boolean), and defaults + to **recovery mode = `smtp_valid` only** until reputation recovers. Set + `CAMPAIGN_INCLUDE_CATCH_ALL=1` to re-add catch-all domains afterward. +- **Healthcare is fine** — separate instance (`listmonk-hc` / DB `listmonk_hc`), + cleaned list (`clean_hc_warmup_list.py` already drops `mx_unreachable`), bounce + rate ~2-3%. No change needed; it proves the fix is correct. + +## Why the SMTP-probe verification under-counts deliverable addresses + +`email_verifier.py` does syntax → MX → SMTP `RCPT TO`. Results: + +| result | count | sendable? | why | +|---|---|---|---| +| `catch_all_domain` | 1,082,817 | risky | domain accepts ALL rcpts at SMTP time, then may bounce later | +| `mx_unreachable` | 438,163 | **NO** | MX exists but never answered the probe — **hard-bounces on real send** | +| `smtp_valid` | 11,774 | **YES** | an MX explicitly accepted this exact mailbox | +| `no_mx_records` / `invalid_syntax` / `smtp_rejected_550` | ~46k | no | dead | + +The probe can only *confirm* a mailbox on non-catch-all domains that answer the +RCPT handshake — which is a small slice. Only **~3,042 `smtp_valid` are still +unsent**, so recovery mode will exhaust the clean pool in ~1 day. **We need a way +to grow the verified-deliverable list without burning PW's reputation.** + +## The real fix: burner-domain bounce verification + +SMTP-probe verification is unreliable (catch-alls mask validity; many MTAs refuse +probes but accept real mail). The only ground truth is **actually send a message +and see if it bounces.** But doing that from PW's domain is what got us here. So: + +### Design + +1. **Dedicated throwaway verification domain** (NOT performancewest.net and NOT + carrierone.com — both are reputation assets we must protect). Register a cheap + neutral `.com` via Porkbun (we already have the Porkbun integration). Give it + its own SPF/DKIM/DMARC and a dedicated sending IP/identity (separate postfix + instance or a transactional provider sub-account that isolates reputation). + +2. **Send a low-key, CAN-SPAM-compliant, non-commercial verification email** to + the unverified pool (e.g. a plain "is this the right contact for ?" or a + bland newsletter-style note with a working unsubscribe). It must be a real, + legitimate message — never deceptive — but its ONLY purpose is to elicit a + delivered-vs-bounced signal. Throttled and warmed like any send. + +3. **Catch bounces from that domain's own MTA log** (reuse `bounce-watcher.sh`'s + `status=bounced` tail pattern) and **write the result back to + `fmcsa_carriers.email_verify_result`**: + - delivered (no bounce within N hours) → upgrade to a new `send_confirmed` + result that the PW campaign filter treats as sendable. + - hard-bounced → mark `hard_bounced`, permanently excluded from PW sends. + +4. **PW campaigns then send only to `smtp_valid` + `send_confirmed`** — addresses + proven deliverable by a real send — keeping PW's bounce rate near zero. + +### Why a separate domain/IP + +Reputation is per sending-domain + per-IP. If the burner domain gets blocklisted +from the inevitable bounces during scrubbing, **PW and carrierone are untouched.** +The burner is disposable: if it burns, rotate to a new one. PW only ever sends to +the cleaned output. + +### Compliance guardrails (must-haves) + +- Real **CAN-SPAM** compliance: truthful from/subject, physical address, working + one-click unsubscribe, honor opt-outs immediately (sync opt-outs back to PW's + suppression list too). +- **Not deceptive**: the email is a genuine message (these are public FMCSA + business contacts for B2B outreach), not a fake/pretext. The bounce signal is a + byproduct, not a trick. +- Suppress anyone who ever bounced or opted out from ALL future sends (burner and + PW). + +## Status / next steps + +- [x] Fix the PW trucking send filter (drop `mx_unreachable`; recovery mode). +- [x] Confirm healthcare unaffected. +- [x] Add `send_confirmed` / `hard_bounced` result handling to the campaign + filter + a writeback path from bounce processing (`burner_list_verify.py`). +- [x] **Catch-all auto-rollout instead of the burner domain (2026-06-18).** After + the DKIM signing fix landed, a root-cause classification of the 75k + pre-fix bounces showed the damage was ~55% reputation/auth (which DKIM + fixes) and only ~29% genuinely-dead mailboxes. The catch-all pool accepts + at RCPT time by definition, so it does not user-unknown bounce at send + time -- it is far safer to bleed directly in warmed batches than to stand + up + warm a whole separate burner domain/IP/SPF/DKIM identity. So the + catch-all pool is now gated by an **automatic in-house rollout** in + `build_trucking_campaigns.py` (`catch_all_enabled()`): + - enables only when `warmup_day() >= CAMPAIGN_CATCH_ALL_MIN_DAY` (21) + AND the **recent** (2-day) live campaign bounce rate is below + `CAMPAIGN_CATCH_ALL_MAX_BOUNCE_PCT` (8%) on a trustworthy sample + (>= 300 sent); + - **auto-reverts** to the clean `smtp_valid`/`send_confirmed` pool on the + next run if bounces spike back above the ceiling; + - a deliberately SHORT window so a past disaster (the Jun-16 ~45% 7-day + rate) cannot block the rollout forever, and a fresh spike trips it fast; + - `CAMPAIGN_INCLUDE_CATCH_ALL=1/0` still hard-overrides the auto decision. + Applied uniformly to trucking + IFTA + UCR builders (`tc.usable_filter()`). + The bounce-watcher continues to auto-suppress any individual hard bounces + in real time, so PW's own bounce rate stays bounded during the rollout. +- [ ] ~~Stand up the burner verification domain + isolated MTA identity.~~ + **Dropped** -- superseded by the catch-all auto-rollout above (the burner + was a panic-era design from before the DKIM fix + per-subscriber bounce + tracking made an in-house controlled rollout safe). The `mx_probe_blocked` + consumer-ISP pool (438k, highest dead-mailbox risk) is the only case where + a burner would still help; revisit only if that pool is ever needed. +- [x] ~~Build the verification-send + bounce-writeback worker.~~ Not needed for + catch-all (see above). `burner_list_verify.py` remains available if the + `mx_probe_blocked` pool is ever scrubbed via a burner. diff --git a/docs/carbonio-reval-autoreply-setup.md b/docs/carbonio-reval-autoreply-setup.md new file mode 100644 index 0000000..d5947cd --- /dev/null +++ b/docs/carbonio-reval-autoreply-setup.md @@ -0,0 +1,114 @@ +# Carbonio auto-reply: "my Medicare revalidation is already complete" replies + +Recurring pattern (Pangea Lab/Sue Kincer; Yakima Valley Farm Workers/Sheila +Robertson, both same day). Root cause is the CMS data-lag window: the public CMS +Medicare Revalidation Due Date List still shows a provider as due for several +weeks after they have actually been approved, so a recently completed +revalidation can still look overdue in the published data we target from. Our +outreach matched the official list; the list was just behind reality. + +## How it's deployed (Carbonio / co.carrierone.com) + +`info@performancewest.net` is a Carbonio **distribution list**, and EVERY PW +campaign (healthcare, trucking, telecom) uses `info@` as its reply-to. So the +auto-reply must (a) live on a mailbox that receives `info@` mail and (b) be +anchored to Medicare/revalidation context so it never fires on a trucking +(MCS-150/UCR) or telecom (FCC/RMD) reply. + +Setup that is LIVE: + - Created mailbox `hc-replies@performancewest.net` and added it as a second + member of the `info@` distribution list (justin@ stays on it too). + - Installed the Sieve below on hc-replies@ via + `zmprov ma hc-replies@performancewest.net zimbraMailSieveScript "Page Not Found | Performance West Inc.

404 Error

Page not found

+ Page Not Found | Performance West Inc.

404 Error

Page not found

Sorry, the page you're looking for doesn't exist or has been moved.

Go home @@ -67,7 +67,7 @@ Send reset link s1.async=true; s1.src='https://embed.tawk.to/69d5a9ca0d1c3f1c37998081/1jll9ufph'; s1.charset='UTF-8'; - s1.setAttribute('crossorigin','*'); + s0.parentNode.insertBefore(s1,s0); })(); \ No newline at end of file diff --git a/site/public/about/index.html b/site/public/about/index.html index 3b026b3..06e2553 100644 --- a/site/public/about/index.html +++ b/site/public/about/index.html @@ -5,22 +5,22 @@ if (h === "dev.performancewest.net") return "https://api.dev.performancewest.net"; return "https://api.performancewest.net"; })(); - About | Performance West Inc.

About Performance West

+ About | Performance West Inc.

Regulatory Compliance Consulting

About Performance West

Professional compliance consulting built on fixed pricing, domain expertise, and a commitment to helping businesses navigate regulatory requirements.

Our story

Performance West Inc. began with a focus on corporate and telecom regulatory compliance — helping carriers, CLECs, and VoIP providers navigate FCC filings, state PUC registrations, STIR/SHAKEN implementation, and the alphabet soup of telecom regulation.

As we worked with telecom clients, we saw the same companies struggling with compliance challenges that extended well beyond telecom: employee classification issues, TCPA exposure from their marketing operations, data privacy gaps under CCPA, and basic corporate registration oversights. Businesses were either ignoring these issues or paying law firms hundreds of dollars per hour for work that didn't require a legal opinion — it required someone who understood the regulatory requirements and could deliver practical, actionable compliance guidance.

-So we expanded. Today, Performance West covers five domains of compliance consulting: telecom, employment, data privacy, TCPA, and corporate compliance. We serve businesses across the United States from our home base in Cheyenne, Wyoming, where we are incorporated and maintain our principal office. +So we expanded. Today, Performance West is a full-service regulatory compliance consulting firm covering telecom, transportation (DOT/FMCSA), healthcare provider compliance, data privacy, TCPA, employment, and corporate compliance. We serve businesses and providers across the United States from our home base in Cheyenne, Wyoming, where we are incorporated and maintain our principal office.

Performance West is a Christian, kingdom-minded business. We operate with integrity, honesty, and a commitment to treating every client — from a single-state LLC to a national carrier — with the same level of professionalism and care. Our work reflects our values: we do what we say we'll do, we charge what we say we'll charge, and we deliver when we say we'll deliver. -

What we do

-Performance West provides compliance consulting services across telecom, employment, data privacy, TCPA, and corporate regulatory domains. We help businesses identify compliance gaps, build remediation plans, and implement the documentation, filings, and processes needed to meet their regulatory obligations. +

What we do

+Performance West provides compliance consulting services across telecom, transportation (DOT/FMCSA), healthcare provider compliance, data privacy, TCPA, employment, and corporate regulatory domains. We help businesses and providers identify compliance gaps, build remediation plans, and prepare and file the documentation needed to meet their regulatory obligations. In healthcare, that means handling Medicare PECOS revalidation and enrollment, NPI/NPPES updates, reactivations, and OIG/SAM exclusion screening — we prepare every filing, verify it for accuracy so it isn’t rejected, and track it through to acceptance, so providers never lose billing privileges to a missed deadline or a clerical error.

All of our services are offered at fixed prices with defined deliverables and turnaround times. No billable hours, no surprise invoices, no scope creep. You know exactly what you're getting and what it costs before we begin.

Important: Performance West provides compliance consulting services. We do not provide legal advice, legal opinions, or legal representation. If you need legal counsel, we recommend working with a qualified attorney in your jurisdiction. Our consultants can work alongside your legal team to provide the compliance analysis and documentation they need. -

Our approach

Fixed pricing, not billable hours

Every service has a published price. You know the cost before we start, and that's the cost when we finish.

Multi-domain expertise

Telecom, employment, privacy, TCPA, and corporate compliance under one roof. Most businesses have needs across multiple domains.

Defined turnaround times

We commit to a delivery date and meet it. Most services are delivered in 3–7 business days. No indefinite timelines.

Compliance consulting, not legal representation

We identify gaps and provide actionable remediation plans. For legal disputes, we recommend qualified attorneys in your jurisdiction.

Get in touch

+

Our approach

Fixed pricing, not billable hours

Every service has a published price. You know the cost before we start, and that's the cost when we finish.

Multi-domain expertise

Telecom, employment, privacy, TCPA, and corporate compliance under one roof. Most businesses have needs across multiple domains.

Defined turnaround times

We commit to a delivery date and meet it. Most services are delivered in 3–7 business days. No indefinite timelines.

Compliance consulting, not legal representation

We identify gaps and provide actionable remediation plans. For legal disputes, we recommend qualified attorneys in your jurisdiction.

Meet the founder

Justin Hannah, Founder of Performance West Inc.

Justin Hannah

Founder & Principal Consultant

Justin Hannah has spent more than two decades in telecommunications and regulatory-heavy industries. He has served as President of Carrier One (a retail telecom and unified-communications provider) since 2001 and built one of the first high-capacity VoIP trunking networks in the United States — deploying Asterisk-based systems as early as 2003 and working in FreeSWITCH ever since. Along the way he founded and ran wholesale voice and electronic-payments (Transactions America) businesses, giving him a rare, end-to-end view of how regulated companies actually operate.

That hands-on background — FCC filings, STIR/SHAKEN, carrier registrations, payments/PCI, and the day-to-day engineering behind them — is exactly what Performance West is built on. Justin founded the firm in 2023 to give businesses and providers a better option than ignoring their compliance obligations or paying law firms hundreds of dollars an hour for work that doesn’t require a legal opinion — it requires someone who understands the filings and gets them done correctly.

Under his leadership, Performance West has grown from telecom compliance into a multi-sector practice spanning transportation (DOT/FMCSA), healthcare provider compliance (Medicare/PECOS, NPI/NPPES), data privacy, TCPA, and corporate registration. He runs it with a deeply technical, accuracy-first approach — every filing is verified before submission and tracked through to acceptance — and as an honest, kingdom-minded business that treats a solo practitioner and a national carrier with the same care.

Connect on LinkedIn

Get in touch

Have questions about our services or want to discuss your compliance needs? Reach out anytime.

Contact us @@ -36,6 +36,7 @@ Subscribe
Performance West

Performance West Inc. · 525 Randall Ave Ste 100-1195, Cheyenne, WY 82001 · performancewest.net · (888) 411-0383

+

Privacy Policy · Terms of Service · Security & Trust · Accessibility

Performance West is a regulatory compliance consulting firm, not a law firm. This does not constitute legal advice.

How can we help?

Choose a category and tell us what you need.

All Services Pricing Free Tools Contact Form a Business Client Portal
+
Accessibility

Accessibility Statement

We want everyone to be able to use performancewest.net, regardless of ability or technology.

+ +
+

Our commitment. Performance West is committed to making our website accessible to the widest possible audience, and we aim to conform to the Web Content Accessibility Guidelines (WCAG) 2.1 Level AA as a practical standard.

+

What we do. We design for keyboard navigation, sufficient color contrast, descriptive link and image text, responsive layouts that work on phones and assistive technologies, and clear, plain-language content. We review pages as we build and update them.

+

Ongoing effort. Accessibility is an ongoing process. Some older pages or third-party components (such as embedded chat or payment widgets) may not yet fully conform; we work to improve them over time.

+

Need help, or found a barrier? If you have trouble accessing any part of this site, or you need information in an alternative format, we’ll help. Contact us and we’ll work with you directly:

+ +

We aim to respond to accessibility feedback within 3 business days.

+
+

Stay ahead of compliance changes

Regulatory updates, enforcement trends, and compliance tips. No spam.

How can we help?

Choose a category and tell us what you need.

\ No newline at end of file diff --git a/site/public/account/reset-password/index.html b/site/public/account/reset-password/index.html index 98a5e4c..7f05fd8 100644 --- a/site/public/account/reset-password/index.html +++ b/site/public/account/reset-password/index.html @@ -5,7 +5,7 @@ if (h === "dev.performancewest.net") return "https://api.dev.performancewest.net"; return "https://api.performancewest.net"; })(); - Reset Password | Performance West Inc.
Performance West

Set new password

Choose a new password for your account.