From 4d20e45ce84b3daf3d57db3c44316ae757220217 Mon Sep 17 00:00:00 2001 From: lorentz Date: Fri, 10 Jul 2026 17:33:27 -0400 Subject: [PATCH] feat(10-02): create PAX8 schema migration 091 - Six tables: pax8_companies, pax8_products, pax8_subscriptions, pax8_orders, pax8_order_items, pax8_company_match_review - Header/line-item design (D-01) with hard FK order_items->orders CASCADE - Monetary NUMERIC(12,2) + currency CHAR(3) DEFAULT 'USD' (D-04) - raw_payload JSONB safety net on all five non-review tables (D-03) - pax8_company_match_review modeled field-for-field on device_link_review (migration 080), with soft-ref BIGINT[] candidates and hard FK to pax8_companies + nullable FK to companies(id) --- migrations/091_pax8_tables.sql | 170 +++++++++++++++++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 migrations/091_pax8_tables.sql diff --git a/migrations/091_pax8_tables.sql b/migrations/091_pax8_tables.sql new file mode 100644 index 0000000..dc32ef0 --- /dev/null +++ b/migrations/091_pax8_tables.sql @@ -0,0 +1,170 @@ +-- PAX8 integration — Postgres schema. +-- +-- Lays down the full PAX8 schema Phases 11-14 will populate and consume. +-- Schema-only migration (Phase 10) — no sync logic, no matching logic yet. +-- +-- • Client companies -> pax8_companies +-- • Active seat/license subscriptions -> pax8_subscriptions +-- • Product/SKU catalog -> pax8_products +-- • Order headers (Invoice-shaped, see note below) -> pax8_orders +-- • Order line items (InvoiceItem-shaped) -> pax8_order_items +-- • PAX8 company <-> Autotask company conflicts -> pax8_company_match_review +-- +-- NOTE on pax8_orders / pax8_order_items: table names stay "orders" per the +-- D-01 header/line-item design decision, but the column shapes below carry +-- PAX8 Invoice/InvoiceItem semantics (total, status, unit price) rather than +-- the bare Order/LineItem object fields — Phase 12's sync service sources +-- these from PAX8's `/invoices` resource, not `/orders`. Confirm the exact +-- field mapping against PAX8's live API response when Phase 12 is planned. + +-- --------------------------------------------------------------------------- +-- 1. pax8_companies — PAX8 client companies +-- --------------------------------------------------------------------------- +-- external_id is left nullable and separate from name: PAX8's own company id +-- is the primary key, but there is no confirmed stable secondary identifier +-- (domain, external ref) yet — RESEARCH-pax8-company-identifiers is still +-- open. Do not assume company name is the only field comparable to Autotask. + +CREATE TABLE IF NOT EXISTS pax8_companies ( + id UUID PRIMARY KEY, + name TEXT NOT NULL, + external_id TEXT, + website TEXT, + status TEXT, + city TEXT, + state_or_province TEXT, + postal_code TEXT, + country TEXT, + raw_payload JSONB, + synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + is_deleted BOOLEAN NOT NULL DEFAULT false, + deleted_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_pax8_companies_is_deleted ON pax8_companies(is_deleted); + +-- --------------------------------------------------------------------------- +-- 2. pax8_products — PAX8 product/SKU catalog +-- --------------------------------------------------------------------------- +-- No "referenced only" constraint here on purpose: whether Phase 11 syncs +-- the full catalog or lazily populates rows as referenced by subscriptions +-- is left open (CONTEXT.md Claude's Discretion). The deprecated alternate- +-- SKU field upstream is not modeled. + +CREATE TABLE IF NOT EXISTS pax8_products ( + id UUID PRIMARY KEY, + sku TEXT, + vendor_sku TEXT, + name TEXT, + category TEXT, + raw_payload JSONB, + synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + is_deleted BOOLEAN NOT NULL DEFAULT false, + deleted_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_pax8_products_is_deleted ON pax8_products(is_deleted); + +-- --------------------------------------------------------------------------- +-- 3. pax8_subscriptions — active seat/license subscriptions per company +-- --------------------------------------------------------------------------- +-- pax8_company_id / product_id are intentionally soft refs (plain indexed +-- columns, not hard FKs) — sync insert order across companies/subscriptions/ +-- products is not guaranteed within a single sync pass. + +CREATE TABLE IF NOT EXISTS pax8_subscriptions ( + id UUID PRIMARY KEY, + pax8_company_id UUID, -- soft ref -> pax8_companies(id) + product_id UUID, -- soft ref -> pax8_products(id) + quantity INTEGER, + billing_term TEXT, + status TEXT, + start_date TIMESTAMPTZ, + raw_payload JSONB, + synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + is_deleted BOOLEAN NOT NULL DEFAULT false, + deleted_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_pax8_subscriptions_is_deleted ON pax8_subscriptions(is_deleted); +CREATE INDEX IF NOT EXISTS idx_pax8_subscriptions_company ON pax8_subscriptions(pax8_company_id); + +-- --------------------------------------------------------------------------- +-- 4. pax8_orders — order/invoice header (D-01 header table) +-- --------------------------------------------------------------------------- +-- Invoice-shaped: total/status/currency mirror PAX8's Invoice object per +-- RESEARCH Pitfall 1. No lookback-window column (D-02) — sync pulls full +-- order history on first sync in a later phase. + +CREATE TABLE IF NOT EXISTS pax8_orders ( + id UUID PRIMARY KEY, + pax8_company_id UUID, -- soft ref -> pax8_companies(id) + order_date TIMESTAMPTZ, + total NUMERIC(12,2), + status TEXT, + currency CHAR(3) NOT NULL DEFAULT 'USD', + raw_payload JSONB, + synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + is_deleted BOOLEAN NOT NULL DEFAULT false, + deleted_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_pax8_orders_is_deleted ON pax8_orders(is_deleted); +CREATE INDEX IF NOT EXISTS idx_pax8_orders_company ON pax8_orders(pax8_company_id); + +-- --------------------------------------------------------------------------- +-- 5. pax8_order_items — order/invoice line items (D-01 line table) +-- --------------------------------------------------------------------------- +-- InvoiceItem-shaped: unit_price/line_total mirror PAX8's InvoiceItem object +-- per RESEARCH Pitfall 1. Hard FK to pax8_orders — header/line relationship, +-- unlike the company/product soft refs above, because order_items are always +-- inserted together with (after) their parent order in a single sync pass. + +CREATE TABLE IF NOT EXISTS pax8_order_items ( + id UUID PRIMARY KEY, + order_id UUID NOT NULL REFERENCES pax8_orders(id) ON DELETE CASCADE, + product_id UUID, -- soft ref -> pax8_products(id) + quantity INTEGER, + unit_price NUMERIC(12,2), + line_total NUMERIC(12,2), + currency CHAR(3) NOT NULL DEFAULT 'USD', + raw_payload JSONB, + synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + is_deleted BOOLEAN NOT NULL DEFAULT false, + deleted_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_pax8_order_items_is_deleted ON pax8_order_items(is_deleted); +CREATE INDEX IF NOT EXISTS idx_pax8_order_items_order ON pax8_order_items(order_id); + +-- --------------------------------------------------------------------------- +-- 6. pax8_company_match_review — PAX8 <-> Autotask company conflicts queue +-- --------------------------------------------------------------------------- +-- Copied field-for-field from device_link_review (migration 080). Populated +-- by Phase 12's matching logic — this migration only creates the shape. +-- Admin picks the right Autotask company; the matcher does not auto-merge. + +CREATE TABLE IF NOT EXISTS pax8_company_match_review ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + pax8_company_id UUID NOT NULL REFERENCES pax8_companies(id) ON DELETE CASCADE, + candidate_company_ids BIGINT[] NOT NULL, + match_confidences TEXT[] NOT NULL, + detected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + resolved_at TIMESTAMPTZ, + resolved_by_user_id TEXT REFERENCES "user"(id) ON DELETE SET NULL, + resolved_to_company_id BIGINT REFERENCES companies(id) ON DELETE SET NULL, + resolution_note TEXT +); + +CREATE INDEX IF NOT EXISTS ix_pax8_company_match_review_unresolved + ON pax8_company_match_review(detected_at DESC) WHERE resolved_at IS NULL; +CREATE INDEX IF NOT EXISTS ix_pax8_company_match_review_pax8_company + ON pax8_company_match_review(pax8_company_id); + +-- Don't keep stale rows blocking new reviews: partial unique index enforces +-- "one open review per PAX8 company" rather than a blanket unique constraint. +CREATE UNIQUE INDEX IF NOT EXISTS uq_pax8_company_match_review_open + ON pax8_company_match_review(pax8_company_id) WHERE resolved_at IS NULL; + +COMMENT ON TABLE pax8_company_match_review IS + 'PAX8 -> Autotask company-match conflicts queue: one row per PAX8 company that matches 2+ Autotask companies (or is otherwise ambiguous). Populated by Phase 12''s matching logic. Admin picks the right Autotask company; the matcher does not auto-merge.';