Compare commits

..

No commits in common. "2bd87d27f22d0d49f34b4dbecf7b6e2b5252b1f9" and "0cb9db66ad85ecc76807e720c0ccd0c584001e16" have entirely different histories.

1825 changed files with 145148 additions and 75 deletions

View file

@ -0,0 +1,11 @@
---
name: No Venn.ca references in campaigns
description: User wants all Venn.ca bank account mentions removed from email campaigns and marketing content
type: feedback
---
Do not include Venn.ca Canadian business bank account references in email campaigns or marketing content.
**Why:** User explicitly asked to remove these references. The Venn banking step should not be pitched in the campaign emails.
**How to apply:** When creating or updating Listmonk campaign content, omit the "Open your Venn.ca Canadian business bank account" step from process walkthroughs and any other Venn mentions.

29
.gitignore vendored Normal file
View file

@ -0,0 +1,29 @@
node_modules/
dist/
.astro/
__pycache__/
*.pyc
.env
.env.*
*.log
pgdata/
*.sqlite
*.db
# Docker volumes
minio-data/
forgejo-data/
# OS
.DS_Store
Thumbs.db
# IDE
.vscode/
.idea/
# Build artifacts
api/dist/
site/dist/
site/.astro/
mcp/dist/

48
CLAUDE.md Normal file
View file

@ -0,0 +1,48 @@
# Performance West — Development Guidelines
## Deployment Rules
- **NEVER** edit files in `/tmp/` — always edit in this project directory
- **NEVER** scp individual files to dev/prod — always commit and deploy via git
- After editing any file, commit it: `git add <file> && git commit -m "description"`
- All source code lives in this repo and is deployed via `git pull` on the server
### Deploy to dev
```bash
./scripts/deploy.sh dev
```
### Deploy to prod
```bash
./scripts/deploy.sh prod
```
## Git Server
- **URL**: https://git.performancewest.net
- **Repo**: performancewest/new-site
- **SSH clone**: `git clone ssh://git@git.performancewest.net:2222/performancewest/new-site.git`
## Infrastructure
- **Prod server**: `deploy@207.174.124.71:22022``/opt/performancewest/`
- **Dev server**: same host → `/opt/performancewest-dev/`
- **HestiaCP**: `root@cp.carrierone.com:22022` (DNS, email provisioning)
- **Docker Compose**: all services run in containers (API, site, workers, postgres, etc.)
## Project Structure
- `api/` — Express.js API (TypeScript)
- `site/` — Astro static site (pages, components, layouts)
- `scripts/` — Python workers, document generators, scrapers
- `infra/` — Ansible playbooks, nginx configs
- `docs/` — Product documentation
## Site Pages
Site source files live in `site/src/pages/`. The site uses:
- Layout: `site/src/layouts/Base.astro`
- Components: `site/src/components/`
- Tailwind CSS with `pw-` custom color palette
- Inline `<script>` for interactivity (no React/Vue)
- API base URL: `(window as any).__PW_API`

73
LICENSE
View file

@ -1,73 +0,0 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
(a) You must give any other recipients of the Work or Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.
You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives.
Copyright 2026 justin
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View file

@ -1,2 +0,0 @@
# new-site

17
api/Dockerfile Normal file
View file

@ -0,0 +1,17 @@
FROM node:22-slim AS builder
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
COPY tsconfig.json ./
COPY src/ src/
RUN npm run build
FROM node:22-slim
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json* ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist dist/
COPY migrations/ migrations/
EXPOSE 3001
CMD ["node", "dist/index.js"]

View file

@ -0,0 +1,84 @@
-- 001_core_tables.sql
-- Core tables for Performance West API: subscribers, tickets, quotes, orders.
-- Run: psql $DATABASE_URL < migrations/001_core_tables.sql
BEGIN;
-- Mailing list subscribers
CREATE TABLE IF NOT EXISTS subscribers (
id SERIAL PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
name TEXT,
company TEXT,
consent_text TEXT NOT NULL,
consent_at TIMESTAMPTZ NOT NULL DEFAULT now(),
ip_address TEXT,
confirmed BOOLEAN DEFAULT FALSE,
unsubscribed BOOLEAN DEFAULT FALSE,
source TEXT DEFAULT 'website',
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_subscribers_email ON subscribers(email);
CREATE INDEX IF NOT EXISTS idx_subscribers_created ON subscribers(created_at DESC);
-- Support tickets (local fallback when Zammad is unavailable)
CREATE TABLE IF NOT EXISTS tickets (
id SERIAL PRIMARY KEY,
category TEXT NOT NULL CHECK (category IN (
'question', 'support', 'issue', 'service_request', 'quote'
)),
subject TEXT NOT NULL,
message TEXT NOT NULL,
email TEXT,
name TEXT,
page TEXT,
ip_address TEXT,
zammad_ticket_id TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_tickets_category ON tickets(category);
CREATE INDEX IF NOT EXISTS idx_tickets_created ON tickets(created_at DESC);
-- Quote requests (for custom-priced services)
CREATE TABLE IF NOT EXISTS quotes (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL,
company TEXT,
phone TEXT,
service_slug TEXT NOT NULL,
details TEXT,
status TEXT DEFAULT 'pending' CHECK (status IN (
'pending', 'sent', 'accepted', 'declined', 'expired'
)),
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_quotes_status ON quotes(status);
CREATE INDEX IF NOT EXISTS idx_quotes_email ON quotes(email);
-- Service orders (fixed-price and accepted quotes)
CREATE TABLE IF NOT EXISTS orders (
id SERIAL PRIMARY KEY,
order_number TEXT UNIQUE NOT NULL,
quote_id INTEGER REFERENCES quotes(id),
service_slug TEXT NOT NULL,
name TEXT NOT NULL,
email TEXT NOT NULL,
company TEXT,
status TEXT DEFAULT 'received' CHECK (status IN (
'received', 'processing', 'review', 'delivered', 'cancelled'
)),
amount_cents INTEGER,
created_at TIMESTAMPTZ DEFAULT now(),
delivered_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status);
CREATE INDEX IF NOT EXISTS idx_orders_number ON orders(order_number);
CREATE INDEX IF NOT EXISTS idx_orders_email ON orders(email);
COMMIT;

View file

@ -0,0 +1,171 @@
-- 002_state_filing_fees.sql
-- State-by-state business formation filing fees for all 50 states + DC.
-- All monetary values in cents (e.g., 10000 = $100.00).
-- Sources: LLC University (2026), individual state SOS offices, Nolo (2025).
-- Last verified: 2026-03-19
BEGIN;
CREATE TABLE IF NOT EXISTS state_filing_fees (
id SERIAL PRIMARY KEY,
state_code CHAR(2) NOT NULL UNIQUE,
state_name TEXT NOT NULL,
-- LLC Formation
llc_formation_fee INTEGER NOT NULL,
llc_annual_fee INTEGER,
llc_annual_period TEXT,
-- Corporation Formation
corp_formation_fee INTEGER NOT NULL,
corp_annual_fee INTEGER,
corp_annual_period TEXT,
-- Foreign Qualification
foreign_llc_fee INTEGER,
foreign_corp_fee INTEGER,
-- Expedited Processing
expedited_fee INTEGER,
expedited_label TEXT,
-- Name Reservation
name_reservation_fee INTEGER,
name_reservation_days INTEGER,
-- Special Requirements
publication_required BOOLEAN DEFAULT FALSE,
publication_est_cost INTEGER,
franchise_tax_required BOOLEAN DEFAULT FALSE,
franchise_tax_min INTEGER,
franchise_tax_notes TEXT,
business_license_required BOOLEAN DEFAULT FALSE,
business_license_fee INTEGER,
-- Filing Portal
portal_name TEXT,
portal_url TEXT,
online_filing_available BOOLEAN DEFAULT TRUE,
typical_processing_days INTEGER,
-- Metadata
notes TEXT,
fee_source_url TEXT,
last_verified DATE DEFAULT '2026-03-19',
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- Also create table for NW RA wholesale pricing (to be populated later)
CREATE TABLE IF NOT EXISTS nwra_wholesale_pricing (
id SERIAL PRIMARY KEY,
state_code CHAR(2) NOT NULL,
service_type TEXT NOT NULL,
wholesale_cost INTEGER NOT NULL,
retail_price INTEGER NOT NULL,
notes TEXT,
last_verified DATE,
UNIQUE(state_code, service_type)
);
-- Also create table for formation orders
CREATE TABLE IF NOT EXISTS formation_orders (
id SERIAL PRIMARY KEY,
order_number TEXT UNIQUE NOT NULL,
customer_name TEXT NOT NULL,
customer_email TEXT NOT NULL,
customer_phone TEXT,
customer_company TEXT,
state_code CHAR(2) NOT NULL,
entity_type TEXT NOT NULL CHECK (entity_type IN ('llc', 'corporation', 's_corp')),
entity_name TEXT NOT NULL,
entity_name_alt TEXT,
management_type TEXT,
registered_agent_state CHAR(2),
principal_address TEXT,
mailing_address TEXT,
members_json JSONB,
include_ra_service BOOLEAN DEFAULT TRUE,
include_ein BOOLEAN DEFAULT FALSE,
include_operating_agreement BOOLEAN DEFAULT FALSE,
expedited BOOLEAN DEFAULT FALSE,
state_fee_cents INTEGER NOT NULL,
service_fee_cents INTEGER NOT NULL,
nwra_cost_cents INTEGER NOT NULL DEFAULT 0,
expedited_fee_cents INTEGER DEFAULT 0,
total_cents INTEGER NOT NULL,
status TEXT DEFAULT 'received' CHECK (status IN (
'received', 'processing', 'submitted', 'filed', 'delivered', 'cancelled'
)),
nwra_order_id TEXT,
state_filing_number TEXT,
filed_at TIMESTAMPTZ,
formation_docs_url TEXT,
delivered_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_formation_orders_status ON formation_orders(status);
CREATE INDEX IF NOT EXISTS idx_formation_orders_state ON formation_orders(state_code);
CREATE INDEX IF NOT EXISTS idx_formation_orders_email ON formation_orders(customer_email);
-- Seed all 50 states + DC
INSERT INTO state_filing_fees (
state_code, state_name,
llc_formation_fee, llc_annual_fee, llc_annual_period,
corp_formation_fee, corp_annual_fee, corp_annual_period,
foreign_llc_fee, foreign_corp_fee,
expedited_fee, expedited_label,
name_reservation_fee, name_reservation_days,
publication_required, publication_est_cost,
franchise_tax_required, franchise_tax_min, franchise_tax_notes,
business_license_required, business_license_fee,
portal_name, portal_url, online_filing_available, typical_processing_days,
notes, fee_source_url
) VALUES
('AL', 'Alabama', 20000, NULL, NULL, 20000, NULL, NULL, 20000, 20000, NULL, NULL, 280000, 120, FALSE, NULL, FALSE, NULL, 'BPT $50 min/yr', FALSE, NULL, 'AL SOS', 'sos.alabama.gov', TRUE, 7, '$50 BPT due 2.5 months after formation', 'sos.alabama.gov/sites/default/files/form-files/FeeSchedule.pdf'),
('AK', 'Alaska', 25000, 1000000, 'biennial', 25000, 1000000, 'biennial', 25000, 25000, NULL, NULL, 250000, 120, FALSE, NULL, FALSE, NULL, NULL, TRUE, 500000, 'AK CBPL', 'commerce.alaska.gov', TRUE, 10, '$50/yr state business license', 'commerce.alaska.gov/web/cbpl/Corporations'),
('AZ', 'Arizona', 5000, NULL, NULL, 6000, NULL, NULL, 15000, 18500, 1000000, 'same-day', 100000, 120, TRUE, 3000000, FALSE, NULL, NULL, FALSE, NULL, 'eCorp', 'ecorp.azcc.gov', TRUE, 5, 'Publication req ~$200-400', 'azcc.gov/corporations'),
('AR', 'Arkansas', 4500, 1500000, 'annual', 4500, 1500000, 'annual', 27500, 27500, 500000, 'same-day', 250000, 120, FALSE, NULL, FALSE, NULL, NULL, FALSE, NULL, 'AR SOS', 'sos.arkansas.gov', TRUE, 5, NULL, 'sos.arkansas.gov'),
('CA', 'California', 7000, 8000000, 'annual', 10000, 250000, 'annual', 7000, 10000, 7500000, 'same-day', 100000, 60, FALSE, NULL, TRUE, 8000000, '$800/yr franchise tax (waived 1st yr sometimes); $900-$11790 if revenue >$250K', FALSE, NULL, 'bizfile Online', 'bizfileonline.sos.ca.gov', TRUE, 5, 'Very expensive ongoing costs', 'sos.ca.gov/business-programs'),
('CO', 'Colorado', 5000, 250000, 'annual', 5000, 250000, 'annual', 10000, 10000, NULL, NULL, 250000, 120, FALSE, NULL, FALSE, NULL, NULL, FALSE, NULL, 'CO SOS', 'sos.state.co.us', TRUE, 1, 'Online filing is immediate', 'sos.state.co.us/pubs/business'),
('CT', 'Connecticut', 12000, 800000, 'annual', 25000, 1500000, 'annual', 12000, 25000, 1000000, 'same-day', 600000, 120, FALSE, NULL, FALSE, NULL, NULL, FALSE, NULL, 'CT SOTS', 'portal.ct.gov/sots', TRUE, 5, NULL, 'portal.ct.gov/sots'),
('DE', 'Delaware', 11000, 3000000, 'annual', 8900, 1750000, 'annual', 20000, 24500, 5000000, '24-hour', 750000, 120, FALSE, NULL, TRUE, 1750000, '$300/yr LLC franchise tax; Corp $175 min', FALSE, NULL, 'eCorp/ICIS', 'corp.delaware.gov', TRUE, 14, '$5000 for 1-hour rush', 'corp.delaware.gov/fee/'),
('FL', 'Florida', 12500, 1387500, 'annual', 7000, 1500000, 'annual', 12500, 7000, NULL, NULL, 250000, 120, FALSE, NULL, FALSE, NULL, NULL, FALSE, NULL, 'Sunbiz', 'sunbiz.org', TRUE, 2, '$400 late fee for annual report', 'dos.fl.gov/sunbiz/forms/fees/'),
('GA', 'Georgia', 11000, 600000, 'annual', 11000, 600000, 'annual', 23500, 23500, 1000000, 'same-day', 250000, 120, FALSE, NULL, FALSE, NULL, NULL, FALSE, NULL, 'GA SOS', 'sos.ga.gov', TRUE, 7, NULL, 'sos.ga.gov/corporations-division'),
('HI', 'Hawaii', 5000, 150000, 'annual', 5000, 150000, 'annual', 5000, 5000, 250000, 'expedited', 100000, 120, FALSE, NULL, FALSE, NULL, NULL, FALSE, NULL, 'HI BREG', 'cca.hawaii.gov/breg', TRUE, 7, NULL, 'cca.hawaii.gov/breg'),
('ID', 'Idaho', 10000, NULL, 'annual', 10000, NULL, 'annual', 10000, 10000, 200000, 'expedited', 200000, 120, FALSE, NULL, FALSE, NULL, NULL, FALSE, NULL, 'ID SOS', 'sos.idaho.gov', TRUE, 5, 'Annual report required but $0 fee', 'sos.idaho.gov/business'),
('IL', 'Illinois', 15000, 750000, 'annual', 15000, 750000, 'annual', 15000, 17500, 5000000, 'same-day', 250000, 90, FALSE, NULL, FALSE, NULL, NULL, FALSE, NULL, 'IL SOS', 'ilsos.gov', TRUE, 5, NULL, 'ilsos.gov/departments/business_services'),
('IN', 'Indiana', 9500, 310000, 'biennial', 9500, 310000, 'biennial', 9500, 9500, 1000000, '24-hour', 200000, 120, FALSE, NULL, FALSE, NULL, NULL, FALSE, NULL, 'IN SOS', 'in.gov/sos/business', TRUE, 5, NULL, 'in.gov/sos/business'),
('IA', 'Iowa', 5000, 300000, 'biennial', 5000, 600000, 'biennial', 10000, 10000, 250000, '24-hour', 100000, 120, FALSE, NULL, FALSE, NULL, NULL, FALSE, NULL, 'IA SOS', 'sos.iowa.gov', TRUE, 5, NULL, 'sos.iowa.gov/business'),
('KS', 'Kansas', 16000, 500000, 'annual', 9000, 550000, 'annual', 16500, 11500, 400000, 'rush', 300000, 120, FALSE, NULL, FALSE, NULL, NULL, FALSE, NULL, 'KS SOS', 'sos.ks.gov', TRUE, 5, NULL, 'sos.ks.gov/business'),
('KY', 'Kentucky', 4000, 150000, 'annual', 4000, 150000, 'annual', 9000, 9000, 500000, 'same-day', 150000, 120, FALSE, NULL, FALSE, NULL, NULL, FALSE, NULL, 'KY SOS', 'sos.ky.gov', TRUE, 3, 'One of the cheapest states', 'sos.ky.gov/bus'),
('LA', 'Louisiana', 10000, 350000, 'annual', 7500, 350000, 'annual', 15000, 15000, 500000, 'same-day', 250000, 60, FALSE, NULL, FALSE, NULL, NULL, FALSE, NULL, 'LA SOS', 'sos.la.gov', TRUE, 5, 'Initial report due within 90 days', 'sos.la.gov/BusinessServices'),
('ME', 'Maine', 17500, 850000, 'annual', 14500, 850000, 'annual', 25000, 25000, 1000000, 'same-day', 200000, 120, FALSE, NULL, FALSE, NULL, NULL, FALSE, NULL, 'ME SOS', 'maine.gov/sos/cec/corp', TRUE, 7, NULL, 'maine.gov/sos/cec/corp'),
('MD', 'Maryland', 10000, 3000000, 'annual', 17000, 3000000, 'annual', 10000, 17000, 500000, 'same-day', 250000, 120, FALSE, NULL, FALSE, NULL, NULL, FALSE, NULL, 'MD SDAT', 'dat.maryland.gov', TRUE, 7, 'Expensive ongoing: $300/yr annual report', 'dat.maryland.gov/businesses'),
('MA', 'Massachusetts', 50000, 5000000, 'annual', 27500, 1250000, 'annual', 50000, 30000, 750000, 'expedited', 300000, 60, FALSE, NULL, FALSE, NULL, NULL, FALSE, NULL, 'MA SOC', 'sec.state.ma.us/cor', TRUE, 5, 'Most expensive state for LLC formation + annual', 'sec.state.ma.us/cor'),
('MI', 'Michigan', 5000, 250000, 'annual', 6000, 250000, 'annual', 5000, 6000, 1000000, 'same-day', 250000, 120, FALSE, NULL, FALSE, NULL, NULL, FALSE, NULL, 'MI LARA', 'michigan.gov/lara', TRUE, 5, '$1000 for 1-hour rush', 'michigan.gov/lara'),
('MN', 'Minnesota', 15500, NULL, NULL, 15500, NULL, NULL, 15500, 15500, NULL, NULL, 350000, 120, FALSE, NULL, FALSE, NULL, NULL, FALSE, NULL, 'MN SOS', 'sos.state.mn.us', TRUE, 3, 'No RA required; no annual fee', 'sos.state.mn.us/business-liens'),
('MS', 'Mississippi', 5000, NULL, NULL, 5000, NULL, NULL, 25000, 25000, 500000, '24-hour', 250000, 180, FALSE, NULL, FALSE, NULL, NULL, FALSE, NULL, 'MS SOS', 'sos.ms.gov', TRUE, 5, 'No annual report for LLCs', 'sos.ms.gov/business-services'),
('MO', 'Missouri', 5000, NULL, NULL, 5800, NULL, NULL, 10500, 10500, 500000, '24-hour', 250000, 60, FALSE, NULL, FALSE, NULL, NULL, FALSE, NULL, 'MO SOS', 'sos.mo.gov/business', TRUE, 5, 'No annual report or fee for LLCs', 'sos.mo.gov/business'),
('MT', 'Montana', 3500, 200000, 'annual', 7000, 200000, 'annual', 3500, 7000, 200000, 'expedited', 100000, 120, FALSE, NULL, FALSE, NULL, NULL, FALSE, NULL, 'MT SOS', 'sosmt.gov/business', TRUE, 5, 'Cheapest LLC formation in the US', 'sosmt.gov/business'),
('NE', 'Nebraska', 10000, 130000, 'biennial', 10000, 130000, 'biennial', 12000, 10000, NULL, NULL, 300000, 120, FALSE, NULL, FALSE, NULL, NULL, FALSE, NULL, 'NE SOS', 'sos.nebraska.gov', TRUE, 5, NULL, 'sos.nebraska.gov'),
('NV', 'Nevada', 42500, 3500000, 'annual', 72500, 6500000, 'annual', 42500, 42500, 5000000, '2-hour', 250000, 90, FALSE, NULL, FALSE, NULL, NULL, TRUE, 2000000, 'SilverFlume', 'nvsilverflume.gov', TRUE, 3, '$200 LLC biz license + $500 corp biz license; no income tax', 'nvsilverflume.gov'),
('NH', 'New Hampshire', 10000, 1000000, 'annual', 10000, 1000000, 'annual', 10000, 10000, NULL, NULL, 150000, 120, FALSE, NULL, FALSE, NULL, NULL, FALSE, NULL, 'NH SOS', 'sos.nh.gov', TRUE, 7, NULL, 'sos.nh.gov'),
('NJ', 'New Jersey', 12500, 750000, 'annual', 12500, 750000, 'annual', 12500, 12500, 500000, 'same-day', 500000, 120, FALSE, NULL, FALSE, NULL, NULL, FALSE, NULL, 'NJ DORES', 'njportal.com/dor', TRUE, 5, NULL, 'njportal.com/dor'),
('NM', 'New Mexico', 5000, NULL, NULL, 12500, 250000, 'biennial', 10000, 10000, NULL, NULL, 200000, 120, FALSE, NULL, FALSE, NULL, NULL, FALSE, NULL, 'NM SOS', 'sos.nm.gov', TRUE, 5, 'No LLC annual report or fee', 'sos.nm.gov'),
('NY', 'New York', 20000, 90000, 'biennial', 12500, 90000, 'biennial', 25000, 22500, 5000000, '2-hour', 200000, 60, TRUE, 10000000, FALSE, NULL, 'Corp franchise tax based on income/capital', FALSE, NULL, 'NY DOS', 'dos.ny.gov', TRUE, 7, 'PUBLICATION REQ: 2 newspapers x 6 weeks = $200-$2000+ depending on county', 'dos.ny.gov/business'),
('NC', 'North Carolina', 12500, 2000000, 'annual', 12500, 2000000, 'annual', 25000, 25000, 5000000, 'same-day', 300000, 120, FALSE, NULL, FALSE, NULL, 'Corp: $200 min franchise tax based on net worth', FALSE, NULL, 'NC SOS', 'sosnc.gov', TRUE, 5, NULL, 'sosnc.gov'),
('ND', 'North Dakota', 13500, 500000, 'annual', 10000, 250000, 'annual', 13500, 13500, NULL, NULL, 100000, 365, FALSE, NULL, FALSE, NULL, NULL, FALSE, NULL, 'ND SOS', 'sos.nd.gov', TRUE, 7, NULL, 'sos.nd.gov'),
('OH', 'Ohio', 9900, NULL, NULL, 9900, NULL, NULL, 9900, 9900, NULL, NULL, 390000, 180, FALSE, NULL, FALSE, NULL, NULL, FALSE, NULL, 'OH SOS', 'ohiosos.gov', TRUE, 5, 'No annual reports or fees at all', 'ohiosos.gov'),
('OK', 'Oklahoma', 10000, 250000, 'annual', 5000, NULL, NULL, 30000, 30000, NULL, NULL, 100000, 60, FALSE, NULL, FALSE, NULL, NULL, FALSE, NULL, 'OK SOS', 'sos.ok.gov', TRUE, 5, NULL, 'sos.ok.gov'),
('OR', 'Oregon', 10000, 1000000, 'annual', 10000, 500000, 'annual', 27500, 27500, 1000000, 'same-day', 1000000, 120, FALSE, NULL, FALSE, NULL, NULL, FALSE, NULL, 'OR SOS', 'sos.oregon.gov', TRUE, 5, NULL, 'sos.oregon.gov'),
('PA', 'Pennsylvania', 12500, 70000, 'annual', 12500, 70000, 'annual', 25000, 25000, 3000000, 'same-day', 700000, 120, FALSE, NULL, FALSE, NULL, NULL, FALSE, NULL, 'PA DOS', 'dos.pa.gov', TRUE, 7, '$7/yr annual report (new 2025)', 'dos.pa.gov'),
('RI', 'Rhode Island', 15000, 500000, 'annual', 23000, 500000, 'annual', 15000, 15000, NULL, NULL, 500000, 120, FALSE, NULL, FALSE, NULL, 'Corp: $400 min annual tax', FALSE, NULL, 'RI SOS', 'sos.ri.gov', TRUE, 7, NULL, 'sos.ri.gov'),
('SC', 'South Carolina', 11000, NULL, NULL, 13500, 250000, 'annual', 11000, 11000, 500000, '24-hour', 250000, 120, FALSE, NULL, FALSE, NULL, 'Corp: $25 min annual license fee', FALSE, NULL, 'SC SOS', 'sos.sc.gov', TRUE, 5, 'No LLC annual report', 'sos.sc.gov'),
('SD', 'South Dakota', 15000, 550000, 'annual', 15000, 500000, 'annual', 75000, 75000, NULL, NULL, 250000, 120, FALSE, NULL, FALSE, NULL, NULL, FALSE, NULL, 'SD SOS', 'sdsos.gov', TRUE, 5, '$750 foreign LLC fee (highest)', 'sdsos.gov'),
('TN', 'Tennessee', 30000, 3000000, 'annual', 10000, 200000, 'annual', 30000, 30000, 5000000, '24-hour', 200000, 120, FALSE, NULL, FALSE, NULL, 'Corp: $100 min franchise tax based on net worth', FALSE, NULL, 'TN SOS', 'sos.tn.gov', TRUE, 5, 'LLC fees are $50/member with $300 min', 'sos.tn.gov'),
('TX', 'Texas', 30000, NULL, NULL, 30000, NULL, NULL, 75000, 75000, 500000, 'same-day', 400000, 120, FALSE, NULL, TRUE, 0, 'Franchise tax only if revenue >$2.47M; PIR required May 15', FALSE, NULL, 'SOSDirect', 'direct.sos.state.tx.us', TRUE, 3, 'No income tax; $750 foreign LLC fee', 'sos.state.tx.us'),
('UT', 'Utah', 5900, 180000, 'annual', 5900, 180000, 'annual', 7000, 7000, 750000, 'same-day', 220000, 120, FALSE, NULL, FALSE, NULL, NULL, FALSE, NULL, 'UT DCC', 'corporations.utah.gov', TRUE, 3, 'Cheapest overall for formation + ongoing', 'corporations.utah.gov'),
('VT', 'Vermont', 15500, 450000, 'annual', 15500, 600000, 'annual', 12500, 12500, NULL, NULL, 200000, 120, FALSE, NULL, FALSE, NULL, NULL, FALSE, NULL, 'VT SOS', 'sos.vermont.gov', TRUE, 7, NULL, 'sos.vermont.gov'),
('VA', 'Virginia', 10000, 500000, 'annual', 7500, 250000, 'annual', 10000, 10000, 2000000, '2-hour', 100000, 120, FALSE, NULL, FALSE, NULL, 'Stock corp: $100 min/yr based on shares', FALSE, NULL, 'VA SCC', 'scc.virginia.gov', TRUE, 3, NULL, 'scc.virginia.gov'),
('WA', 'Washington', 20000, 600000, 'annual', 19000, 700000, 'annual', 20000, 18000, 500000, 'same-day', 300000, 180, FALSE, NULL, FALSE, NULL, 'B&O gross receipts tax (not formation-related)', FALSE, NULL, 'CCFS', 'ccfs.sos.wa.gov', TRUE, 3, 'No income tax; B&O tax applies', 'ccfs.sos.wa.gov'),
('WV', 'West Virginia', 10000, 250000, 'annual', 10000, 250000, 'annual', 15000, 15000, 250000, '24-hour', 150000, 120, FALSE, NULL, FALSE, NULL, NULL, FALSE, NULL, 'WV SOS', 'sos.wv.gov', TRUE, 5, NULL, 'sos.wv.gov'),
('WI', 'Wisconsin', 13000, 250000, 'annual', 10000, 250000, 'annual', 10000, 10000, 250000, 'same-day', 150000, 120, FALSE, NULL, FALSE, NULL, NULL, FALSE, NULL, 'WI DFI', 'dfi.wi.gov', TRUE, 3, NULL, 'dfi.wi.gov'),
('WY', 'Wyoming', 10000, 600000, 'annual', 10000, 600000, 'annual', 10000, 10000, 1000000, 'same-day', 500000, 120, FALSE, NULL, FALSE, NULL, NULL, FALSE, NULL, 'WyoBiz', 'wyobiz.wyo.gov', TRUE, 2, 'No income tax; privacy-friendly; no member/manager names on public filings', 'wyobiz.wyo.gov'),
('DC', 'District of Columbia', 9900, 3000000, 'biennial', 9900, 3000000, 'biennial', 9900, 9900, NULL, NULL, 250000, 120, FALSE, NULL, FALSE, NULL, NULL, FALSE, NULL, 'DC DLCP', 'dcra.dc.gov', TRUE, 5, '$300 biennial report', 'dcra.dc.gov');
COMMIT;

View file

@ -0,0 +1,77 @@
-- 003_discount_codes.sql
-- Discount and referral code system.
-- Supports percentage and flat discounts, usage limits, expiration,
-- referral partner tracking, and per-service or global application.
BEGIN;
-- Discount / referral codes
CREATE TABLE IF NOT EXISTS discount_codes (
id SERIAL PRIMARY KEY,
code TEXT NOT NULL UNIQUE, -- e.g. 'SAVE20', 'PARTNER-ACME', 'REDDIT10'
description TEXT, -- internal note
-- Discount type: 'percent' or 'flat'
discount_type TEXT NOT NULL CHECK (discount_type IN ('percent', 'flat')),
discount_value INTEGER NOT NULL, -- percent (0-100) or flat amount in cents
-- e.g. percent=20 means 20% off service fee; flat=10000 means $100 off total
-- Scope: which services does this code apply to?
-- NULL = all services; otherwise comma-separated slugs
applies_to TEXT DEFAULT NULL, -- NULL=all, or 'formation,llc,corporation'
-- Referral partner info (for payout tracking)
referral_partner TEXT, -- partner name or ID
referral_email TEXT, -- partner payout email
referral_pct INTEGER DEFAULT 0, -- % of service fee paid to partner (0-100)
-- Limits
max_uses INTEGER DEFAULT NULL, -- NULL = unlimited
max_uses_per_email INTEGER DEFAULT 1, -- per customer email
current_uses INTEGER DEFAULT 0,
-- Validity
active BOOLEAN DEFAULT TRUE,
starts_at TIMESTAMPTZ DEFAULT now(),
expires_at TIMESTAMPTZ DEFAULT NULL, -- NULL = never expires
-- Metadata
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_discount_codes_code ON discount_codes(code);
CREATE INDEX IF NOT EXISTS idx_discount_codes_partner ON discount_codes(referral_partner);
-- Usage log: track every time a code is used
CREATE TABLE IF NOT EXISTS discount_usage (
id SERIAL PRIMARY KEY,
discount_code_id INTEGER NOT NULL REFERENCES discount_codes(id),
code TEXT NOT NULL,
order_type TEXT NOT NULL, -- 'formation', 'service', 'quote'
order_id INTEGER, -- FK to formation_orders.id or orders.id
customer_email TEXT NOT NULL,
discount_amount INTEGER NOT NULL, -- actual discount applied in cents
referral_payout INTEGER DEFAULT 0, -- amount owed to referral partner in cents
ip_address TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_discount_usage_code ON discount_usage(code);
CREATE INDEX IF NOT EXISTS idx_discount_usage_email ON discount_usage(customer_email);
CREATE INDEX IF NOT EXISTS idx_discount_usage_partner ON discount_usage(discount_code_id);
-- Seed some example codes
INSERT INTO discount_codes (code, description, discount_type, discount_value, applies_to, max_uses, active) VALUES
('LAUNCH25', 'Launch promotion — 25% off service fee', 'percent', 25, NULL, 100, TRUE),
('FIRST50', 'First-time customer — $50 off', 'flat', 5000, NULL, NULL, TRUE),
('FORMATION100', '$100 off business formation', 'flat', 10000, 'formation', NULL, TRUE),
('REDDIT10', 'Reddit community — 10% off', 'percent', 10, NULL, NULL, TRUE)
ON CONFLICT (code) DO NOTHING;
-- Example referral partner code
INSERT INTO discount_codes (code, description, discount_type, discount_value, referral_partner, referral_email, referral_pct, max_uses, active) VALUES
('REF-EXAMPLE', 'Example referral partner', 'percent', 15, 'Example CPA Firm', 'partner@example.com', 20, NULL, TRUE)
ON CONFLICT (code) DO NOTHING;
COMMIT;

View file

@ -0,0 +1,62 @@
-- 004_admin_queue.sql
-- Admin users, order queue management, and audit log.
BEGIN;
-- Admin users (password stored as bcrypt hash)
CREATE TABLE IF NOT EXISTS admin_users (
id SERIAL PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
display_name TEXT,
email TEXT,
active BOOLEAN DEFAULT TRUE,
last_login_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT now()
);
-- Order audit log — tracks every state change and admin action
CREATE TABLE IF NOT EXISTS order_audit_log (
id SERIAL PRIMARY KEY,
-- Which order (supports both formation_orders and general orders)
order_type TEXT NOT NULL CHECK (order_type IN ('formation', 'service', 'quote')),
order_id INTEGER NOT NULL,
order_number TEXT,
-- What happened
action TEXT NOT NULL, -- 'status_change', 'note_added', 'manual_filing', 'document_uploaded', 'assigned', 'escalated', 'refunded'
from_status TEXT,
to_status TEXT,
-- Who did it
actor_type TEXT NOT NULL CHECK (actor_type IN ('system', 'admin', 'worker', 'customer')),
actor_id INTEGER, -- admin_users.id if actor_type = 'admin'
actor_name TEXT,
-- Details
note TEXT,
metadata JSONB, -- flexible: file paths, error details, automation output, etc.
-- Timestamp
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_audit_order ON order_audit_log(order_type, order_id);
CREATE INDEX IF NOT EXISTS idx_audit_action ON order_audit_log(action);
CREATE INDEX IF NOT EXISTS idx_audit_created ON order_audit_log(created_at DESC);
-- Add queue management columns to formation_orders
ALTER TABLE formation_orders ADD COLUMN IF NOT EXISTS assigned_to INTEGER REFERENCES admin_users(id);
ALTER TABLE formation_orders ADD COLUMN IF NOT EXISTS priority TEXT DEFAULT 'normal' CHECK (priority IN ('low', 'normal', 'high', 'urgent'));
ALTER TABLE formation_orders ADD COLUMN IF NOT EXISTS automation_status TEXT DEFAULT 'pending' CHECK (automation_status IN ('pending', 'running', 'succeeded', 'failed', 'manual'));
ALTER TABLE formation_orders ADD COLUMN IF NOT EXISTS automation_error TEXT;
ALTER TABLE formation_orders ADD COLUMN IF NOT EXISTS automation_attempts INTEGER DEFAULT 0;
ALTER TABLE formation_orders ADD COLUMN IF NOT EXISTS admin_notes TEXT;
ALTER TABLE formation_orders ADD COLUMN IF NOT EXISTS last_activity_at TIMESTAMPTZ DEFAULT now();
CREATE INDEX IF NOT EXISTS idx_formation_automation ON formation_orders(automation_status);
CREATE INDEX IF NOT EXISTS idx_formation_assigned ON formation_orders(assigned_to);
CREATE INDEX IF NOT EXISTS idx_formation_priority ON formation_orders(priority);
-- Add similar columns to general orders table
ALTER TABLE orders ADD COLUMN IF NOT EXISTS assigned_to INTEGER REFERENCES admin_users(id);
ALTER TABLE orders ADD COLUMN IF NOT EXISTS priority TEXT DEFAULT 'normal';
ALTER TABLE orders ADD COLUMN IF NOT EXISTS admin_notes TEXT;
COMMIT;

View file

@ -0,0 +1,42 @@
-- 005_attorney_review.sql
-- Attorney review add-on for compliance services (NOT formations).
-- Clients can optionally add attorney review to any compliance deliverable.
BEGIN;
-- Add attorney review fields to the general orders table (compliance services)
ALTER TABLE orders ADD COLUMN IF NOT EXISTS attorney_review_requested BOOLEAN DEFAULT FALSE;
ALTER TABLE orders ADD COLUMN IF NOT EXISTS attorney_review_status TEXT DEFAULT NULL
CHECK (attorney_review_status IS NULL OR attorney_review_status IN (
'pending', 'sent_to_attorney', 'in_review', 'completed', 'returned'
));
ALTER TABLE orders ADD COLUMN IF NOT EXISTS attorney_review_fee_cents INTEGER DEFAULT 0;
ALTER TABLE orders ADD COLUMN IF NOT EXISTS attorney_review_notes TEXT;
ALTER TABLE orders ADD COLUMN IF NOT EXISTS attorney_review_completed_at TIMESTAMPTZ;
-- Attorney review pricing per service type
CREATE TABLE IF NOT EXISTS attorney_review_pricing (
id SERIAL PRIMARY KEY,
service_slug TEXT NOT NULL UNIQUE,
review_fee INTEGER NOT NULL, -- cents: what client pays
attorney_cost INTEGER NOT NULL, -- cents: what we pay the attorney
description TEXT,
available BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT now()
);
INSERT INTO attorney_review_pricing (service_slug, review_fee, attorney_cost, description) VALUES
('flsa-audit', 69900, 45000, 'Attorney review of FLSA audit findings and remediation plan'),
('contractor-classification', 49900, 35000, 'Attorney legal opinion letter on contractor classification'),
('handbook-review', 69900, 50000, 'Attorney review of employee handbook compliance recommendations'),
('policy-development', 69900, 50000, 'Attorney review of custom workplace policies'),
('ccpa-audit', 69900, 45000, 'Attorney review of CCPA/CPRA audit findings'),
('privacy-policy', 39900, 30000, 'Attorney review of generated privacy policy'),
('data-mapping', 49900, 35000, 'Attorney review of data inventory and flow analysis'),
('breach-response', 69900, 45000, 'Attorney review of breach response plan'),
('consent-audit', 49900, 35000, 'Attorney review of TCPA consent practices assessment'),
('dnc-compliance', 39900, 25000, 'Attorney review of DNC compliance assessment'),
('campaign-review', 39900, 25000, 'Attorney review of marketing campaign compliance')
ON CONFLICT (service_slug) DO NOTHING;
COMMIT;

View file

@ -0,0 +1,47 @@
-- 006_relay_integration.sql
-- Relay Financial integration — filing payment tracking and commission payouts.
BEGIN;
-- Track every payment made via the Relay card to state portals
CREATE TABLE IF NOT EXISTS filing_payments (
id SERIAL PRIMARY KEY,
formation_order_id INTEGER REFERENCES formation_orders(id),
state_code CHAR(2) NOT NULL,
amount_cents INTEGER NOT NULL,
card_last4 TEXT NOT NULL, -- last 4 digits for reconciliation
portal_confirmation TEXT, -- confirmation number from state portal
relay_transaction_id TEXT, -- matched Relay/bank transaction (after reconciliation)
reconciled BOOLEAN DEFAULT FALSE,
reconciled_at TIMESTAMPTZ,
notes TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_filing_payments_order ON filing_payments(formation_order_id);
CREATE INDEX IF NOT EXISTS idx_filing_payments_reconciled ON filing_payments(reconciled);
-- Commission payouts to referral partners
CREATE TABLE IF NOT EXISTS commission_payouts (
id SERIAL PRIMARY KEY,
referral_partner TEXT NOT NULL,
partner_email TEXT NOT NULL,
amount_cents INTEGER NOT NULL,
period_start DATE, -- payout period
period_end DATE,
order_count INTEGER DEFAULT 0,
order_numbers TEXT[], -- array of order numbers included
status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'sent', 'confirmed')),
sent_via TEXT DEFAULT 'relay_ach', -- 'relay_ach', 'check', 'wire'
relay_transaction_id TEXT,
approved_by TEXT,
approved_at TIMESTAMPTZ,
sent_at TIMESTAMPTZ,
notes TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_commission_payouts_partner ON commission_payouts(referral_partner);
CREATE INDEX IF NOT EXISTS idx_commission_payouts_status ON commission_payouts(status);
COMMIT;

View file

@ -0,0 +1,78 @@
-- 007_refunds.sql
-- Refund tracking for failed filings that were not the customer's fault.
-- Covers: state portal errors, payment processing failures, name collisions
-- discovered after payment, state rejections, etc.
BEGIN;
CREATE TABLE IF NOT EXISTS refunds (
id SERIAL PRIMARY KEY,
refund_number TEXT UNIQUE NOT NULL, -- REF-2026-XXXXXX
-- What's being refunded
order_type TEXT NOT NULL CHECK (order_type IN ('formation', 'service')),
order_id INTEGER, -- FK to formation_orders.id or orders.id
order_number TEXT NOT NULL, -- PW-2026-XXXX or ORD-2026-XXXX
-- Who
customer_name TEXT NOT NULL,
customer_email TEXT NOT NULL,
-- Amount
original_amount_cents INTEGER NOT NULL, -- what was originally charged
refund_amount_cents INTEGER NOT NULL, -- what we're refunding (may be partial)
refund_type TEXT NOT NULL CHECK (refund_type IN ('full', 'partial', 'state_fee_only', 'service_fee_only')),
-- Why
reason_category TEXT NOT NULL CHECK (reason_category IN (
'state_portal_error', -- state portal down, rejected filing incorrectly
'payment_failed', -- card charged but filing didn't go through
'name_collision', -- name became unavailable between search and filing
'state_rejected', -- state rejected the filing after payment
'automation_error', -- our automation failed and couldn't recover
'duplicate_charge', -- accidentally charged twice
'customer_request', -- customer changed mind (discretionary)
'other'
)),
reason_detail TEXT, -- free-text explanation
-- State fee recovery
state_fee_recoverable BOOLEAN DEFAULT FALSE, -- can we get the state fee back?
state_fee_recovered BOOLEAN DEFAULT FALSE,
state_fee_recovery_notes TEXT,
-- Processing
status TEXT DEFAULT 'pending' CHECK (status IN (
'pending', -- refund requested, not yet approved
'approved', -- admin approved, ready to process
'processing', -- refund being sent via Relay
'sent', -- ACH/card refund sent
'confirmed', -- customer confirmed receipt
'denied', -- refund request denied (with reason)
'cancelled'
)),
-- Approval
requested_by TEXT, -- 'system', 'admin', 'customer'
requested_at TIMESTAMPTZ DEFAULT now(),
approved_by INTEGER, -- admin_users.id
approved_at TIMESTAMPTZ,
denied_reason TEXT,
-- Payment
refund_method TEXT CHECK (refund_method IN ('relay_ach', 'card_reversal', 'check', 'credit')),
relay_transaction_id TEXT,
sent_at TIMESTAMPTZ,
confirmed_at TIMESTAMPTZ,
-- Metadata
admin_notes TEXT,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_refunds_order ON refunds(order_type, order_id);
CREATE INDEX IF NOT EXISTS idx_refunds_status ON refunds(status);
CREATE INDEX IF NOT EXISTS idx_refunds_number ON refunds(refund_number);
CREATE INDEX IF NOT EXISTS idx_refunds_email ON refunds(customer_email);
-- Add refund tracking to formation_orders
ALTER TABLE formation_orders ADD COLUMN IF NOT EXISTS refund_id INTEGER REFERENCES refunds(id);
ALTER TABLE formation_orders ADD COLUMN IF NOT EXISTS refunded BOOLEAN DEFAULT FALSE;
-- Add refund tracking to general orders
ALTER TABLE orders ADD COLUMN IF NOT EXISTS refund_id INTEGER REFERENCES refunds(id);
ALTER TABLE orders ADD COLUMN IF NOT EXISTS refunded BOOLEAN DEFAULT FALSE;
COMMIT;

View file

@ -0,0 +1,125 @@
-- 008_bundles.sql
-- Service bundles — 20% off when purchasing all services in a category
-- or mix-and-match across categories.
BEGIN;
CREATE TABLE IF NOT EXISTS service_bundles (
id SERIAL PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
description TEXT,
-- Which services are included (array of service slugs)
services TEXT[] NOT NULL,
-- Pricing
discount_pct INTEGER NOT NULL DEFAULT 20, -- 20% off
-- Category: 'employment', 'privacy', 'tcpa', 'telecom', 'corporate', 'multi'
category TEXT NOT NULL,
-- Display
active BOOLEAN DEFAULT TRUE,
display_order INTEGER DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT now()
);
-- Category bundles (all services in one category)
INSERT INTO service_bundles (slug, name, description, services, discount_pct, category, display_order) VALUES
(
'employment-complete',
'Employment Compliance Bundle',
'Complete employment compliance coverage: FLSA audit, contractor classification (up to 5 workers), employee handbook review, and workplace policy development.',
ARRAY['flsa-audit', 'contractor-classification', 'handbook-review', 'policy-development'],
20,
'employment',
1
),
(
'privacy-complete',
'Data Privacy Bundle',
'Full privacy compliance: CCPA/CPRA audit, privacy policy generation, data mapping & inventory, and breach response planning.',
ARRAY['ccpa-audit', 'privacy-policy', 'data-mapping', 'breach-response'],
20,
'privacy',
2
),
(
'tcpa-complete',
'TCPA Compliance Bundle',
'Complete TCPA/marketing compliance: SMS/call consent audit, DNC list compliance review, and marketing campaign review.',
ARRAY['consent-audit', 'dnc-compliance', 'campaign-review'],
20,
'tcpa',
3
),
(
'telecom-complete',
'Telecom Compliance Bundle',
'Full telecom regulatory compliance: FCC 499A filing, STIR/SHAKEN implementation, IPES & ISP registrations, telecom database management, and state PUC/PSC filings.',
ARRAY['fcc-499a', 'stir-shaken', 'ipes-isp', 'database-management', 'state-puc'],
20,
'telecom',
4
),
(
'corporate-complete',
'Corporate Services Bundle',
'Complete business setup: business formation, state registration, annual report filing, and registered agent service.',
ARRAY['formation', 'state-registration', 'annual-reports', 'registered-agent'],
20,
'corporate',
5
),
-- Cross-category bundles
(
'startup-essentials',
'Startup Essentials Bundle',
'Everything a new business needs: business formation + contractor classification (up to 3 workers) + privacy policy + employee handbook review.',
ARRAY['formation', 'contractor-classification', 'privacy-policy', 'handbook-review'],
20,
'multi',
10
),
(
'compliance-360',
'Compliance 360 Bundle',
'Comprehensive multi-domain compliance: FLSA audit + contractor classification (up to 10 workers) + handbook review + CCPA audit + consent audit. The most complete compliance package available.',
ARRAY['flsa-audit', 'contractor-classification', 'handbook-review', 'ccpa-audit', 'consent-audit'],
20,
'multi',
11
)
ON CONFLICT (slug) DO NOTHING;
-- Track bundle orders
CREATE TABLE IF NOT EXISTS bundle_orders (
id SERIAL PRIMARY KEY,
bundle_slug TEXT NOT NULL REFERENCES service_bundles(slug),
order_number TEXT UNIQUE NOT NULL,
customer_name TEXT NOT NULL,
customer_email TEXT NOT NULL,
customer_phone TEXT,
customer_company TEXT,
-- Pricing
original_total_cents INTEGER NOT NULL, -- sum of individual service prices
discount_pct INTEGER NOT NULL,
discount_cents INTEGER NOT NULL,
final_total_cents INTEGER NOT NULL,
-- Add-ons
attorney_review BOOLEAN DEFAULT FALSE,
attorney_review_fee_cents INTEGER DEFAULT 0,
discount_code TEXT,
discount_code_cents INTEGER DEFAULT 0,
-- Status
status TEXT DEFAULT 'received' CHECK (status IN (
'received', 'processing', 'review', 'delivered', 'cancelled'
)),
-- Individual service order IDs (populated as each service is fulfilled)
service_order_ids INTEGER[],
notes TEXT,
created_at TIMESTAMPTZ DEFAULT now(),
delivered_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_bundle_orders_status ON bundle_orders(status);
CREATE INDEX IF NOT EXISTS idx_bundle_orders_email ON bundle_orders(customer_email);
COMMIT;

View file

@ -0,0 +1,48 @@
-- 009: Entity cache — stores bulk-downloaded business entity records
-- from state Secretary of State open data portals.
-- Used by Verilex Data Professional API via internal bulk export endpoint.
CREATE TABLE IF NOT EXISTS entity_cache (
id SERIAL PRIMARY KEY,
jurisdiction TEXT NOT NULL, -- US_CO, US_NY, etc.
entity_name TEXT NOT NULL,
entity_number TEXT, -- state filing number
entity_type TEXT, -- LLC, CORPORATION, LP, LLP, etc.
status TEXT, -- ACTIVE, DISSOLVED, SUSPENDED, DELINQUENT
formation_date DATE,
dissolution_date DATE,
registered_agent TEXT,
principal_address TEXT,
state TEXT NOT NULL, -- 2-letter
source TEXT DEFAULT 'socrata', -- socrata, sftp, csv, playwright
raw_data JSONB, -- original source record for debugging
last_synced TIMESTAMPTZ DEFAULT NOW(),
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(jurisdiction, entity_number)
);
CREATE INDEX IF NOT EXISTS idx_ec_state ON entity_cache (state);
CREATE INDEX IF NOT EXISTS idx_ec_name ON entity_cache USING gin (entity_name gin_trgm_ops);
CREATE INDEX IF NOT EXISTS idx_ec_number ON entity_cache (entity_number);
CREATE INDEX IF NOT EXISTS idx_ec_status ON entity_cache (status);
CREATE INDEX IF NOT EXISTS idx_ec_synced ON entity_cache (last_synced);
-- Name search cache — stores results of live name availability searches
-- to avoid hammering state portals. Entries expire after 24 hours.
CREATE TABLE IF NOT EXISTS name_search_cache (
id SERIAL PRIMARY KEY,
state_code CHAR(2) NOT NULL,
searched_name TEXT NOT NULL,
available BOOLEAN,
exact_match BOOLEAN DEFAULT FALSE,
similar_names TEXT[],
raw_response TEXT,
searched_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ DEFAULT NOW() + INTERVAL '24 hours',
UNIQUE(state_code, searched_name)
);
CREATE INDEX IF NOT EXISTS idx_nsc_lookup ON name_search_cache (state_code, searched_name) WHERE expires_at > NOW();
-- Enable trigram extension if not already enabled
CREATE EXTENSION IF NOT EXISTS pg_trgm;

View file

@ -0,0 +1,25 @@
-- 009_id_upload.sql
-- One-time upload tokens for ID verification (Anytime Mailbox registration).
-- Supports both desktop file upload and mobile QR code → phone camera upload.
BEGIN;
CREATE TABLE IF NOT EXISTS id_upload_tokens (
id SERIAL PRIMARY KEY,
token TEXT UNIQUE NOT NULL,
order_type TEXT DEFAULT 'canada_crtc',
order_reference TEXT,
customer_email TEXT NOT NULL,
customer_name TEXT,
front_uploaded BOOLEAN DEFAULT FALSE,
back_uploaded BOOLEAN DEFAULT FALSE,
minio_paths JSONB DEFAULT '{}',
expires_at TIMESTAMPTZ NOT NULL,
used BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_id_tokens_token ON id_upload_tokens(token);
CREATE INDEX IF NOT EXISTS idx_id_tokens_expires ON id_upload_tokens(expires_at);
COMMIT;

View file

@ -0,0 +1,71 @@
-- 010_canada_crtc.sql
-- Canadian CRTC Telecom Carrier Package orders.
BEGIN;
CREATE TABLE IF NOT EXISTS canada_crtc_orders (
id SERIAL PRIMARY KEY,
order_number TEXT UNIQUE NOT NULL,
-- Customer
customer_name TEXT NOT NULL,
customer_email TEXT NOT NULL,
customer_phone TEXT,
customer_company TEXT,
-- Corporation details
company_type TEXT NOT NULL CHECK (company_type IN ('numbered', 'named')),
company_name_choice1 TEXT,
company_name_choice2 TEXT,
company_name_choice3 TEXT,
company_name_final TEXT,
bc_incorporation_number TEXT,
-- Director
director_name TEXT NOT NULL,
director_address TEXT NOT NULL,
director_citizenship TEXT,
-- Mailbox
mailbox_unit_number TEXT,
mailbox_address TEXT DEFAULT '329 Howe St, Vancouver, BC V6C 3N2',
mailbox_account_email TEXT,
-- Telecom details
services_description TEXT NOT NULL,
geographic_coverage TEXT DEFAULT 'Canada-wide',
include_bits BOOLEAN DEFAULT TRUE,
regulatory_contact_name TEXT,
regulatory_contact_email TEXT,
regulatory_contact_phone TEXT,
-- ID upload
id_upload_token TEXT,
id_verified BOOLEAN DEFAULT FALSE,
-- Pricing
service_fee_cents INTEGER DEFAULT 289900,
government_fee_cents INTEGER DEFAULT 38000,
discount_code TEXT,
discount_cents INTEGER DEFAULT 0,
total_cents INTEGER NOT NULL,
-- Fulfillment status
status TEXT DEFAULT 'received' CHECK (status IN (
'received', 'mailbox_setup', 'name_reservation', 'incorporation',
'crtc_letter', 'review', 'delivered', 'cancelled'
)),
automation_status TEXT DEFAULT 'pending',
automation_error TEXT,
-- Binder
binder_generated BOOLEAN DEFAULT FALSE,
binder_minio_path TEXT,
binder_shipped BOOLEAN DEFAULT FALSE,
binder_tracking_number TEXT,
binder_shipped_at TIMESTAMPTZ,
-- Subscription
subscription_id TEXT,
next_renewal_date DATE,
-- Timestamps
admin_notes TEXT,
created_at TIMESTAMPTZ DEFAULT now(),
delivered_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_crtc_orders_status ON canada_crtc_orders(status);
CREATE INDEX IF NOT EXISTS idx_crtc_orders_email ON canada_crtc_orders(customer_email);
CREATE INDEX IF NOT EXISTS idx_crtc_orders_number ON canada_crtc_orders(order_number);
COMMIT;

View file

@ -0,0 +1,105 @@
-- 011_sales_agents.sql
-- Sales agent referral system with commission tracking.
-- Agents get auto-generated REF-XXXXX codes. Clients get 5% off service fee.
-- Commissions paid 14 days after order delivery.
BEGIN;
CREATE TABLE IF NOT EXISTS sales_agents (
id SERIAL PRIMARY KEY,
agent_code TEXT UNIQUE NOT NULL, -- REF-XXXXX (auto-generated)
discount_code_id INTEGER REFERENCES discount_codes(id),
-- Agent info
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
phone TEXT,
company TEXT,
-- ERPNext user link
erpnext_user TEXT, -- ERPNext username for portal login
-- Payout info (for Relay ACH)
bank_name TEXT,
bank_routing TEXT,
bank_account_last4 TEXT, -- last 4 only in plain text
bank_account_encrypted TEXT, -- full account in ERPNext Sensitive ID
payout_email TEXT, -- PayPal/e-transfer email alternative
-- Commission config (defaults, overridable per agent)
commission_type TEXT DEFAULT 'flat', -- 'flat' or 'percent'
commission_default_cents INTEGER DEFAULT 30000, -- $300 default for Canada CRTC
commission_pct INTEGER DEFAULT 10, -- 10% for compliance services
-- Per-service commission overrides (JSON)
-- e.g., {"canada-crtc": 30000, "formation-basic": 5000, "formation-complete": 5000, "bundle": 10000}
commission_overrides JSONB DEFAULT '{}',
-- Stats (denormalized for fast queries)
total_referrals INTEGER DEFAULT 0,
total_earned_cents INTEGER DEFAULT 0,
total_paid_cents INTEGER DEFAULT 0,
total_pending_cents INTEGER DEFAULT 0,
-- Status
active BOOLEAN DEFAULT TRUE,
onboarded_at TIMESTAMPTZ,
notes TEXT,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_agents_code ON sales_agents(agent_code);
CREATE INDEX IF NOT EXISTS idx_agents_email ON sales_agents(email);
CREATE INDEX IF NOT EXISTS idx_agents_active ON sales_agents(active);
-- Commission ledger — one row per earned commission
CREATE TABLE IF NOT EXISTS commission_ledger (
id SERIAL PRIMARY KEY,
agent_id INTEGER NOT NULL REFERENCES sales_agents(id),
agent_code TEXT NOT NULL,
-- What was sold
order_type TEXT NOT NULL, -- 'canada_crtc', 'formation', 'service', 'bundle'
order_id INTEGER,
order_number TEXT NOT NULL,
service_slug TEXT,
customer_name TEXT NOT NULL,
customer_email TEXT NOT NULL,
-- Money
order_amount_cents INTEGER NOT NULL, -- what the client paid (total)
discount_cents INTEGER DEFAULT 0, -- 5% discount given to client
commission_cents INTEGER NOT NULL, -- what the agent earns
-- Lifecycle
-- pending: order placed, not yet delivered
-- eligible: order delivered + 14 day holdback passed
-- approved: admin reviewed and approved for payout
-- processing: payout being sent via Relay
-- paid: money sent to agent
-- cancelled: order was cancelled/refunded, commission voided
status TEXT DEFAULT 'pending' CHECK (status IN (
'pending', 'eligible', 'approved', 'processing', 'paid', 'cancelled'
)),
-- Dates
order_delivered_at TIMESTAMPTZ,
eligible_at TIMESTAMPTZ, -- delivered_at + 14 days
approved_by INTEGER, -- admin_users.id
approved_at TIMESTAMPTZ,
paid_at TIMESTAMPTZ,
payment_method TEXT, -- 'relay_ach', 'paypal', 'check'
payment_reference TEXT, -- transaction ID or check number
notes TEXT,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_commission_agent ON commission_ledger(agent_id);
CREATE INDEX IF NOT EXISTS idx_commission_status ON commission_ledger(status);
CREATE INDEX IF NOT EXISTS idx_commission_eligible ON commission_ledger(eligible_at);
CREATE INDEX IF NOT EXISTS idx_commission_order ON commission_ledger(order_type, order_id);
COMMIT;

View file

@ -0,0 +1,93 @@
-- 012_accounting_support.sql
-- Accounting support system: freelance accountants, per-client time tracking,
-- first 3 hours free, $75/hr after, conversation monitoring for bypass attempts.
BEGIN;
-- Freelance accounting advisors
CREATE TABLE IF NOT EXISTS accounting_advisors (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
phone TEXT,
specialties JSONB DEFAULT '[]', -- ["tax", "bookkeeping", "GST/HST", "payroll", "T2"]
erpnext_user TEXT, -- ERPNext username for portal login
hourly_rate_cents INTEGER DEFAULT 7500, -- what we charge clients ($75/hr)
our_cost_cents INTEGER DEFAULT 0, -- what we pay the freelancer (admin only)
active BOOLEAN DEFAULT TRUE,
notes TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
-- Per-client accounting support entitlement
CREATE TABLE IF NOT EXISTS accounting_support_accounts (
id SERIAL PRIMARY KEY,
client_email TEXT NOT NULL,
client_name TEXT,
order_number TEXT NOT NULL, -- linked Canada CRTC order
order_type TEXT DEFAULT 'canada_crtc',
-- Hours tracking
free_hours_total NUMERIC(5,2) DEFAULT 3.00, -- 3 hours free
free_hours_used NUMERIC(5,2) DEFAULT 0.00,
billable_hours_used NUMERIC(5,2) DEFAULT 0.00,
total_billed_cents INTEGER DEFAULT 0,
-- Assigned advisor
assigned_advisor_id INTEGER REFERENCES accounting_advisors(id),
-- Access control (client grants/revokes)
access_granted BOOLEAN DEFAULT FALSE,
access_granted_at TIMESTAMPTZ,
access_revoked_at TIMESTAMPTZ,
-- Metadata
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(client_email, order_number)
);
CREATE INDEX IF NOT EXISTS idx_acct_support_client ON accounting_support_accounts(client_email);
CREATE INDEX IF NOT EXISTS idx_acct_support_order ON accounting_support_accounts(order_number);
-- Time entries per issue
CREATE TABLE IF NOT EXISTS accounting_time_entries (
id SERIAL PRIMARY KEY,
support_account_id INTEGER NOT NULL REFERENCES accounting_support_accounts(id),
advisor_id INTEGER NOT NULL REFERENCES accounting_advisors(id),
issue_reference TEXT NOT NULL, -- ERPNext Issue name
-- Time
hours NUMERIC(5,2) NOT NULL,
description TEXT,
-- Billing split
complimentary_hours NUMERIC(5,2) DEFAULT 0, -- portion covered by free hours
billable_hours NUMERIC(5,2) DEFAULT 0, -- portion billed at $75/hr
billable_amount_cents INTEGER DEFAULT 0,
-- Invoice
invoiced BOOLEAN DEFAULT FALSE,
invoice_reference TEXT, -- ERPNext Sales Invoice name
-- Metadata
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_time_entries_account ON accounting_time_entries(support_account_id);
CREATE INDEX IF NOT EXISTS idx_time_entries_advisor ON accounting_time_entries(advisor_id);
-- Conversation monitoring flags
CREATE TABLE IF NOT EXISTS conversation_flags (
id SERIAL PRIMARY KEY,
issue_reference TEXT NOT NULL,
flagged_user TEXT NOT NULL, -- email of who sent the flagged message
user_type TEXT NOT NULL CHECK (user_type IN ('client', 'advisor')),
client_email TEXT NOT NULL,
advisor_email TEXT NOT NULL,
flagged_pattern TEXT NOT NULL, -- which regex pattern matched
flagged_text TEXT NOT NULL, -- the flagged portion of the message (truncated)
flag_count_for_pair INTEGER DEFAULT 1, -- running count for this client-advisor pair
warning_sent BOOLEAN DEFAULT TRUE,
admin_alerted BOOLEAN DEFAULT TRUE,
admin_reviewed BOOLEAN DEFAULT FALSE,
admin_action TEXT CHECK (admin_action IN ('dismissed', 'warned', 'escalated', 'advisor_removed', NULL)),
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_flags_issue ON conversation_flags(issue_reference);
CREATE INDEX IF NOT EXISTS idx_flags_pair ON conversation_flags(client_email, advisor_email);
COMMIT;

View file

@ -0,0 +1,64 @@
-- 013_payment_surcharges.sql
-- Payment method surcharge tracking.
-- Phase 1 (Stripe): ACH 0%, Card 3%, Klarna 4.5%, Crypto 0%. No PayPal at launch.
-- Phase 2 (Adyen): Klarna drops to ~4.3% effective; PayPal added via Adyen.
BEGIN;
-- Add payment method and surcharge columns to all order tables
ALTER TABLE formation_orders ADD COLUMN IF NOT EXISTS payment_method TEXT CHECK (payment_method IN ('ach', 'card', 'klarna', 'crypto'));
ALTER TABLE formation_orders ADD COLUMN IF NOT EXISTS surcharge_pct NUMERIC(5,2) DEFAULT 0;
ALTER TABLE formation_orders ADD COLUMN IF NOT EXISTS surcharge_cents INTEGER DEFAULT 0;
ALTER TABLE formation_orders ADD COLUMN IF NOT EXISTS stripe_payment_id TEXT;
ALTER TABLE formation_orders ADD COLUMN IF NOT EXISTS stripe_session_id TEXT;
ALTER TABLE canada_crtc_orders ADD COLUMN IF NOT EXISTS payment_method TEXT CHECK (payment_method IN ('ach', 'card', 'klarna', 'crypto'));
ALTER TABLE canada_crtc_orders ADD COLUMN IF NOT EXISTS surcharge_pct NUMERIC(5,2) DEFAULT 0;
ALTER TABLE canada_crtc_orders ADD COLUMN IF NOT EXISTS surcharge_cents INTEGER DEFAULT 0;
ALTER TABLE canada_crtc_orders ADD COLUMN IF NOT EXISTS stripe_payment_id TEXT;
ALTER TABLE canada_crtc_orders ADD COLUMN IF NOT EXISTS stripe_session_id TEXT;
ALTER TABLE canada_crtc_orders ADD COLUMN IF NOT EXISTS payment_status TEXT DEFAULT 'pending_payment';
ALTER TABLE orders ADD COLUMN IF NOT EXISTS payment_method TEXT CHECK (payment_method IN ('ach', 'card', 'klarna', 'crypto'));
ALTER TABLE orders ADD COLUMN IF NOT EXISTS surcharge_pct NUMERIC(5,2) DEFAULT 0;
ALTER TABLE orders ADD COLUMN IF NOT EXISTS surcharge_cents INTEGER DEFAULT 0;
ALTER TABLE orders ADD COLUMN IF NOT EXISTS stripe_payment_id TEXT;
ALTER TABLE orders ADD COLUMN IF NOT EXISTS stripe_session_id TEXT;
ALTER TABLE bundle_orders ADD COLUMN IF NOT EXISTS payment_method TEXT CHECK (payment_method IN ('ach', 'card', 'klarna', 'crypto'));
ALTER TABLE bundle_orders ADD COLUMN IF NOT EXISTS surcharge_pct NUMERIC(5,2) DEFAULT 0;
ALTER TABLE bundle_orders ADD COLUMN IF NOT EXISTS surcharge_cents INTEGER DEFAULT 0;
ALTER TABLE bundle_orders ADD COLUMN IF NOT EXISTS stripe_payment_id TEXT;
ALTER TABLE bundle_orders ADD COLUMN IF NOT EXISTS stripe_session_id TEXT;
-- Payment surcharge configuration (editable by admin)
CREATE TABLE IF NOT EXISTS payment_surcharges (
id SERIAL PRIMARY KEY,
method TEXT NOT NULL UNIQUE,
label TEXT NOT NULL,
surcharge_pct NUMERIC(5,2) NOT NULL DEFAULT 0,
-- Stripe's actual cost so we can track margin
processor_pct NUMERIC(5,2) NOT NULL DEFAULT 0,
processor_cents INTEGER NOT NULL DEFAULT 0, -- fixed fee component in cents
description TEXT,
active BOOLEAN DEFAULT TRUE,
display_order INTEGER DEFAULT 0
);
INSERT INTO payment_surcharges (method, label, surcharge_pct, processor_pct, processor_cents, description, display_order) VALUES
('ach', 'Bank Transfer (ACH) via Stripe Link', 0.00, 0.80, 0, 'No surcharge. We absorb the ACH fee (0.8%, capped at $5).', 1),
('card', 'Credit or Debit Card', 3.00, 2.90, 30, '3% processing surcharge added at checkout.', 2),
('klarna', 'Klarna — Pay in 4 or Monthly', 4.50, 5.99, 30, '4.5% surcharge. Split into 4 interest-free payments or monthly financing.', 3),
('crypto', 'Cryptocurrency (BTC, ETH, USDC)', 0.00, 0.00, 0, 'No processing fee. Paid via BTCPay Server.', 4)
ON CONFLICT (method) DO UPDATE SET
label = EXCLUDED.label,
surcharge_pct = EXCLUDED.surcharge_pct,
processor_pct = EXCLUDED.processor_pct,
processor_cents = EXCLUDED.processor_cents,
description = EXCLUDED.description,
display_order = EXCLUDED.display_order;
-- Remove stale PayPal rows if upgrading from earlier schema
DELETE FROM payment_surcharges WHERE method IN ('paypal', 'paypal_bnpl');
COMMIT;

View file

@ -0,0 +1,62 @@
-- 014_relay_deposits.sql
-- Relay deposit detection and filing fee reservation system.
--
-- Flow:
-- relay_deposit_monitor.py watches relay-deposits@performancewest.net via IMAP.
-- When Relay emails "You received $X from FID BKG SVC LLC" (Stripe payout),
-- a row is inserted into relay_deposits.
-- process_pending_filings() then checks filing_fee_reservations to compute
-- available balance and advances FIFO-ordered "Awaiting Funds" orders.
BEGIN;
-- Relay deposit notifications parsed from email
CREATE TABLE IF NOT EXISTS relay_deposits (
id SERIAL PRIMARY KEY,
amount_cents INTEGER NOT NULL, -- deposited amount in cents
sender_name TEXT NOT NULL, -- e.g. "FID BKG SVC LLC" for Stripe
source TEXT NOT NULL DEFAULT 'stripe', -- 'stripe' | 'other'
email_uid TEXT NOT NULL UNIQUE, -- IMAP UID to prevent duplicate processing
email_subject TEXT,
detected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
processed BOOLEAN NOT NULL DEFAULT FALSE,
processed_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_relay_deposits_unprocessed
ON relay_deposits (detected_at)
WHERE processed = FALSE;
-- Filing fee reservations — tracks which deposits are committed to which orders
CREATE TABLE IF NOT EXISTS filing_fee_reservations (
id SERIAL PRIMARY KEY,
order_id TEXT NOT NULL, -- e.g. "CRTC-2026-0001" or "FO-2026-0042"
order_type TEXT NOT NULL, -- 'canada_crtc' | 'formation'
amount_cents INTEGER NOT NULL, -- filing fee reserved (BC ~C$350 → USD, US state fee)
-- Status lifecycle: pending → reserved → spent | released
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'reserved', 'spent', 'released')),
relay_deposit_id INTEGER REFERENCES relay_deposits(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
reserved_at TIMESTAMPTZ, -- when workflow advanced to Filing/Incorporation
spent_at TIMESTAMPTZ, -- when Playwright confirmed card charged
released_at TIMESTAMPTZ, -- if filing failed → funds returned to pool
notes TEXT
);
CREATE INDEX IF NOT EXISTS idx_filing_fee_reservations_status
ON filing_fee_reservations (status, created_at);
CREATE INDEX IF NOT EXISTS idx_filing_fee_reservations_order
ON filing_fee_reservations (order_id);
-- Helper view: current available balance in Relay filing account
-- available = sum(deposits) - sum(reserved + spent reservations)
CREATE OR REPLACE VIEW relay_available_balance AS
SELECT
COALESCE((SELECT SUM(amount_cents) FROM relay_deposits), 0)
- COALESCE((SELECT SUM(amount_cents) FROM filing_fee_reservations
WHERE status IN ('reserved', 'spent')), 0)
AS available_cents;
COMMIT;

View file

@ -0,0 +1,46 @@
-- 015_job_queue.sql
-- Persistent job queue for portal automation with retry and deferral.
--
-- Replaces the fire-and-forget in-memory job dispatch.
-- A scheduler thread in job_server.py polls this table every 60s.
--
-- Portal hours awareness:
-- BC Corporate Online: Mon-Sat 06:00-22:00 PT, Sun 13:00-22:00 PT
-- IRS EIN Assistant: Mon-Fri 07:00-22:00 ET
-- All others: 24/7 (no deferral needed)
--
-- Retry policy: exponential backoff, 5 attempts max, then escalate to admin.
BEGIN;
CREATE TABLE IF NOT EXISTS job_queue (
id SERIAL PRIMARY KEY,
job_type TEXT NOT NULL, -- 'file_entity' | 'obtain_ein' | 'name_search' | 'process_crtc' | 'deliver'
payload JSONB NOT NULL, -- order details, state_code, order_name, etc.
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'running', 'completed', 'failed', 'deferred', 'cancelled')),
attempts INTEGER NOT NULL DEFAULT 0,
max_attempts INTEGER NOT NULL DEFAULT 5,
run_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- next eligible run time
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
last_error TEXT,
-- Portal context
portal_tz TEXT, -- e.g. 'America/Vancouver' for BC jobs
portal_hours JSONB, -- {mon:[6,22], tue:[6,22], ..., sun:[13,22]}
-- Linkage
order_id TEXT, -- denormalized for easy lookup
order_type TEXT, -- 'canada_crtc' | 'formation'
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Scheduler polls this index every 60s for due, runnable jobs
CREATE INDEX IF NOT EXISTS idx_job_queue_runnable
ON job_queue (run_at, created_at)
WHERE status IN ('pending', 'deferred');
CREATE INDEX IF NOT EXISTS idx_job_queue_order
ON job_queue (order_id)
WHERE status NOT IN ('completed', 'cancelled');
COMMIT;

View file

@ -0,0 +1,51 @@
-- 016_sanctions_screenings.sql
-- Audit log for all CASL (Consolidated Canadian Autonomous Sanctions List) screenings.
--
-- Every director name submitted on a CRTC order is screened before the order is
-- accepted and before any payment is collected. Results are logged here for audit
-- and regulatory purposes.
--
-- The CASL list is maintained by Global Affairs Canada under the Special Economic
-- Measures Act (SEMA) and Justice for Victims of Corrupt Foreign Officials Act (JVCFOA).
-- Source: https://www.international.gc.ca/world-monde/assets/office_docs/
-- international_relations-relations_internationales/sanctions/sema-lmes.xml
BEGIN;
CREATE TABLE IF NOT EXISTS sanctions_screenings (
id SERIAL PRIMARY KEY,
-- Who was screened
screened_name TEXT NOT NULL, -- exact name as entered by customer
order_number TEXT, -- linked order (null if pre-check before order created)
order_type TEXT DEFAULT 'canada_crtc',
-- Result
result TEXT NOT NULL -- 'clear' | 'hit' | 'possible_match' | 'error'
CHECK (result IN ('clear', 'hit', 'possible_match', 'error')),
match_score NUMERIC(5,2), -- 0-100 fuzzy match score (null if clear)
matched_entry JSONB, -- the CASL record that matched {last_name, given_name, country, schedule}
-- List metadata
list_date DATE, -- date the CASL list was last updated (from XML)
list_url TEXT DEFAULT 'https://www.international.gc.ca/world-monde/assets/office_docs/international_relations-relations_internationales/sanctions/sema-lmes.xml',
-- Admin
reviewed_by TEXT, -- admin user who reviewed a possible_match (null if auto-cleared)
review_notes TEXT,
reviewed_at TIMESTAMPTZ,
override BOOLEAN DEFAULT FALSE, -- admin manually cleared a possible_match
-- Timestamps
screened_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
ip_address TEXT,
user_agent TEXT
);
CREATE INDEX IF NOT EXISTS idx_sanctions_order ON sanctions_screenings (order_number);
CREATE INDEX IF NOT EXISTS idx_sanctions_result ON sanctions_screenings (result, screened_at);
CREATE INDEX IF NOT EXISTS idx_sanctions_name ON sanctions_screenings (screened_name);
-- View: recent hits and possible matches requiring admin review
CREATE OR REPLACE VIEW sanctions_pending_review AS
SELECT * FROM sanctions_screenings
WHERE result IN ('hit', 'possible_match')
AND override = FALSE
ORDER BY screened_at DESC;
COMMIT;

View file

@ -0,0 +1,104 @@
-- 017_identity_verifications.sql
-- Stripe Identity verification sessions for director KYC.
--
-- Every CRTC order director is verified via Stripe Identity BEFORE the order is
-- saved and before any payment is collected. The verification session extracts
-- name and date of birth from the ID document, which are compared against what
-- the customer entered on the order form.
--
-- Applies to ALL payment methods (card, ACH, Klarna, crypto).
--
-- Match result tiers:
-- name_match: exact | fuzzy_pass | fuzzy_warn | mismatch
-- dob_match: exact | no_dob_on_id | mismatch
-- overall: verified | pending | needs_review | failed
--
-- 'needs_review' orders are held in a manual queue — payment is NOT collected
-- until an admin clears them. This is a hard gate.
BEGIN;
CREATE TABLE IF NOT EXISTS identity_verifications (
id SERIAL PRIMARY KEY,
-- Stripe Identity
stripe_session_id TEXT NOT NULL UNIQUE,
stripe_report_id TEXT, -- verification_report id once complete
stripe_status TEXT, -- 'requires_input' | 'processing' | 'verified' | 'canceled'
-- What customer typed on the form
form_director_name TEXT NOT NULL,
form_director_dob DATE, -- null if customer didn't enter DOB
-- What Stripe extracted from the ID document
id_first_name TEXT,
id_last_name TEXT,
id_full_name_extracted TEXT, -- first + last concatenated
id_dob_year INTEGER,
id_dob_month INTEGER,
id_dob_day INTEGER,
id_doc_type TEXT, -- 'driving_license' | 'passport' | 'id_card'
id_issuing_country TEXT,
id_expiry_year INTEGER,
id_expiry_month INTEGER,
id_expiry_day INTEGER,
id_number TEXT, -- redacted after comparison
-- Comparison results
name_match_score NUMERIC(5,2), -- 0-100 fuzzy score
name_match TEXT -- 'exact' | 'fuzzy_pass' | 'fuzzy_warn' | 'mismatch' | 'pending'
CHECK (name_match IN ('exact','fuzzy_pass','fuzzy_warn','mismatch','pending')),
dob_match TEXT -- 'exact' | 'no_dob_on_id' | 'mismatch' | 'pending'
CHECK (dob_match IN ('exact','no_dob_on_id','mismatch','pending')),
doc_expired BOOLEAN DEFAULT FALSE,
-- Overall gate result
overall_result TEXT NOT NULL DEFAULT 'pending'
CHECK (overall_result IN ('pending','verified','needs_review','failed')),
-- Admin review
reviewed_by TEXT,
review_notes TEXT,
reviewed_at TIMESTAMPTZ,
admin_override BOOLEAN DEFAULT FALSE, -- admin manually cleared needs_review
-- Linkage
order_number TEXT, -- set once order is created
order_type TEXT DEFAULT 'canada_crtc',
customer_email TEXT,
-- Audit
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
verified_at TIMESTAMPTZ,
ip_address TEXT,
user_agent TEXT
);
CREATE INDEX IF NOT EXISTS idx_iv_session ON identity_verifications (stripe_session_id);
CREATE INDEX IF NOT EXISTS idx_iv_order ON identity_verifications (order_number);
CREATE INDEX IF NOT EXISTS idx_iv_result ON identity_verifications (overall_result, created_at);
CREATE INDEX IF NOT EXISTS idx_iv_needs_review ON identity_verifications (overall_result)
WHERE overall_result = 'needs_review' AND admin_override = FALSE;
-- Add identity_session_id to CRTC orders so the order route can gate on it
ALTER TABLE canada_crtc_orders
ADD COLUMN IF NOT EXISTS identity_session_id TEXT
REFERENCES identity_verifications(stripe_session_id) ON DELETE SET NULL;
ALTER TABLE canada_crtc_orders
ADD COLUMN IF NOT EXISTS identity_result TEXT
CHECK (identity_result IN ('verified','needs_review','failed','pending'));
-- Admin view: sessions awaiting review
CREATE OR REPLACE VIEW identity_pending_review AS
SELECT
iv.*,
o.order_number AS linked_order,
o.customer_email AS order_email
FROM identity_verifications iv
LEFT JOIN canada_crtc_orders o ON o.identity_session_id = iv.stripe_session_id
WHERE iv.overall_result = 'needs_review'
AND iv.admin_override = FALSE
ORDER BY iv.created_at DESC;
COMMIT;

View file

@ -0,0 +1,95 @@
-- Migration 018: Customer portal auth + address/director book
-- Magic-link authentication — no passwords.
-- Customers are auto-created on first order; they can later log in with their email.
BEGIN;
-- ── Customers ─────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS customers (
id SERIAL PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
name TEXT,
phone TEXT,
company TEXT,
erpnext_id TEXT, -- ERPNext Customer docname (synced after first order)
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_customers_email ON customers (email);
-- ── Customer sessions (magic-link tokens + JWT refresh) ────────────────────────
CREATE TABLE IF NOT EXISTS customer_sessions (
id SERIAL PRIMARY KEY,
customer_id INT NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE, -- opaque random token (magic link or session)
token_type TEXT NOT NULL DEFAULT 'magic', -- 'magic' | 'session'
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ, -- magic tokens are single-use
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
ip_address TEXT,
user_agent TEXT
);
CREATE INDEX IF NOT EXISTS idx_customer_sessions_token ON customer_sessions (token);
CREATE INDEX IF NOT EXISTS idx_customer_sessions_customer ON customer_sessions (customer_id);
-- ── Saved addresses ───────────────────────────────────────────────────────────
-- Populated from completed orders. Customers can pick from these on new orders.
CREATE TABLE IF NOT EXISTS customer_addresses (
id SERIAL PRIMARY KEY,
customer_id INT NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
label TEXT, -- e.g. "Home", "Office", auto-generated
street TEXT NOT NULL,
street2 TEXT,
city TEXT NOT NULL,
province TEXT,
postal TEXT,
country TEXT NOT NULL,
is_default BOOLEAN NOT NULL DEFAULT FALSE,
source_order TEXT, -- order_number this was first seen on
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_customer_addresses_customer ON customer_addresses (customer_id);
-- ── Saved directors / contacts ────────────────────────────────────────────────
-- People who have previously been listed as directors on formation/CRTC orders.
CREATE TABLE IF NOT EXISTS customer_directors (
id SERIAL PRIMARY KEY,
customer_id INT NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
name TEXT NOT NULL,
citizenship TEXT,
address_id INT REFERENCES customer_addresses(id) ON DELETE SET NULL,
is_default BOOLEAN NOT NULL DEFAULT FALSE,
source_order TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_customer_directors_customer ON customer_directors (customer_id);
-- ── Link existing orders to customers (backfill on first login) ───────────────
-- We don't backfill immediately — on first login we match by email and link.
ALTER TABLE canada_crtc_orders
ADD COLUMN IF NOT EXISTS customer_id INT REFERENCES customers(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_crtc_orders_customer ON canada_crtc_orders (customer_id);
ALTER TABLE orders
ADD COLUMN IF NOT EXISTS customer_id INT REFERENCES customers(id) ON DELETE SET NULL;
-- ── Auto-update updated_at on customers ──────────────────────────────────────
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS customers_updated_at ON customers;
CREATE TRIGGER customers_updated_at
BEFORE UPDATE ON customers
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
COMMIT;

View file

@ -0,0 +1,4 @@
-- Migration 019: Add password auth to customers
BEGIN;
ALTER TABLE customers ADD COLUMN IF NOT EXISTS password_hash TEXT;
COMMIT;

View file

@ -0,0 +1,12 @@
-- Migration 020: Password reset tokens
BEGIN;
CREATE TABLE IF NOT EXISTS password_reset_tokens (
id SERIAL PRIMARY KEY,
customer_id INT NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_prt_token ON password_reset_tokens (token);
COMMIT;

View file

@ -0,0 +1,7 @@
-- Migration 021: Add defer_until to formation_orders for holiday/schedule deferred jobs
BEGIN;
ALTER TABLE formation_orders ADD COLUMN IF NOT EXISTS defer_until TIMESTAMPTZ;
CREATE INDEX IF NOT EXISTS idx_formation_orders_deferred
ON formation_orders (defer_until)
WHERE automation_status = 'Deferred' AND defer_until IS NOT NULL;
COMMIT;

View file

@ -0,0 +1,53 @@
-- 022_fcc_rmd.sql
-- FCC Robocall Mitigation Database (RMD) registry cache.
-- Populated by scripts/workers/fcc_rmd_scraper.py — run manually.
BEGIN;
CREATE TABLE IF NOT EXISTS fcc_rmd (
id SERIAL PRIMARY KEY,
-- RMD identifier (e.g. "RMD0001410")
rmd_number TEXT UNIQUE NOT NULL,
frn TEXT,
business_name TEXT,
business_address TEXT,
foreign_voice_provider BOOLEAN DEFAULT FALSE,
country TEXT,
other_frns TEXT,
other_dba_names TEXT,
previous_dba_names TEXT,
-- Robocall mitigation contact (from CSV)
contact_name TEXT,
contact_title TEXT,
contact_department TEXT,
contact_business_address TEXT,
contact_country TEXT,
contact_telephone_number TEXT,
contact_phone_extension TEXT,
-- Contact email — scraped from individual record page (not in CSV)
contact_email TEXT,
contact_email_scraped_at TIMESTAMPTZ,
-- Implementation
implementation TEXT,
voice_service_provider BOOLEAN DEFAULT FALSE,
gateway_provider BOOLEAN DEFAULT FALSE,
intermediate_provider BOOLEAN DEFAULT FALSE,
-- Dates
last_updated DATE,
last_recertified DATE,
-- Direct link to the RMD filing page
filing_url TEXT,
-- sys_id extracted from filing_url — used as Playwright scrape target
servicenow_sys_id TEXT,
-- Audit
csv_imported_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_fcc_rmd_number ON fcc_rmd(rmd_number);
CREATE INDEX IF NOT EXISTS idx_fcc_rmd_frn ON fcc_rmd(frn);
CREATE INDEX IF NOT EXISTS idx_fcc_rmd_email ON fcc_rmd(contact_email);
CREATE INDEX IF NOT EXISTS idx_fcc_rmd_business ON fcc_rmd(business_name);
CREATE INDEX IF NOT EXISTS idx_fcc_rmd_no_email ON fcc_rmd(rmd_number) WHERE contact_email IS NULL;
COMMIT;

View file

@ -0,0 +1,154 @@
-- 023_fcc_rmd_removed.sql
-- FCC carriers removed from the Robocall Mitigation Database for noncompliance.
-- Sources: FCC DA-26-282 (Mar 2026), DA-26-237 (Mar 2026 Belthrough FDO),
-- 39 FCC Rcd 1319 (Feb 2024 Viettel 12), 40 FCC Rcd 6009 (Aug 2025 mass removal)
BEGIN;
CREATE TABLE IF NOT EXISTS fcc_rmd_removed (
id SERIAL PRIMARY KEY,
-- Identifying info
rmd_number TEXT, -- RMD file number if known (e.g. RMD0007602)
frn TEXT, -- FCC Registration Number if known
business_name TEXT NOT NULL,
business_address TEXT,
-- Removal action details
action_type TEXT NOT NULL CHECK (action_type IN (
'final_determination_order', -- FDO: originating illegal robocalls
'deficiency_removal', -- Removed for deficient/incomplete certification
'show_cause_pending' -- Show cause issued, removal imminent
)),
fcc_document TEXT, -- DA number (e.g. DA-26-237)
fcc_docket TEXT, -- EB docket (e.g. EB-TCD-25-00038590)
fcc_citation TEXT, -- FCC Rcd citation
action_date DATE, -- Date of FCC order
deficiency_notice_date DATE, -- Date deficiency was first noticed
removal_reason TEXT, -- Human-readable reason
-- Contact info (from FCC order or independently researched)
contact_name TEXT,
contact_email TEXT,
contact_phone TEXT,
company_website TEXT,
-- Research status
email_researched BOOLEAN DEFAULT FALSE,
email_research_notes TEXT,
-- Mautic tracking
mautic_lead_id INTEGER,
-- Timestamps
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_rmd_removed_rmd_number ON fcc_rmd_removed(rmd_number);
CREATE INDEX IF NOT EXISTS idx_rmd_removed_business ON fcc_rmd_removed(business_name);
CREATE INDEX IF NOT EXISTS idx_rmd_removed_action_type ON fcc_rmd_removed(action_type);
CREATE INDEX IF NOT EXISTS idx_rmd_removed_action_date ON fcc_rmd_removed(action_date);
CREATE INDEX IF NOT EXISTS idx_rmd_removed_email ON fcc_rmd_removed(contact_email);
CREATE INDEX IF NOT EXISTS idx_rmd_removed_no_email ON fcc_rmd_removed(id) WHERE contact_email IS NULL;
-- ============================================================
-- SEED DATA
-- ============================================================
-- ------------------------------------------------------------
-- 1. BELTHROUGH LLC — Final Determination Order (DA 26-237)
-- March 12, 2026 — Originating illegal robocalls
-- Contact email from FCC order document
-- ------------------------------------------------------------
INSERT INTO fcc_rmd_removed (
rmd_number, business_name, business_address,
action_type, fcc_document, fcc_docket, fcc_citation, action_date,
removal_reason, contact_name, contact_email,
email_researched
) VALUES (
'RMD0015088',
'Belthrough LLC',
'1942 Broadway, Ste 314C, Boulder, CO 80302',
'final_determination_order',
'DA-26-237', 'EB-TCD-24-00037445', '(EB 2026)',
'2026-03-12',
'Originated and transited illegal prerecorded robocalls impersonating ISPs, financial services, and border protection agencies. Failed to respond to FCC Notification of Suspected Illegal Traffic (Sept 2025) and Initial Determination Order (Feb 2026). Traffic blocked industry-wide within 30 days.',
'Brisa Cruz',
'brisa@belthrough.com',
TRUE
) ON CONFLICT DO NOTHING;
-- ------------------------------------------------------------
-- 236. 35 COMPANIES — DA-26-282 (March 24, 2026)
-- Show cause / cure or face permanent removal
-- These were provisionally reinstated after the Aug 2025
-- mass removal but still have deficient certifications.
-- ------------------------------------------------------------
INSERT INTO fcc_rmd_removed (rmd_number, business_name, action_type, fcc_document, fcc_docket, action_date, deficiency_notice_date, removal_reason) VALUES
('RMD0007602', 'makrodepot', 'show_cause_pending', 'DA-26-282', 'EB-TCD-25-00038590', '2026-03-24', '2025-11-17', 'RMD certification deficient — missing required robocall mitigation plan information. Provisionally reinstated after Aug 2025 mass removal; cure or face permanent removal by ~Apr 7, 2026.'),
('RMD0008963', 'ConnX Inc.', 'show_cause_pending', 'DA-26-282', 'EB-TCD-25-00038590', '2026-03-24', '2025-11-17', 'RMD certification deficient — missing required robocall mitigation plan information.'),
('RMD0005673', 'Reachme.com Inc', 'show_cause_pending', 'DA-26-282', 'EB-TCD-25-00038590', '2026-03-24', '2025-11-19', 'RMD certification deficient — missing required robocall mitigation plan information.'),
('RMD0007929', 'Convergence Technology Solutions Corp.', 'show_cause_pending', 'DA-26-282', 'EB-TCD-25-00038590', '2026-03-24', '2025-11-17', 'RMD certification deficient — missing required robocall mitigation plan information.'),
('RMD0005475', 'Skycom Healthcare', 'show_cause_pending', 'DA-26-282', 'EB-TCD-25-00038590', '2026-03-24', '2025-12-10', 'RMD certification deficient — missing required robocall mitigation plan information.'),
('RMD0006142', 'CFX Business Solutions Inc', 'show_cause_pending', 'DA-26-282', 'EB-TCD-25-00038590', '2026-03-24', '2025-09-15', 'RMD certification deficient — missing required robocall mitigation plan information.'),
('RMD0009206', 'Daniels Business Services', 'show_cause_pending', 'DA-26-282', 'EB-TCD-25-00038590', '2026-03-24', '2025-11-21', 'RMD certification deficient — missing required robocall mitigation plan information.'),
('RMD0008474', 'Central Point Networks LLC', 'show_cause_pending', 'DA-26-282', 'EB-TCD-25-00038590', '2026-03-24', '2025-11-21', 'RMD certification deficient — missing required robocall mitigation plan information.'),
('RMD0011016', 'DTA Professionals LLC', 'show_cause_pending', 'DA-26-282', 'EB-TCD-25-00038590', '2026-03-24', '2025-09-17', 'RMD certification deficient — missing required robocall mitigation plan information.'),
('RMD0008638', 'Voko Communications LLC', 'show_cause_pending', 'DA-26-282', 'EB-TCD-25-00038590', '2026-03-24', '2025-12-04', 'RMD certification deficient — missing required robocall mitigation plan information.'),
('RMD0011056', 'Inn Touch Systems', 'show_cause_pending', 'DA-26-282', 'EB-TCD-25-00038590', '2026-03-24', '2025-09-22', 'RMD certification deficient — missing required robocall mitigation plan information.'),
('RMD0009397', 'Yeltek', 'show_cause_pending', 'DA-26-282', 'EB-TCD-25-00038590', '2026-03-24', '2026-01-22', 'RMD certification deficient — missing required robocall mitigation plan information.'),
('RMD0006872', 'HIGHCOMM LLC', 'show_cause_pending', 'DA-26-282', 'EB-TCD-25-00038590', '2026-03-24', '2026-01-28', 'RMD certification deficient — missing required robocall mitigation plan information.'),
('RMD0008713', 'Inatech Solutions Inc.', 'show_cause_pending', 'DA-26-282', 'EB-TCD-25-00038590', '2026-03-24', '2025-09-24', 'RMD certification deficient — missing required robocall mitigation plan information.'),
('RMD0008142', 'phonesforward.com', 'show_cause_pending', 'DA-26-282', 'EB-TCD-25-00038590', '2026-03-24', '2026-01-22', 'RMD certification deficient — missing required robocall mitigation plan information.'),
('RMD0008143', 'Dixie Net Communications', 'show_cause_pending', 'DA-26-282', 'EB-TCD-25-00038590', '2026-03-24', '2025-09-24', 'RMD certification deficient — missing required robocall mitigation plan information.'),
('RMD0011232', 'Enhanced Business Communications LLC', 'show_cause_pending', 'DA-26-282', 'EB-TCD-25-00038590', '2026-03-24', '2025-11-21', 'RMD certification deficient — missing required robocall mitigation plan information.'),
('RMD0008192', 'Dynamic Network Support', 'show_cause_pending', 'DA-26-282', 'EB-TCD-25-00038590', '2026-03-24', '2025-09-24', 'RMD certification deficient — missing required robocall mitigation plan information.'),
('RMD0005602', 'UT&T', 'show_cause_pending', 'DA-26-282', 'EB-TCD-25-00038590', '2026-03-24', '2026-01-20', 'RMD certification deficient — missing required robocall mitigation plan information.'),
('RMD0004752', 'Secure UICC', 'show_cause_pending', 'DA-26-282', 'EB-TCD-25-00038590', '2026-03-24', '2025-11-21', 'RMD certification deficient — missing required robocall mitigation plan information.'),
('RMD0012459', 'Ring2Voice Inc', 'show_cause_pending', 'DA-26-282', 'EB-TCD-25-00038590', '2026-03-24', '2026-01-23', 'RMD certification deficient — missing required robocall mitigation plan information.'),
('RMD0011304', 'One Too Many Insurance Agency Inc. DBA Search & Save', 'show_cause_pending', 'DA-26-282', 'EB-TCD-25-00038590', '2026-03-24', '2025-11-24', 'RMD certification deficient — missing required robocall mitigation plan information.'),
('RMD0007940', 'Apps Communications Inc.', 'show_cause_pending', 'DA-26-282', 'EB-TCD-25-00038590', '2026-03-24', '2025-09-25', 'RMD certification deficient — missing required robocall mitigation plan information.'),
('RMD0007131', 'Digital Division LLC', 'show_cause_pending', 'DA-26-282', 'EB-TCD-25-00038590', '2026-03-24', '2025-09-25', 'RMD certification deficient — missing required robocall mitigation plan information.'),
('RMD0008994', 'Axxess Consult Inc', 'show_cause_pending', 'DA-26-282', 'EB-TCD-25-00038590', '2026-03-24', '2026-01-23', 'RMD certification deficient — missing required robocall mitigation plan information.'),
('RMD0008919', 'Consumer Agent Portal LLC DBA TrustedChoice.com', 'show_cause_pending', 'DA-26-282', 'EB-TCD-25-00038590', '2026-03-24', '2025-09-25', 'RMD certification deficient — missing required robocall mitigation plan information.'),
('RMD0008129', 'Conference America Inc.', 'show_cause_pending', 'DA-26-282', 'EB-TCD-25-00038590', '2026-03-24', '2026-01-23', 'RMD certification deficient — missing required robocall mitigation plan information.'),
('RMD0008093', 'Universal E-Business Solutions LLC', 'show_cause_pending', 'DA-26-282', 'EB-TCD-25-00038590', '2026-03-24', '2026-01-23', 'RMD certification deficient — missing required robocall mitigation plan information.'),
('RMD0006392', 'Opex Communications Inc.', 'show_cause_pending', 'DA-26-282', 'EB-TCD-25-00038590', '2026-03-24', '2026-01-23', 'RMD certification deficient — missing required robocall mitigation plan information.'),
('RMD0005094', 'Optus Networks Pty Limited', 'show_cause_pending', 'DA-26-282', 'EB-TCD-25-00038590', '2026-03-24', '2025-12-19', 'RMD certification deficient — missing required robocall mitigation plan information.'),
(NULL, 'Jeremiah Connelly', 'show_cause_pending', 'DA-26-282', 'EB-TCD-25-00038590', '2026-03-24', '2025-11-19', 'Individual filer. RMD certification deficient — missing required robocall mitigation plan information.'),
('RMD0007323', 'CSB Technologies', 'show_cause_pending', 'DA-26-282', 'EB-TCD-25-00038590', '2026-03-24', '2025-11-18', 'RMD certification deficient — missing required robocall mitigation plan information.'),
('RMD0015313', 'Easy Numbers LLC', 'show_cause_pending', 'DA-26-282', 'EB-TCD-25-00038590', '2026-03-24', '2025-11-18', 'RMD certification deficient — missing required robocall mitigation plan information.'),
('RMD0008338', 'SECURE', 'show_cause_pending', 'DA-26-282', 'EB-TCD-25-00038590', '2026-03-24', '2025-11-18', 'RMD certification deficient — missing required robocall mitigation plan information.'),
('RMD0002196', 'Panobit LLC', 'show_cause_pending', 'DA-26-282', 'EB-TCD-25-00038590', '2026-03-24', '2026-01-23', 'RMD certification deficient — missing required robocall mitigation plan information.');
-- ------------------------------------------------------------
-- 37. VIETTEL BUSINESS SOLUTIONS CO. et al. — Feb 2024 removal
-- 39 FCC Rcd 1319 — 12 entities with deficient certifications
-- Note: Full list from order. Viettel is the named lead entity;
-- the remaining 11 are unnamed in public summaries — they will
-- be populated from the actual order appendix when retrieved.
-- ------------------------------------------------------------
INSERT INTO fcc_rmd_removed (
business_name, action_type, fcc_document, fcc_citation, action_date,
removal_reason
) VALUES (
'Viettel Business Solutions Co.',
'deficiency_removal',
NULL, '39 FCC Rcd 1319', '2024-02-01',
'Lead entity in Feb 2024 mass removal of 12 carriers with deficient RMD certifications. Failed to update certifications with required information after notice and opportunity to cure. Removed per 47 CFR § 64.6305(d)-(e).'
) ON CONFLICT DO NOTHING;
-- ------------------------------------------------------------
-- NOTE: August 2025 mass removal (1,203 carriers, 40 FCC Rcd 6009)
-- and Aug 7, 2025 removal (185 carriers) — full company lists
-- are in appendices of those orders (DA number not confirmed).
-- These will be populated once the order appendix is retrieved
-- from FCC EDOCS. The action_date and docket are known.
-- A placeholder record documents this batch:
-- ------------------------------------------------------------
INSERT INTO fcc_rmd_removed (
business_name, action_type, fcc_docket, fcc_citation, action_date,
removal_reason
) VALUES (
'[1,203 carriers — Aug 2025 mass removal — appendix pending]',
'deficiency_removal',
'EB-TCD-25-00038590', '40 FCC Rcd 6009', '2025-08-25',
'Largest single RMD removal in program history. 1,203 providers removed for failing to respond to the Dec 2024 Show Cause Order (39 FCC Rcd 13318) regarding deficient certifications. Full appendix to be retrieved from FCC EDOCS DA order document.'
) ON CONFLICT DO NOTHING;
COMMIT;

View file

@ -0,0 +1,17 @@
-- 024_crtc_trade_name.sql
-- Add trade name support to canada_crtc_orders.
BEGIN;
-- Add trade name columns
ALTER TABLE canada_crtc_orders
ADD COLUMN IF NOT EXISTS trade_name TEXT,
ADD COLUMN IF NOT EXISTS add_trade_name BOOLEAN DEFAULT FALSE;
-- Expand company_type CHECK to include numbered_tradename
-- Drop old constraint and recreate (PG doesn't support ALTER CONSTRAINT)
ALTER TABLE canada_crtc_orders DROP CONSTRAINT IF EXISTS canada_crtc_orders_company_type_check;
ALTER TABLE canada_crtc_orders ADD CONSTRAINT canada_crtc_orders_company_type_check
CHECK (company_type IN ('numbered', 'numbered_tradename', 'named'));
COMMIT;

View file

@ -0,0 +1,38 @@
-- 025_crtc_pipeline_tracking.sql
-- Add columns to track each pipeline step's completion for CRTC orders.
BEGIN;
-- Domain + email provisioning
ALTER TABLE canada_crtc_orders ADD COLUMN IF NOT EXISTS ca_domain TEXT;
ALTER TABLE canada_crtc_orders ADD COLUMN IF NOT EXISTS domain_provisioned_at TIMESTAMPTZ;
ALTER TABLE canada_crtc_orders ADD COLUMN IF NOT EXISTS domain_credentials_sent_at TIMESTAMPTZ;
-- Canadian DID
ALTER TABLE canada_crtc_orders ADD COLUMN IF NOT EXISTS ca_did_number TEXT;
ALTER TABLE canada_crtc_orders ADD COLUMN IF NOT EXISTS did_provisioned_at TIMESTAMPTZ;
-- CRTC registration submission
ALTER TABLE canada_crtc_orders ADD COLUMN IF NOT EXISTS crtc_submitted_at TIMESTAMPTZ;
ALTER TABLE canada_crtc_orders ADD COLUMN IF NOT EXISTS crtc_submitted_from_email TEXT;
-- BITS affidavit + notarization + submission
ALTER TABLE canada_crtc_orders ADD COLUMN IF NOT EXISTS bits_affidavit_minio_path TEXT;
ALTER TABLE canada_crtc_orders ADD COLUMN IF NOT EXISTS bits_notary_order_id TEXT;
ALTER TABLE canada_crtc_orders ADD COLUMN IF NOT EXISTS bits_notarized_at TIMESTAMPTZ;
ALTER TABLE canada_crtc_orders ADD COLUMN IF NOT EXISTS bits_notarized_minio_path TEXT;
ALTER TABLE canada_crtc_orders ADD COLUMN IF NOT EXISTS bits_submitted_at TIMESTAMPTZ;
-- CCTS registration
ALTER TABLE canada_crtc_orders ADD COLUMN IF NOT EXISTS ccts_registered_at TIMESTAMPTZ;
-- Banking referral
ALTER TABLE canada_crtc_orders ADD COLUMN IF NOT EXISTS banking_referral_sent_at TIMESTAMPTZ;
-- Order confirmation email
ALTER TABLE canada_crtc_orders ADD COLUMN IF NOT EXISTS confirmation_email_sent_at TIMESTAMPTZ;
-- ERPNext Sales Order link (webhooks fire on Sales Order state changes)
ALTER TABLE canada_crtc_orders ADD COLUMN IF NOT EXISTS erpnext_sales_order TEXT;
COMMIT;

View file

@ -0,0 +1,24 @@
-- 026_director_split_fields.sql
-- Split director name into first/middle/last for BC Registry (COLIN) compatibility.
-- Add mailing address and additional directors support.
BEGIN;
-- Split director name fields
ALTER TABLE canada_crtc_orders ADD COLUMN IF NOT EXISTS director_first_name TEXT;
ALTER TABLE canada_crtc_orders ADD COLUMN IF NOT EXISTS director_middle_name TEXT;
ALTER TABLE canada_crtc_orders ADD COLUMN IF NOT EXISTS director_last_name TEXT;
-- Director mailing address (if different from delivery address)
ALTER TABLE canada_crtc_orders ADD COLUMN IF NOT EXISTS director_mailing_different BOOLEAN DEFAULT FALSE;
ALTER TABLE canada_crtc_orders ADD COLUMN IF NOT EXISTS director_mailing_address TEXT;
-- Additional directors (JSON array of objects)
-- Each object: {first_name, middle_name, last_name, street, city, province, postal, country}
ALTER TABLE canada_crtc_orders ADD COLUMN IF NOT EXISTS additional_directors JSONB;
-- BC incorporation results (populated by frappe_ca_registry after filing)
ALTER TABLE canada_crtc_orders ADD COLUMN IF NOT EXISTS bc_incorporation_number TEXT;
ALTER TABLE canada_crtc_orders ADD COLUMN IF NOT EXISTS company_name_final TEXT;
COMMIT;

View file

@ -0,0 +1,6 @@
-- 027_domain_privacy.sql
-- Add domain_privacy preference to CRTC orders.
BEGIN;
ALTER TABLE canada_crtc_orders ADD COLUMN IF NOT EXISTS domain_privacy BOOLEAN DEFAULT TRUE;
COMMIT;

View file

@ -0,0 +1,9 @@
-- 028_did_routing.sql
-- Store customer's DID routing preference from the order form.
BEGIN;
ALTER TABLE canada_crtc_orders ADD COLUMN IF NOT EXISTS did_routing_type TEXT DEFAULT 'later';
ALTER TABLE canada_crtc_orders ADD COLUMN IF NOT EXISTS did_forward_number TEXT;
ALTER TABLE canada_crtc_orders ADD COLUMN IF NOT EXISTS did_sip_uri TEXT;
ALTER TABLE canada_crtc_orders ADD COLUMN IF NOT EXISTS did_sip_ip TEXT;
COMMIT;

View file

@ -0,0 +1,14 @@
-- 029_payment_completion_columns.sql
-- Adds payment completion tracking columns used by Stripe checkout flow.
BEGIN;
ALTER TABLE canada_crtc_orders ADD COLUMN IF NOT EXISTS paid_at TIMESTAMPTZ;
ALTER TABLE formation_orders ADD COLUMN IF NOT EXISTS payment_status TEXT DEFAULT 'pending_payment';
ALTER TABLE formation_orders ADD COLUMN IF NOT EXISTS paid_at TIMESTAMPTZ;
ALTER TABLE bundle_orders ADD COLUMN IF NOT EXISTS payment_status TEXT DEFAULT 'pending_payment';
ALTER TABLE bundle_orders ADD COLUMN IF NOT EXISTS paid_at TIMESTAMPTZ;
COMMIT;

View file

@ -0,0 +1,7 @@
-- Add PayPal order ID and payment_method columns for direct PayPal checkout
ALTER TABLE canada_crtc_orders ADD COLUMN IF NOT EXISTS paypal_order_id TEXT;
ALTER TABLE canada_crtc_orders ADD COLUMN IF NOT EXISTS payment_method TEXT DEFAULT 'card';
ALTER TABLE formation_orders ADD COLUMN IF NOT EXISTS paypal_order_id TEXT;
ALTER TABLE formation_orders ADD COLUMN IF NOT EXISTS payment_method TEXT DEFAULT 'card';
ALTER TABLE bundle_orders ADD COLUMN IF NOT EXISTS paypal_order_id TEXT;
ALTER TABLE bundle_orders ADD COLUMN IF NOT EXISTS payment_method TEXT DEFAULT 'card';

View file

@ -0,0 +1,4 @@
-- Store SHKeeper crypto payment details (wallet, amount, rate) as JSONB
ALTER TABLE canada_crtc_orders ADD COLUMN IF NOT EXISTS crypto_details JSONB;
ALTER TABLE formation_orders ADD COLUMN IF NOT EXISTS crypto_details JSONB;
ALTER TABLE bundle_orders ADD COLUMN IF NOT EXISTS crypto_details JSONB;

View file

@ -0,0 +1,19 @@
-- Support for client-provided Canadian registered office address (skip Anytime Mailbox)
ALTER TABLE canada_crtc_orders ADD COLUMN IF NOT EXISTS has_own_ca_address BOOLEAN DEFAULT FALSE;
ALTER TABLE canada_crtc_orders ADD COLUMN IF NOT EXISTS own_ca_street TEXT;
ALTER TABLE canada_crtc_orders ADD COLUMN IF NOT EXISTS own_ca_city TEXT;
ALTER TABLE canada_crtc_orders ADD COLUMN IF NOT EXISTS own_ca_province TEXT DEFAULT 'BC';
ALTER TABLE canada_crtc_orders ADD COLUMN IF NOT EXISTS own_ca_postal TEXT;
-- Payment reminder tracking (abandoned cart recovery)
ALTER TABLE canada_crtc_orders ADD COLUMN IF NOT EXISTS reminder_15m_sent_at TIMESTAMPTZ;
ALTER TABLE canada_crtc_orders ADD COLUMN IF NOT EXISTS reminder_1d_sent_at TIMESTAMPTZ;
ALTER TABLE canada_crtc_orders ADD COLUMN IF NOT EXISTS reminder_2d_sent_at TIMESTAMPTZ;
ALTER TABLE formation_orders ADD COLUMN IF NOT EXISTS reminder_15m_sent_at TIMESTAMPTZ;
ALTER TABLE formation_orders ADD COLUMN IF NOT EXISTS reminder_1d_sent_at TIMESTAMPTZ;
ALTER TABLE formation_orders ADD COLUMN IF NOT EXISTS reminder_2d_sent_at TIMESTAMPTZ;
ALTER TABLE bundle_orders ADD COLUMN IF NOT EXISTS reminder_15m_sent_at TIMESTAMPTZ;
ALTER TABLE bundle_orders ADD COLUMN IF NOT EXISTS reminder_1d_sent_at TIMESTAMPTZ;
ALTER TABLE bundle_orders ADD COLUMN IF NOT EXISTS reminder_2d_sent_at TIMESTAMPTZ;

View file

@ -0,0 +1,34 @@
-- ═══════════════════════════════════════════════════════════════════════════════
-- 033: Anytime Mailbox locations + client selection columns
-- ═══════════════════════════════════════════════════════════════════════════════
-- Anytime Mailbox BC locations with live pricing (updated daily by scraper)
CREATE TABLE IF NOT EXISTS amb_locations (
id SERIAL PRIMARY KEY,
slug TEXT UNIQUE NOT NULL, -- e.g. "howe-st-vancouver"
name TEXT NOT NULL, -- e.g. "329 Howe St"
full_address TEXT NOT NULL, -- full street address line
city TEXT NOT NULL DEFAULT 'Vancouver',
province TEXT NOT NULL DEFAULT 'BC',
postal_code TEXT NOT NULL,
provider_url TEXT NOT NULL, -- AMB listing URL for scraping
plan_name TEXT DEFAULT 'Basic',
monthly_price_usd INTEGER NOT NULL DEFAULT 0, -- cents
yearly_price_usd INTEGER NOT NULL DEFAULT 0, -- cents (annual rate, used in orders)
is_active BOOLEAN DEFAULT TRUE,
available_units INTEGER DEFAULT -1, -- -1 = unknown, 0 = sold out, >0 = count
last_scraped_at TIMESTAMPTZ,
price_changed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Client-selected AMB location + unit + DID on CRTC orders
ALTER TABLE canada_crtc_orders ADD COLUMN IF NOT EXISTS amb_location_slug TEXT;
ALTER TABLE canada_crtc_orders ADD COLUMN IF NOT EXISTS amb_annual_price_cents INTEGER DEFAULT 0;
ALTER TABLE canada_crtc_orders ADD COLUMN IF NOT EXISTS client_selected_unit TEXT;
ALTER TABLE canada_crtc_orders ADD COLUMN IF NOT EXISTS client_selected_did TEXT;
ALTER TABLE canada_crtc_orders ADD COLUMN IF NOT EXISTS funds_available BOOLEAN DEFAULT FALSE;
ALTER TABLE canada_crtc_orders ADD COLUMN IF NOT EXISTS funds_available_at TIMESTAMPTZ;
ALTER TABLE canada_crtc_orders ADD COLUMN IF NOT EXISTS stripe_topup_id TEXT;
ALTER TABLE canada_crtc_orders ADD COLUMN IF NOT EXISTS client_selection_email_sent_at TIMESTAMPTZ;

View file

@ -0,0 +1,20 @@
-- 034: AMB operator name + own-address contact fields
--
-- operator_name: the legal name of the mailbox company operating each AMB location
-- (e.g. "Regus", "iPostal1", "The UPS Store"). Used as the addressee on the binder
-- parcel so the mailbox operator accepts the package on behalf of the client's corp.
--
-- own_ca_company: company/organisation name at the client's own BC address.
-- Required when has_own_ca_address = TRUE so the binder label reads:
-- Attn: <own_ca_attn>
-- <own_ca_company>
-- <own_ca_street> ...
--
-- own_ca_attn: individual contact name at the client's own BC address (optional).
ALTER TABLE amb_locations
ADD COLUMN IF NOT EXISTS operator_name TEXT;
ALTER TABLE canada_crtc_orders
ADD COLUMN IF NOT EXISTS own_ca_company TEXT,
ADD COLUMN IF NOT EXISTS own_ca_attn TEXT;

View file

@ -0,0 +1,14 @@
-- 035: eSign columns for CRTC notification letter signing
--
-- esign_signed_at: timestamp when the client signed the letter
-- esign_signature_b64: base64-encoded PNG of the drawn signature
-- esign_signer_email: email address used to sign (from portal JWT)
-- crtc_letter_minio_key: MinIO object key for the generated CRTC letter PDF
-- (set by the pipeline at Step 6 so the sign page can
-- generate a presigned preview URL)
ALTER TABLE canada_crtc_orders
ADD COLUMN IF NOT EXISTS esign_signed_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS esign_signature_b64 TEXT,
ADD COLUMN IF NOT EXISTS esign_signer_email TEXT,
ADD COLUMN IF NOT EXISTS crtc_letter_minio_key TEXT;

View file

@ -0,0 +1,16 @@
-- 036_crtc_ontario.sql
-- Add Ontario incorporation support to CRTC orders.
-- Adds province selection column and generalizes BC-specific field name.
BEGIN;
-- 1. Add incorporation_province column (default BC for existing orders)
ALTER TABLE canada_crtc_orders
ADD COLUMN IF NOT EXISTS incorporation_province TEXT DEFAULT 'BC'
CHECK (incorporation_province IN ('BC', 'ON'));
-- 2. Rename bc_incorporation_number → incorporation_number
ALTER TABLE canada_crtc_orders
RENAME COLUMN bc_incorporation_number TO incorporation_number;
COMMIT;

View file

@ -0,0 +1,8 @@
-- 037: Add disclaimer acknowledgment + existing Canadian DID storage
--
-- disclaimer_agreed_at: timestamp when client checked the "not legal advice" disclaimer
-- existing_ca_did: client's pre-existing Canadian phone number (skips DID provisioning)
ALTER TABLE canada_crtc_orders
ADD COLUMN IF NOT EXISTS disclaimer_agreed_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS existing_ca_did TEXT;

View file

@ -0,0 +1,61 @@
-- 038: Telecom Entity tracking — multi-entity support
--
-- Links customers to their telecom entities (each with FRN, 499 Filer ID, etc.)
-- A single customer can manage multiple entities.
-- Compliance checks and 499-A filings are scoped per entity.
CREATE TABLE IF NOT EXISTS telecom_entities (
id SERIAL PRIMARY KEY,
customer_id INTEGER REFERENCES customers(id),
-- Jurisdiction
jurisdiction TEXT NOT NULL DEFAULT 'FCC'
CHECK (jurisdiction IN ('FCC', 'CRTC')),
-- FCC identifiers (US entities)
frn TEXT, -- 10-digit FCC Registration Number
filer_id_499 TEXT, -- USAC 499 Filer ID
-- CRTC identifiers (Canadian entities)
incorporation_number TEXT, -- BC/ON provincial corporation number
incorporation_province TEXT, -- BC or ON
crtc_registration_number TEXT, -- CRTC carrier registration ID
-- Entity info
legal_name TEXT NOT NULL,
dba_name TEXT,
ein TEXT, -- EIN (US) or BN (Canada)
-- Classification (from 499-A questionnaire — FCC entities)
filer_type TEXT, -- interconnected_voip, non_interconnected_voip, clec, etc.
infra_type TEXT, -- facilities, reseller, both
is_deminimis BOOLEAN DEFAULT FALSE,
is_lire BOOLEAN DEFAULT FALSE,
service_categories TEXT[], -- array of category slugs
-- Contact
contact_name TEXT,
contact_email TEXT,
contact_phone TEXT,
ceo_name TEXT,
ceo_title TEXT,
-- Address
address_street TEXT,
address_city TEXT,
address_state TEXT,
address_zip TEXT,
-- Revenue snapshot (from last 499-A)
last_filing_year INTEGER,
total_revenue_cents BIGINT DEFAULT 0,
interstate_pct NUMERIC(5,2) DEFAULT 0,
international_pct NUMERIC(5,2) DEFAULT 0,
-- Status
active BOOLEAN DEFAULT TRUE,
notes TEXT,
-- ERPNext link
erpnext_customer TEXT, -- ERPNext Customer docname
-- Metadata
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_telecom_entities_customer ON telecom_entities(customer_id);
CREATE INDEX IF NOT EXISTS idx_telecom_entities_frn ON telecom_entities(frn) WHERE frn IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_telecom_entities_filer ON telecom_entities(filer_id_499) WHERE filer_id_499 IS NOT NULL;
-- Add entity_id to compliance-related tables
ALTER TABLE canada_crtc_orders ADD COLUMN IF NOT EXISTS telecom_entity_id INTEGER REFERENCES telecom_entities(id);

View file

@ -0,0 +1,20 @@
-- 039: Add defer_until + idempotency markers to canada_crtc_orders
--
-- defer_until: timestamp until which the worker should not run this order's pipeline.
-- Used by the standard-vs-expedited delay system.
--
-- binder_emailed_at / binder_compiled_at / binder_uploaded_at: idempotency markers
-- for Steps 7-9 so the pipeline can resume after a defer without re-doing work
-- (preventing duplicate client emails).
ALTER TABLE canada_crtc_orders
ADD COLUMN IF NOT EXISTS defer_until TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS automation_note TEXT,
ADD COLUMN IF NOT EXISTS binder_compiled_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS binder_uploaded_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS binder_emailed_at TIMESTAMPTZ;
-- Index for the deferred order poller
CREATE INDEX IF NOT EXISTS idx_crtc_orders_deferred
ON canada_crtc_orders (defer_until)
WHERE automation_status = 'Deferred' AND defer_until IS NOT NULL;

View file

@ -0,0 +1,22 @@
-- 040: Local cache of FCC Form 499 filer database
-- Source: https://apps.fcc.gov/cgb/form499/499results.cfm?XML=TRUE
-- Updated daily by scraper. Used for FRN → entity name lookup
-- so compliance wizard doesn't need to hit the FCC site.
CREATE TABLE IF NOT EXISTS fcc_499_filers (
id SERIAL PRIMARY KEY,
filer_id TEXT UNIQUE, -- USAC 499 Filer ID
frn TEXT, -- FCC Registration Number (10-digit)
legal_name TEXT NOT NULL,
trade_name TEXT,
state TEXT, -- 2-letter state code
service_type TEXT, -- Primary service classification
holding_company TEXT,
status TEXT, -- Active / Inactive
last_scraped_at TIMESTAMPTZ DEFAULT NOW(),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_fcc_499_filers_frn ON fcc_499_filers(frn) WHERE frn IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_fcc_499_filers_name ON fcc_499_filers USING gin(to_tsvector('english', legal_name));

View file

@ -0,0 +1,9 @@
-- 041: Track RMD removal status directly on fcc_rmd records
-- When a provider disappears from the RMD CSV, we mark them as removed
-- rather than deleting the record. This way we can still look them up
-- by FRN and tell them their RMD filing is missing.
ALTER TABLE fcc_rmd
ADD COLUMN IF NOT EXISTS removed_from_rmd BOOLEAN DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS removed_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS last_seen_in_csv TIMESTAMPTZ;

View file

@ -0,0 +1,6 @@
-- 042: Red light status tracking on fcc_rmd records
-- Populated by the CORES Playwright scraper using PW's CORES login.
ALTER TABLE fcc_rmd
ADD COLUMN IF NOT EXISTS red_light_status TEXT, -- 'green', 'red', 'unknown'
ADD COLUMN IF NOT EXISTS red_light_checked_at TIMESTAMPTZ;

View file

@ -0,0 +1,54 @@
-- 043: Carrier classification for FCC compliance checkup
--
-- Adds carrier type classification fields to telecom_entities.
-- Used to determine proper RMD letter template, CPNI cert format,
-- and 499-A filing preparation based on carrier category.
-- Primary carrier category (how the provider registers with FCC)
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS carrier_category TEXT
CHECK (carrier_category IN (
'interconnected_voip',
'non_interconnected_voip',
'clec',
'ixc',
'cmrs',
'other'
));
-- Operational role flags (not mutually exclusive)
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS is_wholesale BOOLEAN DEFAULT FALSE;
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS is_gateway_provider BOOLEAN DEFAULT FALSE;
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS is_international_only BOOLEAN DEFAULT FALSE;
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS uses_ucaas_provider BOOLEAN DEFAULT FALSE;
-- Type-specific metadata (JSONB to avoid endless nullable columns)
-- UCaaS: {"ucaas_provider": "RingCentral", "ucaas_provider_frn": "0012345678"}
-- Gateway: {"gateway_countries": ["Mexico","India"], "gateway_type": "international"}
-- Wholesale: {"wholesale_customer_types": ["clec","voip"], "downstream_count_approx": 50}
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS carrier_metadata JSONB DEFAULT '{}';
-- STIR/SHAKEN specifics (needed for RMD letter sections)
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS stir_shaken_status TEXT
CHECK (stir_shaken_status IN (
'complete_implementation',
'partial_implementation',
'robocall_mitigation_only',
'exempt_small_carrier',
'not_applicable'
));
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS stir_shaken_cert_authority TEXT;
-- Upstream provider (for resellers and UCaaS-based carriers)
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS upstream_provider_name TEXT;
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS upstream_provider_frn TEXT;
-- RMD letter generation tracking
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS rmd_letter_minio_path TEXT;
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS rmd_letter_generated_at TIMESTAMPTZ;
-- Last compliance checkup timestamp
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS last_compliance_checkup TIMESTAMPTZ;
-- Index for filtering by carrier category
CREATE INDEX IF NOT EXISTS idx_telecom_entities_carrier_category
ON telecom_entities(carrier_category) WHERE carrier_category IS NOT NULL;

View file

@ -0,0 +1,49 @@
-- 044: Compliance orders — tracks paid compliance service orders
--
-- Bridges the gap between the free compliance check tool and ERPNext:
-- 1. Customer runs free compliance check → sees what needs fixing
-- 2. Customer places a compliance order → creates row here
-- 3. Checkout creates Stripe session → payment_status advances
-- 4. On payment: ERPNext Sales Order created, webhook triggers worker
-- 5. Worker generates documents, uploads to MinIO
-- 6. Delivery worker emails documents to customer
CREATE TABLE IF NOT EXISTS compliance_orders (
id SERIAL PRIMARY KEY,
order_number TEXT NOT NULL UNIQUE, -- CO-XXXXXXXX format
-- Service
service_slug TEXT NOT NULL, -- maps to SERVICE_HANDLERS key
service_name TEXT NOT NULL, -- human-readable service name
service_fee_cents INTEGER NOT NULL DEFAULT 0,
-- Linked entity (optional — required for FCC services)
telecom_entity_id INTEGER REFERENCES telecom_entities(id),
-- Customer
customer_email TEXT NOT NULL,
customer_name TEXT NOT NULL,
customer_phone TEXT,
-- Payment
payment_status TEXT NOT NULL DEFAULT 'pending_payment'
CHECK (payment_status IN (
'pending_payment', 'paid', 'refunded', 'cancelled'
)),
payment_method TEXT,
surcharge_pct NUMERIC(5,2) DEFAULT 0,
surcharge_cents INTEGER DEFAULT 0,
stripe_session_id TEXT,
paid_at TIMESTAMPTZ,
-- Discount
discount_code TEXT,
discount_cents INTEGER DEFAULT 0,
-- ERPNext link
erpnext_sales_order TEXT,
-- Metadata
notes TEXT,
intake_data JSONB DEFAULT '{}', -- questionnaire answers, entity snapshot
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_compliance_orders_email ON compliance_orders(customer_email);
CREATE INDEX IF NOT EXISTS idx_compliance_orders_entity ON compliance_orders(telecom_entity_id)
WHERE telecom_entity_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_compliance_orders_status ON compliance_orders(payment_status);

View file

@ -0,0 +1,56 @@
-- 045: FACS (Foreign Adversary Control System) intake fields
--
-- Captures data needed for Schedule A/B/C determination and
-- foreign adversary ownership disclosure under 47 CFR 1.80003.
-- Effective June 9, 2026 (FCC 26-2, GN Docket No. 25-166).
-- Foreign adversary control attestation
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS facs_schedule TEXT
CHECK (facs_schedule IN ('A', 'B', 'C'));
-- Does this entity have any foreign adversary ownership or control?
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS facs_has_foreign_adversary BOOLEAN;
-- FACS attestation filing status
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS facs_filing_status TEXT
CHECK (facs_filing_status IN (
'not_required', -- Schedule C / exempt
'pending', -- needs to file
'filed_no', -- filed attestation: no foreign adversary control
'filed_yes', -- filed attestation: yes (Schedule B disclosure required)
'disclosure_filed' -- Schedule B detailed disclosure submitted
));
-- Foreign ownership details (JSONB for flexibility)
-- Structure:
-- {
-- "has_foreign_owners": true/false,
-- "foreign_adversary_nexus": true/false,
-- "ownership_interests": [
-- {
-- "holder_name": "Entity Name",
-- "holder_country": "CN",
-- "interest_type": "equity|voting|board|contractual",
-- "interest_pct": 15.0,
-- "is_direct": true/false,
-- "is_foreign_adversary": true/false,
-- "description": "15% equity via holding company XYZ Ltd"
-- }
-- ],
-- "control_mechanisms": ["board_seats", "veto_rights", "management_agreement"],
-- "section_214_auth": true/false,
-- "covered_authorizations": ["section_214_domestic", "section_214_international"],
-- "facs_notes": "Additional context..."
-- }
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS facs_ownership_data JSONB DEFAULT '{}';
-- Covered authorization types held by this entity
-- Used for Schedule A/B/C determination
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS covered_authorizations TEXT[]
DEFAULT '{}';
-- Section 214 authorization (key determinant for Schedule A)
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS has_section_214 BOOLEAN DEFAULT FALSE;
-- Filing timestamp
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS facs_filed_at TIMESTAMPTZ;

View file

@ -0,0 +1,3 @@
-- Add batch_id to link multiple compliance orders for bundle checkout
ALTER TABLE compliance_orders ADD COLUMN IF NOT EXISTS batch_id TEXT;
CREATE INDEX IF NOT EXISTS idx_compliance_orders_batch ON compliance_orders(batch_id) WHERE batch_id IS NOT NULL;

View file

@ -0,0 +1,43 @@
-- 047: Carrier filing state tracking + checkup recommendations
--
-- Part A: Record when each filing was last submitted to the FCC so that
-- the automated remediation handlers can be idempotent (skip or abbreviate
-- if a filing is already on record for the current cycle). Re-running the
-- compliance checkup reads these timestamps and flips deficiencies green
-- once the corresponding filing completes.
--
-- Part B: Persist the list of recommended follow-up services produced by
-- the FCC Compliance Checkup so the delivery email / portal can surface
-- one-click upsell links per order.
-- ── Part A: filing state on the carrier ──────────────────────────────────
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS rmd_last_cert_date TIMESTAMPTZ;
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS cpni_last_cert_date TIMESTAMPTZ;
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS bdc_last_filing_date TIMESTAMPTZ;
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS stir_shaken_cert_issued_at TIMESTAMPTZ;
-- Confirmation numbers returned by the FCC/USAC portals (captured from the
-- confirmation page screenshot during automated submission)
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS rmd_confirmation_number TEXT;
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS cpni_confirmation_number TEXT;
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS form_499a_confirmation_number TEXT;
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS bdc_confirmation_number TEXT;
-- ── Part B: recommended follow-up services from the checkup ──────────────
ALTER TABLE compliance_orders ADD COLUMN IF NOT EXISTS recommended_slugs TEXT[] DEFAULT '{}';
-- ── Part C: portal onboarding flag ──────────────────────────────────────
-- True on orders where the checkout flow created a brand-new ERPNext
-- Website User for the customer. The success page reads this flag to
-- decide whether to render the set-password form, and the delivery
-- worker reads it to decide whether to include a magic-link fallback.
ALTER TABLE compliance_orders ADD COLUMN IF NOT EXISTS portal_user_created BOOLEAN DEFAULT FALSE;
ALTER TABLE canada_crtc_orders ADD COLUMN IF NOT EXISTS portal_user_created BOOLEAN DEFAULT FALSE;
ALTER TABLE formation_orders ADD COLUMN IF NOT EXISTS portal_user_created BOOLEAN DEFAULT FALSE;
-- Index to find carriers that are overdue on any filing type (used by the
-- renewal worker and by admin dashboards)
CREATE INDEX IF NOT EXISTS idx_telecom_entities_rmd_last_cert
ON telecom_entities(rmd_last_cert_date) WHERE rmd_last_cert_date IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_telecom_entities_cpni_last_cert
ON telecom_entities(cpni_last_cert_date) WHERE cpni_last_cert_date IS NOT NULL;

View file

@ -0,0 +1,104 @@
-- 048: Form 499-A intake fields
--
-- The 2026 Form 499-A (reporting 2025 revenues) requires carrier data
-- beyond what migration 043 captured for the RMD letter. These fields
-- back Block 1 (Lines 106-112), Block 2 (Lines 203-218), Block 2-C
-- (Lines 221-228), and Block 6 (Lines 603-604) of the real form.
--
-- Block/line references per 2026-FCC-Form-499A-Form-Instructions.pdf
-- (November 2025 release).
-- ── Block 1: Filer Identification (Lines 106, 108, 112) ──────────────────
-- Line 106: affiliated filer / holding company
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS affiliated_filer_name TEXT;
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS affiliated_filer_ein TEXT;
-- Line 108: management company
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS management_company_name TEXT;
-- Line 112: all trade names used in past 3 years (including predecessors)
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS trade_names TEXT[] DEFAULT '{}';
-- ── Block 2-A: Regulatory contact (Lines 203-208) ────────────────────────
-- Line 203-206: Person who completed the worksheet — defaults to Performance
-- West (Justin Hannah) as the regulatory contact for managed filers.
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS regulatory_contact_name TEXT
DEFAULT 'Justin Hannah';
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS regulatory_contact_email TEXT
DEFAULT 'justin@performancewest.net';
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS regulatory_contact_phone TEXT
DEFAULT '888-411-0383';
-- Line 207: corporate office for future worksheets — defaults to PW address
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS worksheet_office_company TEXT
DEFAULT 'Performance West Inc';
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS worksheet_office_street TEXT
DEFAULT '30 N Gould St, Ste N';
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS worksheet_office_city TEXT
DEFAULT 'Sheridan';
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS worksheet_office_state TEXT
DEFAULT 'WY';
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS worksheet_office_zip TEXT
DEFAULT '82801';
-- Line 208: billing address — customer (nullable, defaults to entity address)
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS billing_contact_name TEXT;
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS billing_contact_email TEXT;
-- Line 208.1: separate email for ITSP regulatory fee correspondence
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS itsp_regulatory_fee_email TEXT;
-- ── Block 2-B: D.C. Agent (Lines 209-218) — defaults to Northwest RA ─────
-- Hardcoded on DCAgentHandler; stored here so 499-A prep can read it.
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS dc_agent_company TEXT
DEFAULT 'Northwest Registered Agent Service Inc.';
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS dc_agent_street TEXT
DEFAULT '1717 N Street NW STE 1';
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS dc_agent_city TEXT
DEFAULT 'Washington';
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS dc_agent_state TEXT
DEFAULT 'DC';
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS dc_agent_zip TEXT
DEFAULT '20036';
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS dc_agent_phone TEXT
DEFAULT '509-768-2249';
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS dc_agent_email TEXT
DEFAULT 'support@northwestregisteredagent.com';
-- ── Block 2-C: Officers (Lines 221, 223, 225) + Jurisdictions (227) + First-service (228) ──
-- We already track ceo_name/ceo_title (Line 221). Add Officer 2 and Officer 3.
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS officer_2_name TEXT;
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS officer_2_title TEXT;
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS officer_2_street TEXT;
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS officer_2_city TEXT;
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS officer_2_state TEXT;
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS officer_2_zip TEXT;
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS officer_3_name TEXT;
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS officer_3_title TEXT;
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS officer_3_street TEXT;
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS officer_3_city TEXT;
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS officer_3_state TEXT;
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS officer_3_zip TEXT;
-- Line 227: multi-select jurisdictions where service provided/will be provided
-- (past 15 mo + next 12 mo). Store as array of state/territory codes.
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS jurisdictions_served TEXT[] DEFAULT '{}';
-- Line 228: year + month first provided telecom service
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS first_telecom_service_year INT;
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS first_telecom_service_month INT
CHECK (first_telecom_service_month IS NULL OR first_telecom_service_month BETWEEN 1 AND 12);
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS first_telecom_service_pre_1999 BOOLEAN DEFAULT FALSE;
-- ── Block 6: Exemption certifications (Lines 603, 604) ───────────────────
-- Line 603: exempt from each mechanism? (usf/trs/nanpa/lnp)
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS exempt_usf BOOLEAN DEFAULT FALSE;
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS exempt_trs BOOLEAN DEFAULT FALSE;
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS exempt_nanpa BOOLEAN DEFAULT FALSE;
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS exempt_lnp BOOLEAN DEFAULT FALSE;
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS exemption_explanation TEXT;
-- Line 604: state/local gov entity? 501(c) tax exempt?
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS is_state_local_gov BOOLEAN DEFAULT FALSE;
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS is_tax_exempt_501c BOOLEAN DEFAULT FALSE;
-- ── NECA OCN tracking (supports the new ocn-registration service) ────────
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS ocn TEXT;
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS ocn_category TEXT
CHECK (ocn_category IS NULL OR ocn_category IN ('CAP','ETHX','CLEC','IC','IPES','LRSL','PCS','PCSR','ULEC','WIRE','WRSL'));
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS ocn_assigned_at TIMESTAMPTZ;

View file

@ -0,0 +1,40 @@
-- 049: USF quarterly contribution factor tracking
--
-- USAC publishes the federal Universal Service Fund contribution factor
-- (and the associated circularity factor) once per quarter, at
-- https://www.usac.org/service-providers/making-payments/contribution-factors/.
-- The FCC Public Notice typically drops ~14 days before the quarter
-- starts, though the posting date isn't deterministic.
--
-- The usf_factor_monitor worker fetches that page daily, upserts each
-- quarter's factor row here, and when a NEW quarter appears sends a
-- one-shot notification email to every client with an FCC carrier so
-- they can update their customer bill surcharges.
CREATE TABLE IF NOT EXISTS usf_contribution_factors (
id SERIAL PRIMARY KEY,
year INT NOT NULL,
quarter INT NOT NULL CHECK (quarter BETWEEN 1 AND 4),
effective_start DATE NOT NULL,
effective_end DATE NOT NULL,
factor_pct NUMERIC(6, 4) NOT NULL,
circularity_pct NUMERIC(6, 4),
fcc_public_notice TEXT,
first_seen_at TIMESTAMPTZ DEFAULT NOW(),
notified_at TIMESTAMPTZ,
notification_count INT DEFAULT 0,
UNIQUE (year, quarter)
);
CREATE INDEX IF NOT EXISTS idx_usf_factors_effective_start
ON usf_contribution_factors(effective_start);
COMMENT ON TABLE usf_contribution_factors IS
'Quarterly FCC/USAC Universal Service Fund contribution factors. '
'Fed daily by scripts.workers.usf_factor_monitor from the USAC site.';
COMMENT ON COLUMN usf_contribution_factors.factor_pct IS
'Contribution factor as percentage (e.g., 37.6 for 37.6%).';
COMMENT ON COLUMN usf_contribution_factors.circularity_pct IS
'Circularity factor — used to back out gross from net USF pass-through.';
COMMENT ON COLUMN usf_contribution_factors.notified_at IS
'Timestamp of the one-shot client notification email. Prevents duplicate sends.';

View file

@ -0,0 +1,256 @@
-- 050: CDR Ingestion + Traffic Study
--
-- Populated by scripts.workers.cdr_ingester + cdr_puller (see
-- /home/justin/.claude/plans/swirling-napping-sonnet.md for the full design).
--
-- Flow: customer pushes CDRs via SFTPGo OR we pull via a switch preset /
-- generic transport → raw files land in MinIO cdr-uploads/{customer_id}/
-- raw/ → ingester parses (adapter) → validates → dedups → classifies →
-- writes cdr_calls (PG hot path) + parquet (MinIO bulk) → traffic study
-- summarizes into cdr_traffic_studies → pre-fills 499-A workbook.
--
-- Paywall: cdr_study_access_grants gates classified output behind payment
-- for that reporting year's 499-A filing.
--
-- Quotas: cdr_usage_meters tracks bytes + row counts; storage_plan on
-- the profile drives overage billing.
-- ── Profiles (one per telecom_entity that ingests CDRs) ──────────────────
CREATE TABLE IF NOT EXISTS cdr_ingestion_profiles (
id SERIAL PRIMARY KEY,
customer_id INT NOT NULL REFERENCES customers(id),
telecom_entity_id INT NOT NULL REFERENCES telecom_entities(id),
-- Known-switch preset (customer picks from portal dropdown). When set,
-- drives both the transport AND the CDR format automatically. NULL =
-- "Other" with manual transport/format config below.
switch_preset TEXT CHECK (switch_preset IS NULL OR switch_preset IN (
'netsapiens','freeswitch','asterisk','kazoo','ribbon',
'metaswitch','sansay','broadworks','grandstream',
'fortysix_labs','sip_navigator'
)),
-- CDR format adapter (slug matches scripts/workers/cdr_adapters/)
format TEXT NOT NULL,
format_config JSONB DEFAULT '{}', -- column mappings for generic_csv
-- SFTPGo push (customer → our server)
sftpgo_enabled BOOLEAN DEFAULT FALSE,
sftpgo_username TEXT,
sftpgo_password_hash TEXT,
sftpgo_quota_bytes BIGINT DEFAULT 5368709120, -- 5 GB default
-- Generic transport pull (us → customer's switch) — used when
-- switch_preset IS NULL. Presets carry their own config fields.
pull_enabled BOOLEAN DEFAULT FALSE,
pull_transport TEXT CHECK (pull_transport IS NULL OR pull_transport IN
('sftp','ftp','ftps','https','s3','api','scrape')),
pull_host TEXT,
pull_port INT,
pull_remote_glob TEXT,
pull_cron TEXT DEFAULT '0 2 * * *',
pull_sensitive_id TEXT, -- ERPNext Sensitive ID docname
preset_config JSONB DEFAULT '{}', -- preset-specific extras (API host, account_id, etc.)
last_fetched_at TIMESTAMPTZ,
last_fetched_mtime TIMESTAMPTZ,
last_test_at TIMESTAMPTZ,
last_test_ok BOOLEAN,
last_test_error TEXT,
-- Customer's billing-address state — used for the Block 5
-- billing-region report (both-report requirement).
billing_state TEXT,
-- Revenue attribution: per-call gross revenue from the CDR is preferred.
-- Minutes-only estimation is an explicit opt-in for flat-rate line
-- service carriers (or switches without charge data).
minutes_only_estimation_enabled BOOLEAN DEFAULT FALSE,
flat_monthly_revenue_cents BIGINT,
-- Storage quota plan. Filing service includes 10 GB / 10 M rows;
-- customers with higher volumes subscribe to a tier.
storage_plan TEXT NOT NULL DEFAULT 'included'
CHECK (storage_plan IN ('included','tier1','tier2','tier3','enterprise')),
storage_plan_order TEXT, -- compliance_orders.order_number of active plan
over_quota_policy TEXT NOT NULL DEFAULT 'notify'
CHECK (over_quota_policy IN ('notify','block','auto_upgrade')),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(telecom_entity_id)
);
CREATE INDEX IF NOT EXISTS idx_cdr_profiles_customer
ON cdr_ingestion_profiles(customer_id);
-- ── Uploads (file-level tracking) ───────────────────────────────────────
CREATE TABLE IF NOT EXISTS cdr_ingestion_uploads (
id SERIAL PRIMARY KEY,
profile_id INT NOT NULL REFERENCES cdr_ingestion_profiles(id),
source TEXT NOT NULL
CHECK (source IN ('sftpgo','pull','browser','webhook')),
raw_minio_path TEXT NOT NULL,
raw_sha256 TEXT NOT NULL,
normalized_minio_path TEXT,
summary_json JSONB,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','processing','done',
'failed','duplicate','quarantined',
'quota_exceeded')),
duplicate_of_id INT REFERENCES cdr_ingestion_uploads(id),
row_count INT,
rows_accepted INT,
rows_quarantined INT,
rows_dropped_dupes INT,
error TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
processed_at TIMESTAMPTZ,
UNIQUE(profile_id, raw_sha256)
);
CREATE INDEX IF NOT EXISTS idx_cdr_uploads_profile_created
ON cdr_ingestion_uploads(profile_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_cdr_uploads_status
ON cdr_ingestion_uploads(status) WHERE status IN ('pending','processing');
-- ── Wholesale / retail bucket mappings ──────────────────────────────────
CREATE TABLE IF NOT EXISTS cdr_bucket_mappings (
id SERIAL PRIMARY KEY,
profile_id INT NOT NULL REFERENCES cdr_ingestion_profiles(id),
match_type TEXT NOT NULL
CHECK (match_type IN ('trunk_group','account_id')),
match_value TEXT NOT NULL,
bucket TEXT NOT NULL
CHECK (bucket IN ('wholesale','retail')),
override_priority INT DEFAULT 0,
UNIQUE(profile_id, match_type, match_value)
);
-- ── Per-period traffic studies ──────────────────────────────────────────
CREATE TABLE IF NOT EXISTS cdr_traffic_studies (
id SERIAL PRIMARY KEY,
profile_id INT NOT NULL REFERENCES cdr_ingestion_profiles(id),
reporting_year INT NOT NULL,
reporting_period TEXT NOT NULL
CHECK (reporting_period IN ('Q1','Q2','Q3','Q4','ANNUAL')),
total_calls BIGINT,
total_minutes BIGINT,
total_revenue_cents BIGINT,
-- Revenue-weighted percentages (preferred)
interstate_pct NUMERIC(6,4),
intrastate_pct NUMERIC(6,4),
international_pct NUMERIC(6,4),
indeterminate_pct NUMERIC(6,4),
-- Minutes-weighted percentages (cross-check; or primary if
-- minutes_only_estimation_enabled)
interstate_pct_minutes NUMERIC(6,4),
intrastate_pct_minutes NUMERIC(6,4),
international_pct_minutes NUMERIC(6,4),
indeterminate_pct_minutes NUMERIC(6,4),
-- Bucketed minutes
wholesale_minutes BIGINT,
retail_minutes BIGINT,
-- Block 5 regional: both reports produced side-by-side
orig_state_regions_json JSONB,
billing_state_regions_json JSONB,
methodology TEXT,
pdf_minio_path TEXT,
xlsx_minio_path TEXT,
generated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(profile_id, reporting_year, reporting_period)
);
-- ── Classified calls (hot-path PG table; bulk storage is parquet in MinIO) ──
CREATE TABLE IF NOT EXISTS cdr_calls (
id BIGSERIAL PRIMARY KEY,
profile_id INT NOT NULL REFERENCES cdr_ingestion_profiles(id),
upload_id INT NOT NULL REFERENCES cdr_ingestion_uploads(id),
natural_key_hash TEXT NOT NULL, -- SHA-1 of adapter natural key
start_time TIMESTAMPTZ NOT NULL,
duration_sec INT,
billed_amount_cents BIGINT, -- per-call revenue (NULL = unknown)
billed_currency TEXT,
trunk_group_id TEXT,
customer_account_id TEXT,
customer_type TEXT, -- wholesale|retail|unknown
call_direction TEXT, -- inbound|outbound
caller_npa TEXT,
caller_state TEXT,
caller_country TEXT,
called_npa TEXT,
called_state TEXT,
called_country TEXT,
jurisdiction TEXT, -- interstate|intrastate|international|local|indeterminate
orig_state_region TEXT,
billing_state_region TEXT
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_cdr_calls_natural_key
ON cdr_calls(profile_id, natural_key_hash);
CREATE INDEX IF NOT EXISTS idx_cdr_calls_profile_start
ON cdr_calls(profile_id, start_time);
CREATE INDEX IF NOT EXISTS idx_cdr_calls_profile_juris
ON cdr_calls(profile_id, jurisdiction);
-- ── Quarantine: rows that failed validation ─────────────────────────────
CREATE TABLE IF NOT EXISTS cdr_quarantine (
id BIGSERIAL PRIMARY KEY,
upload_id INT NOT NULL REFERENCES cdr_ingestion_uploads(id),
source_row INT,
raw_payload JSONB,
reason_code TEXT NOT NULL,
reason_detail TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_cdr_quarantine_upload
ON cdr_quarantine(upload_id);
-- ── Paywall: per-year access grants ─────────────────────────────────────
--
-- Populated by the checkout.ts payment-complete hook on any of the
-- gating service slugs (fcc-499a, fcc-499a-499q, fcc-full-compliance,
-- cdr-analysis). Presence of a grant unlocks the classified study for
-- that reporting year. Admin view ignores grants.
CREATE TABLE IF NOT EXISTS cdr_study_access_grants (
id SERIAL PRIMARY KEY,
profile_id INT NOT NULL REFERENCES cdr_ingestion_profiles(id),
reporting_year INT NOT NULL,
granted_by_order TEXT NOT NULL, -- compliance_orders.order_number
granted_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(profile_id, reporting_year, granted_by_order)
);
CREATE INDEX IF NOT EXISTS idx_cdr_grants_profile_year
ON cdr_study_access_grants(profile_id, reporting_year);
-- ── Usage meters (quota tracking) ───────────────────────────────────────
CREATE TABLE IF NOT EXISTS cdr_usage_meters (
id SERIAL PRIMARY KEY,
profile_id INT NOT NULL REFERENCES cdr_ingestion_profiles(id),
reporting_year INT NOT NULL,
bytes_stored BIGINT DEFAULT 0,
rows_ingested BIGINT DEFAULT 0,
last_measured_at TIMESTAMPTZ DEFAULT NOW(),
warned_80pct_at TIMESTAMPTZ,
warned_100pct_at TIMESTAMPTZ,
UNIQUE(profile_id, reporting_year)
);
-- ── Link back from telecom_entities ─────────────────────────────────────
ALTER TABLE telecom_entities
ADD COLUMN IF NOT EXISTS cdr_ingestion_profile_id
INT REFERENCES cdr_ingestion_profiles(id);

View file

@ -0,0 +1,93 @@
-- 051: CDR Reference Data — NANPA area codes + FCC Block 5 regions
--
-- nanpa_area_codes: populated by scripts.workers.cdr_npa_importer (one-shot,
-- re-runnable). Seeded empty; importer fills from the public NANPA registry
-- at https://www.nationalnanpa.com/enas/geoAreaCodeNumberReport.do.
--
-- fcc_block5_regions: hardcoded from 2026 FCC Form 499-A Block 5 Lines
-- 503-509 (state-region assignments are stable across form revisions).
-- Seeded inline below.
CREATE TABLE IF NOT EXISTS nanpa_area_codes (
npa TEXT PRIMARY KEY, -- 3-digit area code
state TEXT, -- 2-char US state / territory
country TEXT, -- ISO-2 country code
timezone TEXT,
lata TEXT, -- LATA code (sparse fill in v1)
rate_center TEXT, -- populated when LERG is available
note TEXT,
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_nanpa_country ON nanpa_area_codes(country);
CREATE INDEX IF NOT EXISTS idx_nanpa_state ON nanpa_area_codes(state);
-- ── FCC Block 5 regions (Lines 503-509, 2026 Form 499-A) ────────────────
CREATE TABLE IF NOT EXISTS fcc_block5_regions (
state_code TEXT PRIMARY KEY, -- 2-char USPS code
region_name TEXT NOT NULL,
line_number INT NOT NULL -- 503..509
);
-- Line 503: Southeast
INSERT INTO fcc_block5_regions (state_code, region_name, line_number) VALUES
('AL', 'Southeast', 503), ('FL', 'Southeast', 503), ('GA', 'Southeast', 503),
('KY', 'Southeast', 503), ('LA', 'Southeast', 503), ('MS', 'Southeast', 503),
('NC', 'Southeast', 503), ('PR', 'Southeast', 503), ('SC', 'Southeast', 503),
('TN', 'Southeast', 503), ('VI', 'Southeast', 503)
ON CONFLICT (state_code) DO NOTHING;
-- Line 504: Western
INSERT INTO fcc_block5_regions (state_code, region_name, line_number) VALUES
('AK', 'Western', 504), ('AZ', 'Western', 504), ('CO', 'Western', 504),
('ID', 'Western', 504), ('IA', 'Western', 504), ('MN', 'Western', 504),
('MT', 'Western', 504), ('NE', 'Western', 504), ('NM', 'Western', 504),
('ND', 'Western', 504), ('OR', 'Western', 504), ('SD', 'Western', 504),
('UT', 'Western', 504), ('WA', 'Western', 504), ('WY', 'Western', 504)
ON CONFLICT (state_code) DO NOTHING;
-- Line 505: West Coast (CA, HI, NV + Pacific territories)
INSERT INTO fcc_block5_regions (state_code, region_name, line_number) VALUES
('CA', 'West Coast', 505), ('HI', 'West Coast', 505), ('NV', 'West Coast', 505),
('AS', 'West Coast', 505), ('GU', 'West Coast', 505),
-- Pacific atolls:
('JA', 'West Coast', 505), -- Johnston Atoll
('MI', 'West Coast', 505), -- Midway Atoll (USPS code MH used for Marshall Is.; MI here per FCC form)
('MP', 'West Coast', 505), -- Northern Mariana Islands
('WK', 'West Coast', 505) -- Wake Island
ON CONFLICT (state_code) DO NOTHING;
-- Line 506: Mid-Atlantic
INSERT INTO fcc_block5_regions (state_code, region_name, line_number) VALUES
('DE', 'Mid-Atlantic', 506), ('DC', 'Mid-Atlantic', 506), ('MD', 'Mid-Atlantic', 506),
('NJ', 'Mid-Atlantic', 506), ('PA', 'Mid-Atlantic', 506), ('VA', 'Mid-Atlantic', 506),
('WV', 'Mid-Atlantic', 506)
ON CONFLICT (state_code) DO NOTHING;
-- Line 507: Mid-West
INSERT INTO fcc_block5_regions (state_code, region_name, line_number) VALUES
('IL', 'Mid-West', 507), ('IN', 'Mid-West', 507), ('MI', 'Mid-West', 507),
('OH', 'Mid-West', 507), ('WI', 'Mid-West', 507)
ON CONFLICT (state_code) DO NOTHING;
-- Line 508: Northeast
INSERT INTO fcc_block5_regions (state_code, region_name, line_number) VALUES
('CT', 'Northeast', 508), ('ME', 'Northeast', 508), ('MA', 'Northeast', 508),
('NH', 'Northeast', 508), ('NY', 'Northeast', 508), ('RI', 'Northeast', 508),
('VT', 'Northeast', 508)
ON CONFLICT (state_code) DO NOTHING;
-- Line 509: Southwest
INSERT INTO fcc_block5_regions (state_code, region_name, line_number) VALUES
('AR', 'Southwest', 509), ('KS', 'Southwest', 509), ('MO', 'Southwest', 509),
('OK', 'Southwest', 509), ('TX', 'Southwest', 509)
ON CONFLICT (state_code) DO NOTHING;
-- Note: Line 505 uses 'MI' for Midway Atoll per FCC convention, which
-- collides with Michigan (also 'MI'). Michigan is in Mid-West above; the
-- INSERT for Midway is the second 'MI' and hits the ON CONFLICT DO NOTHING
-- so Michigan wins. If Midway-originated calls ever appear we handle them
-- via the nanpa_area_codes.country column ('US') + a special-case rate
-- center lookup. This is a known FCC form quirk, not a bug.

View file

@ -0,0 +1,48 @@
-- 052: Fields for the four new FCC filings + intake validation
--
-- Adds the telecom_entities columns the new handlers (CORES/FRN, 499 Initial,
-- CALEA SSI, Foreign Carrier Affiliation) persist to, plus a validated flag
-- on compliance_orders for the /validate dry-run endpoint.
-- ── CORES / FRN credentials ──────────────────────────────────────────────
-- We register carriers in FCC CORES on their behalf; store the assigned
-- username + the bcrypt hash of the password we set during registration
-- so a customer calling in later can verify identity without us holding
-- plaintext. Plaintext is delivered to the customer ONCE in a credential
-- packet PDF.
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS cores_username TEXT;
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS cores_password_hash TEXT;
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS cores_registered_at TIMESTAMPTZ;
-- ── CALEA SSI plan state ─────────────────────────────────────────────────
-- 47 USC 229 / 47 CFR 1.20003 — every carrier must maintain an SSI plan.
-- The plan isn't filed with the FCC (kept internally + provided to DOJ on
-- subpoena), so all state is local. Annual review required.
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS calea_ssi_generated_at TIMESTAMPTZ;
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS calea_ssi_reviewer_name TEXT;
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS calea_ssi_next_review_date DATE;
-- ── Foreign carrier affiliations (47 CFR § 63.11) ────────────────────────
-- Array of notification records — one carrier may have multiple foreign
-- affiliations over time. Each element:
-- { "foreign_carrier_legal_name": "...", "country": "ISO-2",
-- "ownership_pct": <number>, "affected_routes": ["CA","MX"],
-- "affiliation_date": "YYYY-MM-DD", "filed_at": "ISO-8601",
-- "ecfs_confirmation": "2026XXXXXXXXX" }
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS foreign_affiliations JSONB DEFAULT '[]'::jsonb;
-- ── Pre-payment validation flag ──────────────────────────────────────────
-- Flipped TRUE by POST /api/v1/compliance-orders/:order_number/validate
-- once every required intake_data field is present. Stripe checkout
-- refuses to create a session when FALSE (UI enforces this client-side
-- too, but the API is the last line of defense).
ALTER TABLE compliance_orders ADD COLUMN IF NOT EXISTS intake_data_validated BOOLEAN DEFAULT FALSE;
ALTER TABLE compliance_orders ADD COLUMN IF NOT EXISTS validation_errors JSONB;
-- ── Indexes ──────────────────────────────────────────────────────────────
CREATE INDEX IF NOT EXISTS idx_telecom_entities_cores_username
ON telecom_entities(cores_username)
WHERE cores_username IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_telecom_entities_calea_next_review
ON telecom_entities(calea_ssi_next_review_date)
WHERE calea_ssi_next_review_date IS NOT NULL;

View file

@ -0,0 +1,75 @@
-- 053: Line 105 ranked multi-select taxonomy + category-specific metadata
--
-- The 2026 FCC Form 499-A Line 105 is an explicit multi-select (ranked 1-5)
-- across 22 carrier categories. Migration 038 stored `service_categories
-- TEXT[]` and `filer_type TEXT` but the handler treated Line 105 as a
-- single category. This migration replaces that with:
--
-- line_105_primary — single required primary category id
-- line_105_categories — JSONB array of {id, rank, infra_type, is_tdm_service}
--
-- Reseller handling: local_reseller and toll_reseller are NOT standalone
-- categories in our data model. A CLEC or IXC with `infra_type='reseller'`
-- triggers the corresponding Line 105 box being ticked automatically by
-- form_499a.py. Similarly an MVNO is wireless with infra_type='mvno'.
--
-- Category-specific metadata lives in dedicated JSONB columns so we can
-- evolve each without touching the shared table schema.
-- ── Line 105 primary + ranked multi-select ──────────────────────────────
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS line_105_primary TEXT;
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS line_105_categories JSONB NOT NULL DEFAULT '[]'::jsonb;
-- Example shape:
-- [{"id":"clec","rank":1,"infra_type":"reseller","is_tdm_service":false},
-- {"id":"ixc", "rank":2,"infra_type":"facilities"}]
--
-- GIN index for "find every filer that provides wireless service" etc.
CREATE INDEX IF NOT EXISTS idx_telecom_entities_line_105_categories
ON telecom_entities USING gin (line_105_categories jsonb_path_ops);
CREATE INDEX IF NOT EXISTS idx_telecom_entities_line_105_primary
ON telecom_entities(line_105_primary)
WHERE line_105_primary IS NOT NULL;
-- ── Category-specific metadata JSONB columns ────────────────────────────
-- wireless_meta shape:
-- { spectrum_bands: ["Lower 700 MHz", "PCS 1900"], host_mno: null,
-- post_paid_subs: 12000, pre_paid_subs: 3500,
-- cell_site_count: 42, msa_coverage: ["MSA-001","MSA-003"] }
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS wireless_meta JSONB;
-- satellite_meta shape:
-- { earth_station_license_ids: ["E123456"], orbital_slot: "W 73.0",
-- satellite_operator: "Intelsat", service_type: "FSS",
-- mss_us_subscribers: null }
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS satellite_meta JSONB;
-- audio_bridging_meta shape:
-- { platform: "proprietary", toll_free_accessible: true,
-- participant_min_revenue_cents: 1200000,
-- subscription_revenue_cents: 4500000 }
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS audio_bridging_meta JSONB;
-- private_line_circuits: one row per circuit.
-- [{id, bandwidth, endpoint_a:{city,state,country},
-- endpoint_b:{city,state,country}, monthly_revenue_cents}, ...]
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS private_line_circuits JSONB;
-- ── Backfill from the single-valued filer_type/service_categories ───────
-- Existing rows have filer_type populated (e.g., 'interconnected_voip',
-- 'clec'). Seed line_105_primary + line_105_categories from it so we
-- don't break live data. infra_type from 038 is also carried into the
-- JSONB entry.
UPDATE telecom_entities
SET line_105_primary = filer_type,
line_105_categories = jsonb_build_array(
jsonb_build_object(
'id', filer_type,
'rank', 1,
'infra_type', COALESCE(infra_type, 'facilities'),
'is_tdm_service', false
)
)
WHERE line_105_primary IS NULL
AND filer_type IS NOT NULL;

View file

@ -0,0 +1,98 @@
-- 054: 499-A form-fidelity columns + FCC reference tables
--
-- Migration 048 added most of Blocks 1/2/6 columns (affiliated_filer_*,
-- trade_names, officer 2+3, jurisdictions_served, first_telecom_service_*,
-- exempt_usf/trs/nanpa/lnp, is_state_local_gov, is_tax_exempt_501c,
-- regulatory_contact_*, itsp_regulatory_fee_email). Migration 038 gave us
-- ceo_name/ceo_title but no CEO address.
--
-- This migration closes the remaining gaps from the April 2026 audit of
-- the 2026 FCC Form 499-A Instructions (pdf at
-- docs/fcc-references/2026-FCC-Form-499A-Form-Instructions.pdf):
--
-- * Officer 1 business address (Lines 219-220 — required same as 2+3)
-- * entity_structure (drives officer count — sole-prop = 1, corp = 3)
-- * exempt_itsp (Line 603 — not in migration 048)
-- * nondisclosure_requested (Line 605)
-- * safe_harbor_election JSONB — per-category-per-quarter election state
-- * fcc_deminimis_factors reference table (Appendix A factor — 0.256 for 2026)
-- * fcc_safe_harbor_percentages reference table (64.9% VoIP / 37.1% cellular / 12.0% paging / 1.0% SMR for 2026)
-- ── Block 2-C: Officer 1 business address (CEO) ─────────────────────────
-- 2026 instructions Lines 219-226 require business address for every officer.
-- Officer 2 + 3 addresses were added in 048; Officer 1 / CEO was omitted.
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS officer_1_street TEXT;
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS officer_1_city TEXT;
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS officer_1_state TEXT;
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS officer_1_zip TEXT;
-- How many officers did the filer claim? Drives REQUIRED_FIELDS conditional
-- for officer 2 + 3 blocks. 1 = sole prop; 2-3 = corp/LLC/partnership.
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS officer_count_claimed SMALLINT
CHECK (officer_count_claimed IS NULL OR officer_count_claimed BETWEEN 1 AND 3);
-- Entity structure — drives officer requirements + Block 6 cert text.
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS entity_structure TEXT
CHECK (entity_structure IS NULL OR entity_structure IN (
'corp','llc','partnership','sole_prop','gov','nonprofit','other'
));
-- ── Block 6: ITSP exemption + nondisclosure request ─────────────────────
-- 048 gave us exempt_usf/trs/nanpa/lnp but not ITSP (per-revenue regulatory
-- fee — applies to interstate telecom service providers).
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS exempt_itsp BOOLEAN DEFAULT FALSE;
-- Line 605 — checkbox to request confidential treatment of revenue info.
-- Requires inline officer attestation text (not stored here, collected in Block6CertStep).
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS nondisclosure_requested BOOLEAN DEFAULT FALSE;
-- ── Safe harbor election state ──────────────────────────────────────────
-- Election must be consistent across affiliated filers per quarter per
-- category. Shape:
-- { voip_interconnected: {year:2025, q:"annual", method:"safe_harbor", pct:64.9},
-- wireless: {year:2025, q:"annual", method:"traffic_study"} }
-- method ∈ {safe_harbor, traffic_study, actual_data}
ALTER TABLE telecom_entities ADD COLUMN IF NOT EXISTS safe_harbor_election JSONB DEFAULT '{}'::jsonb;
-- ── De minimis calculator persistence ───────────────────────────────────
-- Written to the order on /validate + re-computed at filing time.
-- Appendix A worksheet result (11 lines) stored in full for audit.
ALTER TABLE compliance_orders ADD COLUMN IF NOT EXISTS deminimis_worksheet_json JSONB;
ALTER TABLE compliance_orders ADD COLUMN IF NOT EXISTS deminimis_estimated_contrib_cents BIGINT;
ALTER TABLE compliance_orders ADD COLUMN IF NOT EXISTS deminimis_result_is_exempt BOOLEAN;
-- ── Yearly de minimis factor lookup ─────────────────────────────────────
-- 2026 Form 499-A Appendix A Line 10 = 0.256 — applied to the projected
-- interstate+intl contribution base. Filer is exempt if resulting
-- estimated contribution < $10,000. Factor changes annually; parameterize
-- so 2027's release is a one-row insert.
CREATE TABLE IF NOT EXISTS fcc_deminimis_factors (
form_year SMALLINT PRIMARY KEY,
factor NUMERIC(6,4) NOT NULL,
threshold_usd INTEGER NOT NULL DEFAULT 10000,
source_citation TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
INSERT INTO fcc_deminimis_factors (form_year, factor, source_citation)
VALUES (2026, 0.2560, '2026 Form 499-A Appendix A Line 10')
ON CONFLICT (form_year) DO NOTHING;
-- ── Yearly safe harbor percentage lookup ────────────────────────────────
-- Per 2026 Form 499-A Section IV.C.5.g. Unchanged from prior years but
-- parameterized for future updates. Non-interconnected VoIP intentionally
-- omitted — has no safe harbor; code must refuse election.
CREATE TABLE IF NOT EXISTS fcc_safe_harbor_percentages (
form_year SMALLINT NOT NULL,
line_105_category TEXT NOT NULL,
interstate_pct NUMERIC(5,2) NOT NULL,
source_citation TEXT,
PRIMARY KEY (form_year, line_105_category)
);
INSERT INTO fcc_safe_harbor_percentages VALUES
(2026, 'voip_interconnected', 64.9, '2026 Form 499-A IV.C.5.g'),
(2026, 'cellular_pcs', 37.1, '2026 Form 499-A IV.C.5.g'),
(2026, 'paging', 12.0, '2026 Form 499-A IV.C.5.g'),
(2026, 'smr_dispatch', 1.0, '2026 Form 499-A IV.C.5.g')
ON CONFLICT (form_year, line_105_category) DO NOTHING;

View file

@ -0,0 +1,87 @@
-- 055: Reseller certifications + non-contributing reseller tracking
--
-- 2026 FCC Form 499-A Section IV.C.4: to claim revenue on Line 303
-- (carrier's carrier), the filer must have an annually signed
-- certification from each reseller customer that:
-- (a) the reseller is purchasing for resale, at least in part, AND
-- (b) the reseller (or a downstream entity) contributes to USF.
--
-- Sample certification language lives in
-- scripts/document_gen/templates/reseller_cert_attestation.docx.
--
-- Line 511 (Block 5) separately tracks revenues from resellers that
-- DO NOT contribute (de minimis, intl-only, government). These revenues
-- are excluded from TRS/NANPA/LNP/ITSP contribution bases.
-- ── Reseller certifications (drives Line 303 eligibility) ───────────────
CREATE TABLE IF NOT EXISTS reseller_certifications (
id BIGSERIAL PRIMARY KEY,
-- The filer (our customer — the upstream wholesale provider)
filer_telecom_entity_id INTEGER NOT NULL REFERENCES telecom_entities(id)
ON DELETE CASCADE,
-- The reseller (their customer — the downstream carrier buying wholesale)
reseller_filer_id_499 TEXT NOT NULL, -- USAC Filer ID (6-8 digit)
reseller_legal_name TEXT NOT NULL,
reseller_contact_name TEXT,
reseller_contact_email TEXT,
reseller_contact_phone TEXT,
reseller_legal_address JSONB, -- {street,city,state,zip,country}
-- The signed attestation
certification_date DATE NOT NULL,
certification_text TEXT NOT NULL, -- full signed body
certification_minio_path TEXT, -- signed PDF in MinIO
signer_name TEXT,
signer_title TEXT,
-- Renewal tracking — renewal_worker.py emails at T-30/14/7/1
renewal_due DATE NOT NULL, -- cert_date + 1 year
status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('active','expired','revoked')),
-- First reporting year this certification covers
reporting_year_first SMALLINT,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- A filer can have the same reseller with different cert dates
-- (annual renewals). Uniqueness is (filer, reseller, cert_date).
UNIQUE (filer_telecom_entity_id, reseller_filer_id_499, certification_date)
);
CREATE INDEX IF NOT EXISTS idx_reseller_certs_filer_renewal
ON reseller_certifications (filer_telecom_entity_id, renewal_due)
WHERE status = 'active';
CREATE INDEX IF NOT EXISTS idx_reseller_certs_reseller_id
ON reseller_certifications (reseller_filer_id_499);
CREATE INDEX IF NOT EXISTS idx_reseller_certs_renewal_due
ON reseller_certifications (renewal_due)
WHERE status = 'active';
-- ── Non-contributing reseller customers (Line 511) ──────────────────────
-- Separate table because these are reported on Line 511 (allowed deduction
-- from TRS/NANPA/LNP/ITSP bases) rather than supporting Line 303 revenue.
-- Every row requires the reseller's Filer ID per 2026 instructions.
CREATE TABLE IF NOT EXISTS non_contributing_reseller_customers (
id BIGSERIAL PRIMARY KEY,
filer_telecom_entity_id INTEGER NOT NULL REFERENCES telecom_entities(id)
ON DELETE CASCADE,
reseller_filer_id_499 TEXT NOT NULL,
reseller_legal_name TEXT NOT NULL,
non_contributing_reason TEXT NOT NULL
CHECK (non_contributing_reason IN (
'de_minimis','intl_only','government','other'
)),
revenue_cents BIGINT NOT NULL DEFAULT 0,
reporting_year SMALLINT NOT NULL,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (filer_telecom_entity_id, reseller_filer_id_499, reporting_year)
);
CREATE INDEX IF NOT EXISTS idx_nonctr_reseller_filer_year
ON non_contributing_reseller_customers (filer_telecom_entity_id, reporting_year);

View file

@ -0,0 +1,130 @@
-- 056: Inter-Carrier Compensation revenue import
--
-- Post-2011 USF/ICC Transformation Order most intrastate/interstate access
-- is bill-and-keep at $0.0007/min or $0. But real money still flows in:
-- 8YY originating access ($0.002-0.005/MOU), special access / BDS,
-- international settlements, wholesale SIP trunking, access stimulation.
--
-- These revenues belong on 499-A Lines 303, 404.x, 418 depending on
-- category. icc_499a_line_mapping captures the mapping.
--
-- Pipeline mirrors the CDR ingestion pattern:
-- customer uploads file → MinIO → icc_ingester worker → adapter →
-- icc_revenue_lines (deduped by natural_key_hash) → RevenueStep
-- prefills 499-A revenue lines.
-- ── Ingestion uploads (one row per uploaded file) ───────────────────────
CREATE TABLE IF NOT EXISTS icc_ingestion_uploads (
id BIGSERIAL PRIMARY KEY,
profile_id INTEGER NOT NULL REFERENCES cdr_ingestion_profiles(id)
ON DELETE CASCADE,
customer_id INTEGER NOT NULL REFERENCES customers(id),
source_format TEXT NOT NULL CHECK (source_format IN (
'cabs_bos', -- Bellcore Billing Output Specification (fixed-width)
'edi_810', -- X12 EDI 810 invoice
'8yy_qry', -- iconectiv 8YY Query Report (XML)
'itu_tas', -- ITU Telecommunications Accounting System
'icss', -- International Carrier Settlement System
'wholesale_sip_csv', -- Sangoma / Bandwidth / Flowroute / generic CSV
'carrier_invoice_pdf' -- pdfplumber stub (LLM tagging v2)
)),
raw_minio_path TEXT NOT NULL,
raw_sha256 TEXT NOT NULL UNIQUE,
rows_accepted INTEGER DEFAULT 0,
rows_rejected INTEGER DEFAULT 0,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','parsing','complete','failed')),
error_message TEXT,
summary_json JSONB, -- per-adapter details (counterparties, totals)
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
parsed_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_icc_uploads_profile_status
ON icc_ingestion_uploads (profile_id, status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_icc_uploads_pending
ON icc_ingestion_uploads (created_at)
WHERE status = 'pending';
-- ── Parsed revenue lines (one row per invoice line item) ────────────────
CREATE TABLE IF NOT EXISTS icc_revenue_lines (
id BIGSERIAL PRIMARY KEY,
profile_id INTEGER NOT NULL REFERENCES cdr_ingestion_profiles(id)
ON DELETE CASCADE,
reporting_year INTEGER NOT NULL,
reporting_quarter INTEGER CHECK (reporting_quarter IS NULL OR reporting_quarter BETWEEN 1 AND 4),
icc_category TEXT NOT NULL CHECK (icc_category IN (
'term_switched_access', -- terminating switched access (mostly bill-and-keep)
'orig_switched_access', -- originating switched access (billable)
'8yy_orig_access', -- 8YY originating access (iconectiv)
'transit', -- transit compensation
'special_access', -- special access / BDS (DS-1/DS-3/Ethernet carrier-to-carrier)
'intl_settlement', -- international carrier settlements
'wholesale_sip', -- wholesale SIP trunking
'access_stim', -- access stimulation (47 CFR 61.3(bbb))
'other'
)),
counterparty_legal_name TEXT,
counterparty_ocn TEXT, -- Operating Company Number
counterparty_country CHAR(2) NOT NULL DEFAULT 'US',
revenue_cents BIGINT NOT NULL, -- signed; negative = we owe them
minutes_of_use BIGINT,
source_upload_id BIGINT NOT NULL REFERENCES icc_ingestion_uploads(id)
ON DELETE CASCADE,
source_line_no INTEGER,
-- Dedup key — sha256(category|counterparty|period|amount|MOU)
natural_key_hash TEXT NOT NULL,
raw_row JSONB, -- whatever the adapter captured
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (profile_id, reporting_year, reporting_quarter, natural_key_hash)
);
CREATE INDEX IF NOT EXISTS idx_icc_lines_profile_year_cat
ON icc_revenue_lines (profile_id, reporting_year, icc_category);
CREATE INDEX IF NOT EXISTS idx_icc_lines_counterparty
ON icc_revenue_lines (counterparty_ocn, reporting_year)
WHERE counterparty_ocn IS NOT NULL;
-- ── ICC category → Form 499-A line mapping ──────────────────────────────
-- Consumed by RevenueStep prefill and form_499a.py LINE_FILL_MAP.
CREATE TABLE IF NOT EXISTS icc_499a_line_mapping (
icc_category TEXT PRIMARY KEY,
form_499a_line TEXT NOT NULL, -- '404', '404.1', '404.3', '418'
jurisdiction_split TEXT NOT NULL -- 'interstate' | 'intrastate' | 'international' | 'split_traffic'
CHECK (jurisdiction_split IN (
'interstate','intrastate','international','split_traffic'
))
);
INSERT INTO icc_499a_line_mapping VALUES
('term_switched_access', '404', 'split_traffic'),
('orig_switched_access', '404', 'split_traffic'),
('8yy_orig_access', '404.3', 'interstate'),
('transit', '404', 'split_traffic'),
('special_access', '404.1', 'split_traffic'),
('intl_settlement', '418', 'international'),
('wholesale_sip', '404', 'split_traffic'),
('access_stim', '404', 'split_traffic'),
('other', '418', 'split_traffic')
ON CONFLICT (icc_category) DO NOTHING;
-- ── Access-stimulation flag on CDR profile ──────────────────────────────
-- Set by scripts/workers/access_stim_monitor.py when the rolling 6-month
-- terminating:originating MOU ratio exceeds 3:1 (47 CFR 61.3(bbb) trigger).
ALTER TABLE cdr_ingestion_profiles
ADD COLUMN IF NOT EXISTS access_stim_flagged_at TIMESTAMPTZ;
ALTER TABLE cdr_ingestion_profiles
ADD COLUMN IF NOT EXISTS access_stim_evidence_json JSONB;

View file

@ -0,0 +1,86 @@
-- 057: Traffic study FCC compliance + LNPA region allocations
--
-- 2026 FCC Form 499-A Section IV.C.5.h: filers relying on a traffic study
-- must:
-- (a) Sample with 1% margin of error @ 95% confidence
-- (b) Submit the study to USAC alongside the 499-A filing
-- (c) Stamp every page with Filer ID + Company Name + Affiliated Filers Name
--
-- The existing cdr_traffic_studies table (migration 050) tracks studies
-- but lacks the FCC-compliance metadata (sample size, margin of error,
-- methodology narrative) and the stamped-PDF path + USAC submission ID.
-- Add them here.
--
-- Block 5 Lines 503-510 are percentage splits across 10 LNPA regions for
-- both Block 3 (resale) and Block 4 (end user) revenue. Must sum to 100%
-- per column (or 0% if no revenue in that block). Stored per
-- (entity, year, period, region).
-- ── Traffic study FCC-compliance metadata ───────────────────────────────
ALTER TABLE cdr_traffic_studies
ADD COLUMN IF NOT EXISTS methodology_narrative TEXT;
ALTER TABLE cdr_traffic_studies
ADD COLUMN IF NOT EXISTS sample_size INTEGER;
ALTER TABLE cdr_traffic_studies
ADD COLUMN IF NOT EXISTS margin_of_error_pct NUMERIC(4,2);
ALTER TABLE cdr_traffic_studies
ADD COLUMN IF NOT EXISTS confidence_level_pct NUMERIC(4,2) DEFAULT 95.0;
-- Stamped PDF (Filer ID + Company Name + Affiliated Filers Name on every page)
ALTER TABLE cdr_traffic_studies
ADD COLUMN IF NOT EXISTS stamped_pdf_minio_path TEXT;
-- USAC submission tracking
ALTER TABLE cdr_traffic_studies
ADD COLUMN IF NOT EXISTS usac_submitted_at TIMESTAMPTZ;
ALTER TABLE cdr_traffic_studies
ADD COLUMN IF NOT EXISTS usac_submission_id TEXT;
-- Computed flag — TRUE when sample_size + margin_of_error + methodology_narrative
-- + stamped_pdf_minio_path are all populated AND margin_of_error_pct ≤ 1.0
-- AND confidence_level_pct ≥ 95.0. Maintained in application code (not
-- generated column — we want explicit state).
ALTER TABLE cdr_traffic_studies
ADD COLUMN IF NOT EXISTS fcc_compliance_ok BOOLEAN DEFAULT FALSE;
-- ── LNPA region allocations (Block 5 Lines 503-510) ─────────────────────
-- The 10 NANPA/LNPA regions as defined by iconectiv NPAC. Stored per
-- reporting period so historical filings remain reconstructible.
CREATE TABLE IF NOT EXISTS lnpa_region_allocations (
id BIGSERIAL PRIMARY KEY,
telecom_entity_id INTEGER NOT NULL REFERENCES telecom_entities(id)
ON DELETE CASCADE,
reporting_year SMALLINT NOT NULL,
reporting_period TEXT NOT NULL DEFAULT 'annual'
CHECK (reporting_period IN ('annual','Q1','Q2','Q3','Q4')),
region_code TEXT NOT NULL CHECK (region_code IN (
'NE', -- Northeast: CT, MA, ME, NH, NY (parts), RI, VT
'MA', -- Mid-Atlantic: DC, DE, MD, NJ, PA, VA, WV
'SE', -- Southeast: AL, FL, GA, KY, MS, NC, SC, TN
'SC', -- Southern: AR, LA, NM, OK
'TX', -- Texas
'MW', -- Midwest: IL, IN, MI, OH, WI
'IA', -- Iowa / Plains: IA, KS, MN, MO, ND, NE, SD
'RM', -- Rocky Mountain: CO, MT, UT, WY, ID
'NW', -- Northwest: AK, OR, WA
'WC' -- West Coast: AZ, CA, HI, NV
)),
block_3_pct NUMERIC(5,2) NOT NULL DEFAULT 0 -- Lines 503-510 col (a)
CHECK (block_3_pct >= 0 AND block_3_pct <= 100),
block_4_pct NUMERIC(5,2) NOT NULL DEFAULT 0 -- Lines 503-510 col (b)
CHECK (block_4_pct >= 0 AND block_4_pct <= 100),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (telecom_entity_id, reporting_year, reporting_period, region_code)
);
CREATE INDEX IF NOT EXISTS idx_lnpa_region_entity_year
ON lnpa_region_allocations (telecom_entity_id, reporting_year);
-- Validator: the 10 rows for a given (entity, year, period) must sum to
-- 100% per column (or 0% if no revenue in that block). Not enforced at
-- DB level — checked in application code before submission to USAC.

View file

@ -0,0 +1,94 @@
-- 058: Past-due 499-A filings + revisions (amendments)
--
-- Three filing modes for the fcc-499a service:
-- 1. current — the default: annual 499-A for last calendar year
-- 2. past_due — late filing for a reporting year earlier than (current-1)
-- 3. revised — amendment to a previously-filed 499-A (Line 612
-- filing_type = revised_registration | revised_revenue)
--
-- USAC accepts all three through forms.universalservice.org; the
-- Playwright path differs (prior-year selector + penalty acknowledgment
-- for past-due; "Revise Filing" flow with confirmation # for revisions).
--
-- Past-due USF contributions are calculated at the *reporting year's*
-- contribution factor, not the current quarter's — so the handler must
-- use fcc_deminimis_factors[reporting_year] + the correct safe-harbor
-- row per year.
-- ── Compliance order fields ─────────────────────────────────────────────
ALTER TABLE compliance_orders
ADD COLUMN IF NOT EXISTS filing_mode TEXT NOT NULL DEFAULT 'current'
CHECK (filing_mode IN ('current','past_due','revised'));
-- form_year_override lets the filer target a year other than (now-1)
-- for past-due filings, or the original year for a revision.
ALTER TABLE compliance_orders
ADD COLUMN IF NOT EXISTS form_year_override SMALLINT
CHECK (form_year_override IS NULL OR form_year_override BETWEEN 2015 AND 2035);
-- revises_order_number: points at the prior compliance_order whose 499-A
-- this filing amends. The new order inherits intake_data + the prior
-- confirmation number (so USAC knows which filing to revise).
ALTER TABLE compliance_orders
ADD COLUMN IF NOT EXISTS revises_order_number TEXT;
-- Line 612 filing-type reason when filing_mode='revised'. USAC requires
-- this on the revised filing so the reviewer knows whether fields other
-- than revenue changed.
ALTER TABLE compliance_orders
ADD COLUMN IF NOT EXISTS revised_reason TEXT
CHECK (revised_reason IS NULL OR revised_reason IN ('registration','revenue','both'));
-- Prior-confirmation number recorded after a successful revision submit.
-- Links revised filings together so admin can see the full amendment chain.
ALTER TABLE compliance_orders
ADD COLUMN IF NOT EXISTS prior_confirmation_number TEXT;
-- Index on revises_order_number for "show all amendments of order X" queries
CREATE INDEX IF NOT EXISTS idx_compliance_orders_revises
ON compliance_orders(revises_order_number)
WHERE revises_order_number IS NOT NULL;
-- ── Penalty estimate persistence ────────────────────────────────────────
-- For past-due filings we estimate retroactive USF owed so the customer
-- knows the magnitude before clicking "File". FCC forfeitures are
-- separately assessed and NOT estimated here — only the retroactive
-- contribution.
ALTER TABLE compliance_orders
ADD COLUMN IF NOT EXISTS late_filing_retroactive_usf_cents BIGINT;
ALTER TABLE compliance_orders
ADD COLUMN IF NOT EXISTS late_filing_notes TEXT;
-- ── Prior-year de minimis factors ───────────────────────────────────────
-- Each calendar year's 499-A has its own Appendix A Line 10 factor. Past
-- years MUST be seeded before a past-due filing can be validated. Values
-- below are placeholders — before enabling a past-due filing for year N,
-- look up N's actual factor from USAC's 499-A instructions archive at
-- https://www.usac.org/service-providers/contributing-to-the-usf/forms-to-file/
-- and INSERT or UPDATE the correct value.
--
-- 2025 factor (for 2025 revenues filed on 2026-04-01): 0.2560 (already in 054).
-- Placeholder seeds for prior years — leave commented out until verified:
-- INSERT INTO fcc_deminimis_factors (form_year, factor, source_citation) VALUES
-- (2025, 0.xxxx, '2025 Form 499-A Appendix A'),
-- (2024, 0.xxxx, '2024 Form 499-A Appendix A'),
-- (2023, 0.xxxx, '2023 Form 499-A Appendix A')
-- ON CONFLICT (form_year) DO NOTHING;
-- ── Prior-year safe-harbor percentages ──────────────────────────────────
-- Safe harbor has been unchanged at 64.9% VoIP / 37.1% wireless /
-- 12.0% paging / 1.0% SMR since the 2006 Contribution Methodology
-- Reform Order. Seed prior years at the same values so handlers filing
-- past-due can look them up. If the FCC changes safe harbors in the
-- future, prior-year rows will still return correct historical values.
INSERT INTO fcc_safe_harbor_percentages (form_year, line_105_category, interstate_pct, source_citation)
SELECT y, c, v,
CONCAT(y::text, ' Form 499-A IV.C.5.g (back-seeded from 2006 Contribution Reform Order)')
FROM (VALUES
('voip_interconnected', 64.9),
('cellular_pcs', 37.1),
('paging', 12.0),
('smr_dispatch', 1.0)
) AS sh(c, v),
generate_series(2020, 2025) AS y
ON CONFLICT (form_year, line_105_category) DO NOTHING;

View file

@ -0,0 +1,30 @@
-- 059: Waive de minimis exemption
--
-- Background: A filer whose Appendix A worksheet shows they qualify as
-- de minimis is EXEMPT from contributing to USF (§ 54.706). But many
-- carriers — especially small VoIP resellers — have a business reason to
-- file as a regular contributing filer even when de minimis qualifies:
--
-- Upstream wholesale SIP providers charge USF surcharges on the trunk
-- bill. A regular contributing carrier can show the vendor their 499
-- Filer ID + a "Reseller Certification" (see table
-- reseller_certifications) and have the vendor waive wholesale-side
-- USF. A de minimis filer cannot — they eat the vendor's USF charge
-- because they don't contribute directly.
--
-- The economic break-even: when the carrier's own USF contribution
-- (interstate revenue * quarterly factor) is LESS than the USF
-- surcharge on wholesale trunking, filing as regular saves money
-- overall even though they'd otherwise be exempt.
--
-- So: give the filer an explicit opt-out. Set waive_deminimis_exemption=TRUE
-- and the handler will file a full 499-A with USF contribution obligations
-- even when the Appendix A calc shows de minimis status.
ALTER TABLE compliance_orders
ADD COLUMN IF NOT EXISTS waive_deminimis_exemption BOOLEAN NOT NULL DEFAULT FALSE;
-- Why they chose to waive — short free-text so the admin-review dashboard
-- and auditors can see the intent. Not required.
ALTER TABLE compliance_orders
ADD COLUMN IF NOT EXISTS waive_deminimis_reason TEXT;

View file

@ -0,0 +1,24 @@
-- 060: Multi-year filing orders
--
-- When a customer orders the same service slug (typically fcc-499a)
-- for multiple reporting years at once — e.g., catching up on 3 years
-- of past-due 499-A filings simultaneously — they receive a 15%
-- multi-year discount (same magnitude as the existing bundle discount
-- for multi-slug orders).
--
-- We represent this as a single compliance_order with a multi_year_filings
-- array; the handler runs N Playwright sessions against USAC, one per
-- year, and records N confirmation numbers.
ALTER TABLE compliance_orders
ADD COLUMN IF NOT EXISTS multi_year_filings INT[];
-- Per-year confirmation numbers captured after each sub-filing succeeds.
-- Shape: [{"year":2023,"confirmation":"X"},{"year":2024,"confirmation":"Y"}]
ALTER TABLE compliance_orders
ADD COLUMN IF NOT EXISTS multi_year_confirmations JSONB DEFAULT '[]'::jsonb;
-- Discount bookkeeping: when multi_year_filings has 2+ entries, the
-- resolver applies a 15% discount on top of the N × price base.
ALTER TABLE compliance_orders
ADD COLUMN IF NOT EXISTS multi_year_discount_pct NUMERIC(4,1);

View file

@ -0,0 +1,25 @@
-- 061: Fix de minimis + safe-harbor factor keying convention
--
-- Confusion: the "2026 Form 499-A" is the form released in November 2025
-- that reports revenue for calendar year 2025. Our UI stores
-- `form_year = reporting_year` (the year the revenue was earned), which
-- matches USAC's de-minimis-calculator convention. But migration 054
-- seeded the row as `form_year=2026` (the form's release year).
--
-- Fix: re-key the seed so reporting-year-2025 → factor 0.256. Keep the
-- 2026 row in place for anyone who keys by form-year; both rows point
-- at the same factor (since it's just the "filed April 1 2026" factor).
--
-- Going forward, the canonical convention is:
-- form_year = the reporting/revenue year (what the customer picked on
-- RevenueStep + what the handler stashes in intake.form_year)
INSERT INTO fcc_deminimis_factors (form_year, factor, source_citation)
VALUES (2025, 0.2560, 'For CY2025 revenue — 2026 Form 499-A Appendix A')
ON CONFLICT (form_year) DO UPDATE SET
factor = EXCLUDED.factor,
source_citation = EXCLUDED.source_citation;
-- The safe-harbor backfill in migration 058 already covers 2020-2025
-- at the same values (64.9 / 37.1 / 12.0 / 1.0), so no action needed
-- for the safe-harbor table.

View file

@ -0,0 +1,53 @@
-- 062: Crypto payment ledger — immutable money ledger
--
-- Every crypto→USD movement writes one row here: the initial SHKeeper
-- receive, any mid-path swaps (e.g. ETH→USDC), the Coinbase Prime
-- offramp, the Relay bank deposit, card authorizations + settlements,
-- and cold-wallet sweeps. Rows are never mutated after creation —
-- corrections use a `movement_type='adjustment'` or `'reversal'` entry
-- that links via related_ledger_id.
--
-- This table is the source of truth for IRS Form 8949 cost-basis
-- reconciliation: every `receive` captures acquired_at + fx_rate_usd
-- (cost basis); every `offramp` captures disposed_at + proceeds_usd.
CREATE TABLE IF NOT EXISTS crypto_payment_ledger (
id BIGSERIAL PRIMARY KEY,
order_id TEXT NOT NULL,
order_type TEXT NOT NULL, -- compliance | formation | canada_crtc | bundle
coin TEXT NOT NULL, -- BTC | ETH | MATIC | LTC | TRX | BNB | DOGE | USDC | USD
movement_type TEXT NOT NULL CHECK (movement_type IN (
'receive', -- customer → SHKeeper (ledger entry on webhook confirm)
'swap', -- within exchange, coin → coin (e.g., ETH → USDC)
'offramp', -- coin → USD via Coinbase Prime market-sell
'bank_deposit', -- USD arrival at RelayFi (matched from IMAP monitor)
'card_authorization', -- Relay debit card auth event (card_settlement follows)
'card_settlement', -- Relay debit card final posting
'sweep', -- hot → cold wallet transfer
'reversal', -- corrective entry undoing a prior row
'adjustment' -- manual accounting correction
)),
amount_coin NUMERIC(36,18), -- signed; +in, -out. NULL for USD-only rows.
amount_usd_cents BIGINT NOT NULL, -- signed USD-equivalent at entry time
fx_rate_usd NUMERIC(24,10), -- coin→USD rate captured at entry
basis_usd_cents BIGINT, -- cost basis for disposal rows (FIFO)
proceeds_usd_cents BIGINT, -- proceeds for disposal rows
acquired_at TIMESTAMPTZ, -- when this coin was originally received (receive rows)
disposed_at TIMESTAMPTZ, -- when this coin was sold (offramp rows)
provider TEXT, -- shkeeper | coinbase_prime | relay
provider_ref TEXT, -- tx hash | Prime order id | Relay tx id
provider_status TEXT,
state TEXT NOT NULL DEFAULT 'pending'
CHECK (state IN ('pending','confirmed','failed','reversed')),
idempotency_key TEXT UNIQUE, -- e.g. "shkeeper:<txid>" | "prime:<order>" | "sweep:<id>"
related_ledger_id BIGINT REFERENCES crypto_payment_ledger(id),
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_cpl_order ON crypto_payment_ledger (order_id);
CREATE INDEX IF NOT EXISTS idx_cpl_pending ON crypto_payment_ledger (state) WHERE state = 'pending';
CREATE INDEX IF NOT EXISTS idx_cpl_disposal ON crypto_payment_ledger (disposed_at) WHERE disposed_at IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_cpl_movement_type ON crypto_payment_ledger (movement_type);
CREATE INDEX IF NOT EXISTS idx_cpl_provider_ref ON crypto_payment_ledger (provider, provider_ref) WHERE provider_ref IS NOT NULL;

View file

@ -0,0 +1,49 @@
-- 063: Vendor obligations — what each order needs to pay out
--
-- The sizer (scripts/workers/crypto_offramp/sizer.py) writes one row per
-- downstream payment the order incurs. Two kinds:
--
-- filing_fee: paid via Relay debit card via the existing Playwright
-- filing flow. Amount includes 10% buffer for fees/slippage.
-- Links to filing_fee_reservations once funds land at Relay.
--
-- commission: paid via Relay ACH 14 days post-delivery when the
-- corresponding commission_ledger row flips to 'eligible'.
-- Amount is exact (no buffer) — agent is owed a specific
-- fixed dollar amount. Links to commission_ledger so the
-- admin dashboard can show "this $X at Relay is reserved
-- for agent Y on order Z".
--
-- The orchestrator sums pending obligations × their buffer to compute
-- needed_usd_cents for the Coinbase Prime offramp.
CREATE TABLE IF NOT EXISTS vendor_obligations (
id BIGSERIAL PRIMARY KEY,
order_id TEXT NOT NULL,
order_type TEXT NOT NULL,
obligation_kind TEXT NOT NULL DEFAULT 'filing_fee'
CHECK (obligation_kind IN ('filing_fee','commission')),
vendor TEXT NOT NULL,
-- filing_fee: state_sos | usac | fcc_cores | nwra | neca_ocn | amb | bc_registry
-- commission: agent_commission
vendor_detail TEXT,
-- state code ('CA') or portal slug ('USAC-499A')
-- for commissions: the sales_agents.id as text
amount_usd_cents BIGINT NOT NULL,
fx_buffer_bps INT NOT NULL DEFAULT 1000,
-- 1000 bps = 10% default for filing_fee
-- 0 for commission (exact amount)
due_by TIMESTAMPTZ,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','reserved','paid','waived')),
filing_fee_reservation_id INT REFERENCES filing_fee_reservations(id),
commission_ledger_id INT REFERENCES commission_ledger(id),
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_vo_order ON vendor_obligations (order_id);
CREATE INDEX IF NOT EXISTS idx_vo_pending ON vendor_obligations (status) WHERE status = 'pending';
CREATE INDEX IF NOT EXISTS idx_vo_kind ON vendor_obligations (obligation_kind, status);
CREATE INDEX IF NOT EXISTS idx_vo_due_by ON vendor_obligations (due_by) WHERE due_by IS NOT NULL AND status != 'paid';

View file

@ -0,0 +1,44 @@
-- 064: Cold wallet config + sweep audit trail
--
-- Hardware / multisig wallet already exists off-platform. We only need:
-- (a) a place to record the expected address + checksum per coin so
-- the sweeper can abort on startup if env vars drift to an
-- attacker-controlled address
-- (b) audit rows for every sweep we initiate (with approval state)
--
-- Seed data: rows are populated at worker startup from
-- COLD_WALLET_<COIN>_ADDR env vars; startup validates address format
-- per coin (BIP-173 for BTC, EIP-55 for ETH/MATIC/BNB, base58 for
-- LTC/DOGE, bech32 for TRX).
CREATE TABLE IF NOT EXISTS cold_wallet_config (
coin TEXT PRIMARY KEY,
cold_address TEXT NOT NULL,
address_checksum TEXT NOT NULL, -- SHA-256 of the normalized address; validated at startup
hot_float_usd_cents BIGINT NOT NULL DEFAULT 50000, -- keep ~$500 hot per coin
auto_sweep_ceiling_usd_cents BIGINT NOT NULL DEFAULT 500000, -- auto-sweep ≤ $5,000
enabled BOOLEAN NOT NULL DEFAULT TRUE,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS cold_wallet_sweeps (
id BIGSERIAL PRIMARY KEY,
coin TEXT NOT NULL,
amount_coin NUMERIC(36,18) NOT NULL,
amount_usd_cents BIGINT,
cold_address TEXT NOT NULL,
shkeeper_withdraw_id TEXT, -- ID returned by SHKeeper's payout API
tx_hash TEXT, -- on-chain tx hash once broadcast
requires_approval BOOLEAN NOT NULL, -- TRUE when amount_usd > auto_sweep_ceiling
approved_by TEXT,
approved_at TIMESTAMPTZ,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','approved','broadcast','confirmed','failed')),
failure_reason TEXT,
ledger_entry_id BIGINT REFERENCES crypto_payment_ledger(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_cws_status ON cold_wallet_sweeps (status) WHERE status IN ('pending','approved','broadcast');
CREATE INDEX IF NOT EXISTS idx_cws_coin ON cold_wallet_sweeps (coin, created_at DESC);

View file

@ -0,0 +1,67 @@
-- 065: Crypto payment orchestrator jobs + Relay deposit source tagging
--
-- `crypto_payment_jobs` is the worker's mutable state machine (one row
-- per crypto-paid order). `crypto_payment_ledger` is immutable audit;
-- `crypto_payment_jobs` is where the worker records state transitions.
--
-- Also adds `source_kind` to `relay_deposits` so the matcher can tell
-- "this $X deposit was our Coinbase Prime offramp" vs "this was a
-- Stripe ACH batch" vs "something else — operator review".
CREATE TABLE IF NOT EXISTS crypto_payment_jobs (
order_id TEXT PRIMARY KEY,
order_type TEXT NOT NULL,
state TEXT NOT NULL DEFAULT 'received'
CHECK (state IN (
'received', -- SHKeeper webhook in, job enqueued
'sizing', -- computing vendor_obligations
'offramping', -- Coinbase Prime sell + wire initiated
'funds_at_relay', -- USD landed in RelayFi account
'ready', -- Playwright filing flow can now charge the card
'settled', -- reservation spent, order complete
'failed', -- attempt_count exhausted
'manual' -- admin intervention required
)),
coin TEXT NOT NULL,
amount_coin NUMERIC(36,18) NOT NULL,
amount_usd_cents BIGINT NOT NULL, -- SHKeeper balance_fiat at receipt
needed_usd_cents BIGINT, -- sizer output: filing + 10% + commission
offramp_provider TEXT DEFAULT 'coinbase_prime',
offramp_ref TEXT, -- Coinbase Prime order id / transfer id
relay_deposit_id INT REFERENCES relay_deposits(id),
target_card_id TEXT DEFAULT 'RELAY_FILING_CARD_ID',
last_error TEXT,
attempt_count INT NOT NULL DEFAULT 0,
next_retry_at TIMESTAMPTZ,
idempotency_key TEXT UNIQUE, -- "shkeeper-settle:<order>:<first-txid>"
received_at TIMESTAMPTZ,
sized_at TIMESTAMPTZ,
offramping_at TIMESTAMPTZ,
funds_at_relay_at TIMESTAMPTZ,
settled_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_cpj_active
ON crypto_payment_jobs (state)
WHERE state NOT IN ('settled','failed');
CREATE INDEX IF NOT EXISTS idx_cpj_retry
ON crypto_payment_jobs (next_retry_at)
WHERE state NOT IN ('settled','failed','manual') AND next_retry_at IS NOT NULL;
-- ── Extend relay_deposits for source tagging ──────────────────────────
-- Parsed from the Relay deposit notification email body/sender. Values:
-- 'stripe_ach' — Stripe customer payments batched to Relay
-- 'offramp_coinbase_prime' — our own Coinbase Prime → Relay wire/RTP
-- 'internal_transfer' — between our own accounts
-- 'unknown' — operator review required
ALTER TABLE relay_deposits ADD COLUMN IF NOT EXISTS source_kind TEXT;
ALTER TABLE relay_deposits ADD COLUMN IF NOT EXISTS memo TEXT;
-- memo captures the beneficiary memo field (e.g., 'PW-ORDER-FO-2026-0042')
-- so the matcher can find the corresponding crypto_payment_jobs row.
CREATE INDEX IF NOT EXISTS idx_relay_deposits_unmatched_offramp
ON relay_deposits (detected_at DESC)
WHERE source_kind = 'offramp_coinbase_prime' AND processed = FALSE;

View file

@ -0,0 +1,345 @@
-- 066_jurisdictions_and_foreign_qual.sql
--
-- Multi-jurisdiction expansion + foreign qualification support.
--
-- Context:
-- Migration 002 seeded `state_filing_fees` with 50 US states + DC and
-- created `formation_orders` constrained to US entity types. BC has
-- been bolted on for CRTC work, but there's no unified jurisdiction
-- model and no way to order a Certificate of Authority in one or
-- more states from a formation-only OR FCC multi-state client.
--
-- This migration:
--
-- 1. Creates a unified `jurisdictions` table (all US states + DC + CA
-- provinces) with country, currency, portal URLs, and a canonical
-- entity-type catalog per jurisdiction. Acts as the single source of
-- truth read by the Python JurisdictionConfig abstraction.
-- 2. Expands `formation_orders` to accept Canadian entity types + a
-- `country` column, without breaking existing US orders.
-- 3. Adds `province` to `canada_crtc_orders` (defaulting to 'BC' for
-- all existing rows so the BC pipeline keeps working).
-- 4. Creates `foreign_qualification_registrations` tracking every
-- Certificate of Authority / Foreign Qualification filing, with a
-- lifecycle independent of formation orders (so FCC clients can
-- order bulk state registration against an already-formed entity).
-- 5. Adds convenience views for the admin dashboard.
BEGIN;
-- ──────────────────────────────────────────────────────────────────────
-- 1. Canonical jurisdiction registry
-- ──────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS jurisdictions (
code CHAR(2) PRIMARY KEY, -- e.g. 'WY', 'BC', 'ON', 'DC'
name TEXT NOT NULL, -- 'Wyoming', 'British Columbia'
country CHAR(2) NOT NULL CHECK (country IN ('US', 'CA')),
kind TEXT NOT NULL CHECK (kind IN ('state', 'district', 'province', 'territory')),
currency CHAR(3) NOT NULL DEFAULT 'USD' CHECK (currency IN ('USD', 'CAD')),
timezone TEXT, -- 'America/Chicago' etc.
-- Portal info for formation + foreign qualification
portal_name TEXT, -- 'WYBiz', 'COLIN', 'OBR'
portal_url TEXT,
portal_login_required BOOLEAN NOT NULL DEFAULT FALSE,
-- Entity type catalog the jurisdiction recognizes — used by the
-- intake wizard to present the right options.
entity_types_json JSONB NOT NULL DEFAULT '[]'::jsonb,
-- e.g. [{"code":"llc","label":"LLC"},{"code":"corp","label":"Corporation"}]
-- [{"code":"ltd","label":"Limited Company"},{"code":"inc","label":"Incorporated"}]
-- Whether we support foreign qualification filings into this
-- jurisdiction. Independent of whether we can *form* here.
supports_foreign_qualification BOOLEAN NOT NULL DEFAULT TRUE,
foreign_qual_portal_url TEXT, -- often different endpoint
foreign_qual_requires_coa BOOLEAN NOT NULL DEFAULT TRUE,
-- Whether the jurisdiction requires a Certificate of Good Standing
-- from the home state to accompany the foreign qualification.
-- NW Registered Agent wholesale pricing (integer cents)
nwra_foreign_qual_wholesale_cents INTEGER,
-- Ops notes + audit
notes TEXT,
last_verified DATE DEFAULT CURRENT_DATE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_jurisdictions_country ON jurisdictions(country);
CREATE INDEX IF NOT EXISTS idx_jurisdictions_kind ON jurisdictions(kind);
-- Seed US states + DC. Entity-type catalog is the same everywhere in
-- the US (LLC, C-corp, S-corp, Nonprofit, LP/LLP) — jurisdictions that
-- diverge (e.g., closely-held corp, professional corp) can override via
-- update post-seed.
INSERT INTO jurisdictions (code, name, country, kind, currency, timezone, entity_types_json, supports_foreign_qualification)
VALUES
('AL','Alabama', 'US','state','USD','America/Chicago', '[{"code":"llc","label":"LLC"},{"code":"corporation","label":"Corporation"},{"code":"s_corp","label":"S-Corp"}]'::jsonb, TRUE),
('AK','Alaska', 'US','state','USD','America/Anchorage', '[{"code":"llc","label":"LLC"},{"code":"corporation","label":"Corporation"},{"code":"s_corp","label":"S-Corp"}]'::jsonb, TRUE),
('AZ','Arizona', 'US','state','USD','America/Phoenix', '[{"code":"llc","label":"LLC"},{"code":"corporation","label":"Corporation"},{"code":"s_corp","label":"S-Corp"}]'::jsonb, TRUE),
('AR','Arkansas', 'US','state','USD','America/Chicago', '[{"code":"llc","label":"LLC"},{"code":"corporation","label":"Corporation"},{"code":"s_corp","label":"S-Corp"}]'::jsonb, TRUE),
('CA','California', 'US','state','USD','America/Los_Angeles','[{"code":"llc","label":"LLC"},{"code":"corporation","label":"Corporation"},{"code":"s_corp","label":"S-Corp"}]'::jsonb, TRUE),
('CO','Colorado', 'US','state','USD','America/Denver', '[{"code":"llc","label":"LLC"},{"code":"corporation","label":"Corporation"},{"code":"s_corp","label":"S-Corp"}]'::jsonb, TRUE),
('CT','Connecticut', 'US','state','USD','America/New_York', '[{"code":"llc","label":"LLC"},{"code":"corporation","label":"Corporation"},{"code":"s_corp","label":"S-Corp"}]'::jsonb, TRUE),
('DE','Delaware', 'US','state','USD','America/New_York', '[{"code":"llc","label":"LLC"},{"code":"corporation","label":"Corporation"},{"code":"s_corp","label":"S-Corp"}]'::jsonb, TRUE),
('FL','Florida', 'US','state','USD','America/New_York', '[{"code":"llc","label":"LLC"},{"code":"corporation","label":"Corporation"},{"code":"s_corp","label":"S-Corp"}]'::jsonb, TRUE),
('GA','Georgia', 'US','state','USD','America/New_York', '[{"code":"llc","label":"LLC"},{"code":"corporation","label":"Corporation"},{"code":"s_corp","label":"S-Corp"}]'::jsonb, TRUE),
('HI','Hawaii', 'US','state','USD','Pacific/Honolulu', '[{"code":"llc","label":"LLC"},{"code":"corporation","label":"Corporation"},{"code":"s_corp","label":"S-Corp"}]'::jsonb, TRUE),
('ID','Idaho', 'US','state','USD','America/Boise', '[{"code":"llc","label":"LLC"},{"code":"corporation","label":"Corporation"},{"code":"s_corp","label":"S-Corp"}]'::jsonb, TRUE),
('IL','Illinois', 'US','state','USD','America/Chicago', '[{"code":"llc","label":"LLC"},{"code":"corporation","label":"Corporation"},{"code":"s_corp","label":"S-Corp"}]'::jsonb, TRUE),
('IN','Indiana', 'US','state','USD','America/Indianapolis','[{"code":"llc","label":"LLC"},{"code":"corporation","label":"Corporation"},{"code":"s_corp","label":"S-Corp"}]'::jsonb, TRUE),
('IA','Iowa', 'US','state','USD','America/Chicago', '[{"code":"llc","label":"LLC"},{"code":"corporation","label":"Corporation"},{"code":"s_corp","label":"S-Corp"}]'::jsonb, TRUE),
('KS','Kansas', 'US','state','USD','America/Chicago', '[{"code":"llc","label":"LLC"},{"code":"corporation","label":"Corporation"},{"code":"s_corp","label":"S-Corp"}]'::jsonb, TRUE),
('KY','Kentucky', 'US','state','USD','America/New_York', '[{"code":"llc","label":"LLC"},{"code":"corporation","label":"Corporation"},{"code":"s_corp","label":"S-Corp"}]'::jsonb, TRUE),
('LA','Louisiana', 'US','state','USD','America/Chicago', '[{"code":"llc","label":"LLC"},{"code":"corporation","label":"Corporation"},{"code":"s_corp","label":"S-Corp"}]'::jsonb, TRUE),
('ME','Maine', 'US','state','USD','America/New_York', '[{"code":"llc","label":"LLC"},{"code":"corporation","label":"Corporation"},{"code":"s_corp","label":"S-Corp"}]'::jsonb, TRUE),
('MD','Maryland', 'US','state','USD','America/New_York', '[{"code":"llc","label":"LLC"},{"code":"corporation","label":"Corporation"},{"code":"s_corp","label":"S-Corp"}]'::jsonb, TRUE),
('MA','Massachusetts', 'US','state','USD','America/New_York', '[{"code":"llc","label":"LLC"},{"code":"corporation","label":"Corporation"},{"code":"s_corp","label":"S-Corp"}]'::jsonb, TRUE),
('MI','Michigan', 'US','state','USD','America/Detroit', '[{"code":"llc","label":"LLC"},{"code":"corporation","label":"Corporation"},{"code":"s_corp","label":"S-Corp"}]'::jsonb, TRUE),
('MN','Minnesota', 'US','state','USD','America/Chicago', '[{"code":"llc","label":"LLC"},{"code":"corporation","label":"Corporation"},{"code":"s_corp","label":"S-Corp"}]'::jsonb, TRUE),
('MS','Mississippi', 'US','state','USD','America/Chicago', '[{"code":"llc","label":"LLC"},{"code":"corporation","label":"Corporation"},{"code":"s_corp","label":"S-Corp"}]'::jsonb, TRUE),
('MO','Missouri', 'US','state','USD','America/Chicago', '[{"code":"llc","label":"LLC"},{"code":"corporation","label":"Corporation"},{"code":"s_corp","label":"S-Corp"}]'::jsonb, TRUE),
('MT','Montana', 'US','state','USD','America/Denver', '[{"code":"llc","label":"LLC"},{"code":"corporation","label":"Corporation"},{"code":"s_corp","label":"S-Corp"}]'::jsonb, TRUE),
('NE','Nebraska', 'US','state','USD','America/Chicago', '[{"code":"llc","label":"LLC"},{"code":"corporation","label":"Corporation"},{"code":"s_corp","label":"S-Corp"}]'::jsonb, TRUE),
('NV','Nevada', 'US','state','USD','America/Los_Angeles','[{"code":"llc","label":"LLC"},{"code":"corporation","label":"Corporation"},{"code":"s_corp","label":"S-Corp"}]'::jsonb, TRUE),
('NH','New Hampshire', 'US','state','USD','America/New_York', '[{"code":"llc","label":"LLC"},{"code":"corporation","label":"Corporation"},{"code":"s_corp","label":"S-Corp"}]'::jsonb, TRUE),
('NJ','New Jersey', 'US','state','USD','America/New_York', '[{"code":"llc","label":"LLC"},{"code":"corporation","label":"Corporation"},{"code":"s_corp","label":"S-Corp"}]'::jsonb, TRUE),
('NM','New Mexico', 'US','state','USD','America/Denver', '[{"code":"llc","label":"LLC"},{"code":"corporation","label":"Corporation"},{"code":"s_corp","label":"S-Corp"}]'::jsonb, TRUE),
('NY','New York', 'US','state','USD','America/New_York', '[{"code":"llc","label":"LLC"},{"code":"corporation","label":"Corporation"},{"code":"s_corp","label":"S-Corp"}]'::jsonb, TRUE),
('NC','North Carolina','US','state','USD','America/New_York', '[{"code":"llc","label":"LLC"},{"code":"corporation","label":"Corporation"},{"code":"s_corp","label":"S-Corp"}]'::jsonb, TRUE),
('ND','North Dakota', 'US','state','USD','America/Chicago', '[{"code":"llc","label":"LLC"},{"code":"corporation","label":"Corporation"},{"code":"s_corp","label":"S-Corp"}]'::jsonb, TRUE),
('OH','Ohio', 'US','state','USD','America/New_York', '[{"code":"llc","label":"LLC"},{"code":"corporation","label":"Corporation"},{"code":"s_corp","label":"S-Corp"}]'::jsonb, TRUE),
('OK','Oklahoma', 'US','state','USD','America/Chicago', '[{"code":"llc","label":"LLC"},{"code":"corporation","label":"Corporation"},{"code":"s_corp","label":"S-Corp"}]'::jsonb, TRUE),
('OR','Oregon', 'US','state','USD','America/Los_Angeles','[{"code":"llc","label":"LLC"},{"code":"corporation","label":"Corporation"},{"code":"s_corp","label":"S-Corp"}]'::jsonb, TRUE),
('PA','Pennsylvania', 'US','state','USD','America/New_York', '[{"code":"llc","label":"LLC"},{"code":"corporation","label":"Corporation"},{"code":"s_corp","label":"S-Corp"}]'::jsonb, TRUE),
('RI','Rhode Island', 'US','state','USD','America/New_York', '[{"code":"llc","label":"LLC"},{"code":"corporation","label":"Corporation"},{"code":"s_corp","label":"S-Corp"}]'::jsonb, TRUE),
('SC','South Carolina','US','state','USD','America/New_York', '[{"code":"llc","label":"LLC"},{"code":"corporation","label":"Corporation"},{"code":"s_corp","label":"S-Corp"}]'::jsonb, TRUE),
('SD','South Dakota', 'US','state','USD','America/Chicago', '[{"code":"llc","label":"LLC"},{"code":"corporation","label":"Corporation"},{"code":"s_corp","label":"S-Corp"}]'::jsonb, TRUE),
('TN','Tennessee', 'US','state','USD','America/Chicago', '[{"code":"llc","label":"LLC"},{"code":"corporation","label":"Corporation"},{"code":"s_corp","label":"S-Corp"}]'::jsonb, TRUE),
('TX','Texas', 'US','state','USD','America/Chicago', '[{"code":"llc","label":"LLC"},{"code":"corporation","label":"Corporation"},{"code":"s_corp","label":"S-Corp"}]'::jsonb, TRUE),
('UT','Utah', 'US','state','USD','America/Denver', '[{"code":"llc","label":"LLC"},{"code":"corporation","label":"Corporation"},{"code":"s_corp","label":"S-Corp"}]'::jsonb, TRUE),
('VT','Vermont', 'US','state','USD','America/New_York', '[{"code":"llc","label":"LLC"},{"code":"corporation","label":"Corporation"},{"code":"s_corp","label":"S-Corp"}]'::jsonb, TRUE),
('VA','Virginia', 'US','state','USD','America/New_York', '[{"code":"llc","label":"LLC"},{"code":"corporation","label":"Corporation"},{"code":"s_corp","label":"S-Corp"}]'::jsonb, TRUE),
('WA','Washington', 'US','state','USD','America/Los_Angeles','[{"code":"llc","label":"LLC"},{"code":"corporation","label":"Corporation"},{"code":"s_corp","label":"S-Corp"}]'::jsonb, TRUE),
('WV','West Virginia', 'US','state','USD','America/New_York', '[{"code":"llc","label":"LLC"},{"code":"corporation","label":"Corporation"},{"code":"s_corp","label":"S-Corp"}]'::jsonb, TRUE),
('WI','Wisconsin', 'US','state','USD','America/Chicago', '[{"code":"llc","label":"LLC"},{"code":"corporation","label":"Corporation"},{"code":"s_corp","label":"S-Corp"}]'::jsonb, TRUE),
('WY','Wyoming', 'US','state','USD','America/Denver', '[{"code":"llc","label":"LLC"},{"code":"corporation","label":"Corporation"},{"code":"s_corp","label":"S-Corp"}]'::jsonb, TRUE),
('DC','District of Columbia','US','district','USD','America/New_York','[{"code":"llc","label":"LLC"},{"code":"corporation","label":"Corporation"},{"code":"s_corp","label":"S-Corp"}]'::jsonb, TRUE),
-- Canadian provinces: entity_types differ (ltd/inc/corp, sole prop, cooperative)
('BC','British Columbia','CA','province','CAD','America/Vancouver','[{"code":"ltd","label":"Limited Company (Ltd.)"},{"code":"inc","label":"Incorporated (Inc.)"},{"code":"corp","label":"Corporation (Corp.)"}]'::jsonb, TRUE),
('ON','Ontario', 'CA','province','CAD','America/Toronto', '[{"code":"ltd","label":"Limited Company (Ltd.)"},{"code":"inc","label":"Incorporated (Inc.)"},{"code":"corp","label":"Corporation (Corp.)"}]'::jsonb, TRUE),
('AB','Alberta', 'CA','province','CAD','America/Edmonton', '[{"code":"ltd","label":"Limited Company (Ltd.)"},{"code":"inc","label":"Incorporated (Inc.)"},{"code":"corp","label":"Corporation (Corp.)"}]'::jsonb, FALSE),
('QC','Quebec', 'CA','province','CAD','America/Montreal', '[{"code":"inc","label":"Incorporated (Inc.)"}]'::jsonb, FALSE)
ON CONFLICT (code) DO NOTHING;
-- Populate portal fields from state_filing_fees where available. This
-- keeps the two tables in sync without forcing a second seed pass.
UPDATE jurisdictions j
SET portal_name = f.portal_name,
portal_url = f.portal_url
FROM state_filing_fees f
WHERE j.code = f.state_code
AND j.portal_name IS NULL;
-- ──────────────────────────────────────────────────────────────────────
-- 2. formation_orders: expand entity_type + add country
-- ──────────────────────────────────────────────────────────────────────
ALTER TABLE formation_orders
ADD COLUMN IF NOT EXISTS country CHAR(2) NOT NULL DEFAULT 'US'
CHECK (country IN ('US', 'CA'));
-- Drop the old entity_type CHECK (which limited us to LLC/corp/s_corp)
-- and replace with one that allows Canadian types. Keep the data — no
-- rewrites.
ALTER TABLE formation_orders
DROP CONSTRAINT IF EXISTS formation_orders_entity_type_check;
ALTER TABLE formation_orders
ADD CONSTRAINT formation_orders_entity_type_check
CHECK (entity_type IN (
'llc', 'corporation', 's_corp', 'c_corp', 'nonprofit',
'ltd', 'inc', 'corp', -- Canadian
'lp', 'llp', -- partnerships
'pllc', 'pc' -- professional
));
-- Convenience: derive country from jurisdictions when a row is inserted
-- without an explicit country (backfill support).
UPDATE formation_orders
SET country = COALESCE(j.country, 'US')
FROM jurisdictions j
WHERE formation_orders.state_code = j.code
AND formation_orders.country = 'US'; -- only touch default rows
-- ──────────────────────────────────────────────────────────────────────
-- 3. canada_crtc_orders: province column
-- ──────────────────────────────────────────────────────────────────────
ALTER TABLE canada_crtc_orders
ADD COLUMN IF NOT EXISTS province CHAR(2) NOT NULL DEFAULT 'BC'
CHECK (province IN ('BC','ON','AB','QC','MB','SK','NS','NB','NL','PE'));
CREATE INDEX IF NOT EXISTS idx_canada_crtc_orders_province
ON canada_crtc_orders(province);
-- ──────────────────────────────────────────────────────────────────────
-- 4. foreign_qualification_registrations
-- ──────────────────────────────────────────────────────────────────────
--
-- A foreign qualification (aka "Certificate of Authority") is what a
-- company formed in State A files in State B to be allowed to do
-- business in State B. FCC carriers typically need one per state they
-- serve; regular formation clients may add a few as an expansion.
--
-- This table tracks each per-state filing as its own lifecycle, but all
-- filings for one order share a single parent compliance_orders row.
-- That way a 20-state FCC order is one checkout + one invoice with 20
-- independent filings + state fee obligations.
--
-- Why not reuse formation_orders: formation_orders is scoped to creating
-- a NEW entity; foreign qualification operates on an EXISTING entity.
-- Different schema (no entity_name_alt, no members, etc.), different
-- lifecycle.
CREATE TABLE IF NOT EXISTS foreign_qualification_registrations (
id BIGSERIAL PRIMARY KEY,
-- Parent order (compliance_orders row; one order can fan out to
-- many states).
compliance_order_id INTEGER REFERENCES compliance_orders(id) ON DELETE SET NULL,
order_number TEXT NOT NULL,
-- Linked existing entity (if ordered on an already-registered
-- carrier). For FCC clients this is telecom_entities.id. For
-- non-FCC formation expansion, nullable with legal_name /
-- home_state_code filled in directly.
telecom_entity_id INTEGER, -- FK enforced softly via app logic
-- Source / home jurisdiction — where the entity was originally formed.
home_country CHAR(2) NOT NULL DEFAULT 'US'
CHECK (home_country IN ('US','CA')),
home_state_code CHAR(2) NOT NULL,
-- Target jurisdiction — where we're filing the COA.
target_state_code CHAR(2) NOT NULL,
-- Entity info captured at order time. We don't trust telecom_entities
-- to be complete so we snapshot it here, mirroring how compliance
-- orders snapshot legal_name / address.
entity_legal_name TEXT NOT NULL,
entity_type TEXT NOT NULL, -- 'llc' | 'corporation' | etc.
formed_on DATE, -- original formation date
home_state_filing_number TEXT, -- original charter #
ein TEXT,
-- Principal + target-state address
principal_address_json JSONB, -- {street, city, state, zip}
registered_agent_name TEXT, -- RA in the TARGET state
registered_agent_address_json JSONB,
include_ra_service BOOLEAN NOT NULL DEFAULT TRUE,
-- NWRA provisioning — we resell their address as RA-in-target-state.
-- Money. Cents convention — target state fee + service fee + NWRA
-- wholesale cost; retail_total_cents matches the compliance_order
-- line for this state.
state_fee_cents INTEGER NOT NULL DEFAULT 0,
expedited BOOLEAN NOT NULL DEFAULT FALSE,
expedited_fee_cents INTEGER NOT NULL DEFAULT 0,
nwra_wholesale_cents INTEGER NOT NULL DEFAULT 0,
service_fee_cents INTEGER NOT NULL DEFAULT 0,
retail_total_cents INTEGER NOT NULL DEFAULT 0,
-- Documents required for the target state's filing (varies widely):
-- good_standing: certificate of good standing from home state
-- articles: articles of incorporation from home state
-- name_reservation: if target state requires one
-- publication: e.g. NY, AZ, NE require newspaper publication
supporting_docs_json JSONB NOT NULL DEFAULT '{}'::jsonb,
-- Pipeline state per filing.
status TEXT NOT NULL DEFAULT 'received' CHECK (status IN (
'received', -- paid, queued
'docs_pending', -- waiting on good-standing cert
'filing', -- portal automation running
'admin_review', -- manual step needed
'submitted', -- filed, awaiting state approval
'approved', -- COA issued
'rejected',
'cancelled'
)),
state_filing_number TEXT, -- returned by portal
state_confirmation_url TEXT,
filed_at TIMESTAMPTZ,
approved_at TIMESTAMPTZ,
coa_minio_path TEXT, -- stored Certificate of Authority
-- Errors + ops
last_error TEXT,
attempt_count INTEGER NOT NULL DEFAULT 0,
admin_todo_id INTEGER, -- ERPNext ToDo if manual
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- A carrier only needs ONE active foreign qualification per target
-- state — we don't want to accept a duplicate order. Application
-- should look for WHERE status NOT IN ('cancelled','rejected').
UNIQUE (telecom_entity_id, target_state_code,
order_number) -- order_number breaks ties for re-orders
);
CREATE INDEX IF NOT EXISTS idx_fq_order_number
ON foreign_qualification_registrations(order_number);
CREATE INDEX IF NOT EXISTS idx_fq_target_state
ON foreign_qualification_registrations(target_state_code);
CREATE INDEX IF NOT EXISTS idx_fq_status
ON foreign_qualification_registrations(status)
WHERE status NOT IN ('approved','cancelled','rejected');
CREATE INDEX IF NOT EXISTS idx_fq_telecom_entity
ON foreign_qualification_registrations(telecom_entity_id)
WHERE telecom_entity_id IS NOT NULL;
-- Updated_at trigger — keep it in sync with the pattern used elsewhere.
-- We add a dedicated trigger function if one doesn't exist already.
CREATE OR REPLACE FUNCTION set_updated_at_fq() RETURNS trigger AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_fq_updated_at ON foreign_qualification_registrations;
CREATE TRIGGER trg_fq_updated_at
BEFORE UPDATE ON foreign_qualification_registrations
FOR EACH ROW EXECUTE FUNCTION set_updated_at_fq();
-- ──────────────────────────────────────────────────────────────────────
-- 5. View: admin dashboard pipeline
-- ──────────────────────────────────────────────────────────────────────
CREATE OR REPLACE VIEW v_foreign_qualifications_pipeline AS
SELECT
fq.id,
fq.order_number,
fq.entity_legal_name,
fq.home_state_code,
fq.target_state_code,
j.name AS target_state_name,
fq.entity_type,
fq.status,
fq.filed_at,
fq.approved_at,
fq.retail_total_cents,
fq.attempt_count,
fq.last_error,
fq.created_at
FROM foreign_qualification_registrations fq
LEFT JOIN jurisdictions j ON j.code = fq.target_state_code
ORDER BY fq.created_at DESC;
COMMIT;

View file

@ -0,0 +1,99 @@
-- 067_us_state_compliance_configs.sql
--
-- Annual report fees, due dates, and franchise tax info for all 51 US
-- jurisdictions. Read by the renewal_worker to create compliance
-- calendar entries for formation clients.
BEGIN;
CREATE TABLE IF NOT EXISTS state_compliance_obligations (
id SERIAL PRIMARY KEY,
state_code CHAR(2) NOT NULL,
obligation_type TEXT NOT NULL CHECK (obligation_type IN (
'annual_report', 'franchise_tax', 'biennial_report',
'business_license', 'registered_agent'
)),
fee_cents INTEGER NOT NULL DEFAULT 0,
fee_notes TEXT, -- "min $50, max $15,000" etc.
frequency TEXT NOT NULL DEFAULT 'annual'
CHECK (frequency IN ('annual', 'biennial', 'quarterly', 'none')),
due_description TEXT, -- "April 15" or "anniversary month"
due_month INTEGER, -- 1-12 if fixed month; NULL if anniversary
due_day INTEGER, -- 1-31 if fixed day
is_anniversary BOOLEAN NOT NULL DEFAULT FALSE, -- due relative to formation date
state_portal_url TEXT,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (state_code, obligation_type)
);
-- Seed annual report obligations for all 51 US jurisdictions.
-- Source: docs/annual-report-fees-by-state.md (LLC University + state SOS cross-ref)
INSERT INTO state_compliance_obligations
(state_code, obligation_type, fee_cents, fee_notes, frequency, due_description, due_month, due_day, is_anniversary, notes)
VALUES
('AL', 'annual_report', 5000, 'Min $50, scales with net worth up to $15,000', 'annual', 'April 15', 4, 15, FALSE, 'Called "Business Privilege Tax"'),
('AK', 'biennial_report', 10000, '$100 biennial', 'biennial', 'January 2 of even years', 1, 2, FALSE, NULL),
('AZ', 'annual_report', 0, NULL, 'none', NULL, NULL, NULL, FALSE, 'No annual report required for LLCs'),
('AR', 'franchise_tax', 15000, NULL, 'annual', 'May 1', 5, 1, FALSE, 'Called "Franchise Tax Report"'),
('CA', 'franchise_tax', 80000, '$800 franchise tax minimum', 'annual', 'April 15', 4, 15, FALSE, 'Additional fee if gross revenue > $250k'),
('CA', 'biennial_report', 2000, '$20 biennial Statement of Information', 'biennial', 'Anniversary month', NULL, NULL, TRUE, NULL),
('CO', 'annual_report', 2500, NULL, 'annual', 'Anniversary month', NULL, NULL, TRUE, 'Due within 5-month window around anniversary'),
('CT', 'annual_report', 8000, NULL, 'annual', 'March 31', 3, 31, FALSE, NULL),
('DC', 'biennial_report', 30000, '$300 biennial', 'biennial', 'April 1', 4, 1, FALSE, NULL),
('DE', 'franchise_tax', 30000, NULL, 'annual', 'June 1', 6, 1, FALSE, '$300/yr LLC franchise tax'),
('FL', 'annual_report', 13875, NULL, 'annual', 'May 1', 5, 1, FALSE, NULL),
('GA', 'annual_report', 6000, NULL, 'annual', 'April 1', 4, 1, FALSE, 'Called "Annual Registration"'),
('HI', 'annual_report', 1500, NULL, 'annual', 'Anniversary quarter', NULL, NULL, TRUE, NULL),
('ID', 'annual_report', 0, NULL, 'annual', 'Anniversary month', NULL, NULL, TRUE, 'Must file information report but no fee'),
('IL', 'annual_report', 7500, NULL, 'annual', 'Anniversary month', NULL, NULL, TRUE, NULL),
('IN', 'biennial_report', 3000, '$30 biennial', 'biennial', 'Anniversary month', NULL, NULL, TRUE, NULL),
('IA', 'biennial_report', 3000, '$30 biennial', 'biennial', 'April 1 of odd years', 4, 1, FALSE, NULL),
('KS', 'annual_report', 5000, NULL, 'annual', 'April 15', 4, 15, FALSE, NULL),
('KY', 'annual_report', 1500, NULL, 'annual', 'June 30', 6, 30, FALSE, NULL),
('LA', 'annual_report', 3500, NULL, 'annual', 'Anniversary month', NULL, NULL, TRUE, NULL),
('ME', 'annual_report', 8500, NULL, 'annual', 'June 1', 6, 1, FALSE, NULL),
('MD', 'annual_report', 30000, NULL, 'annual', 'April 15', 4, 15, FALSE, 'Personal property tax return functions as annual report'),
('MA', 'annual_report', 50000, NULL, 'annual', 'Anniversary month', NULL, NULL, TRUE, NULL),
('MI', 'annual_report', 2500, NULL, 'annual', 'February 15', 2, 15, FALSE, NULL),
('MN', 'annual_report', 0, NULL, 'annual', 'December 31', 12, 31, FALSE, 'Must file but no fee'),
('MS', 'annual_report', 0, NULL, 'annual', 'April 15', 4, 15, FALSE, 'Must file but no fee'),
('MO', 'annual_report', 0, NULL, 'none', NULL, NULL, NULL, FALSE, 'No annual report required'),
('MT', 'annual_report', 2000, NULL, 'annual', 'April 15', 4, 15, FALSE, NULL),
('NE', 'biennial_report', 1300, '$13 biennial', 'biennial', 'April 1 of odd years', 4, 1, FALSE, NULL),
('NV', 'annual_report', 15000, '$150 Annual List of Members', 'annual', 'Anniversary month', NULL, NULL, TRUE, NULL),
('NV', 'business_license', 20000, '$200 State Business License', 'annual', 'Anniversary month', NULL, NULL, TRUE, 'Separate from Annual List'),
('NH', 'annual_report', 10000, NULL, 'annual', 'April 1', 4, 1, FALSE, NULL),
('NJ', 'annual_report', 7500, NULL, 'annual', 'Anniversary month', NULL, NULL, TRUE, NULL),
('NM', 'annual_report', 0, NULL, 'none', NULL, NULL, NULL, FALSE, 'No annual report required'),
('NY', 'biennial_report', 900, '$9 biennial', 'biennial', 'Anniversary month', NULL, NULL, TRUE, NULL),
('NC', 'annual_report', 20000, NULL, 'annual', 'April 15', 4, 15, FALSE, NULL),
('ND', 'annual_report', 5000, NULL, 'annual', 'November 15', 11, 15, FALSE, NULL),
('OH', 'annual_report', 0, NULL, 'none', NULL, NULL, NULL, FALSE, 'No annual report required'),
('OK', 'annual_report', 2500, NULL, 'annual', 'Anniversary month', NULL, NULL, TRUE, 'Called "Annual Certificate"'),
('OR', 'annual_report', 10000, NULL, 'annual', 'Anniversary month', NULL, NULL, TRUE, NULL),
('PA', 'annual_report', 700, NULL, 'annual', 'September 30', 9, 30, FALSE, 'New requirement starting 2025'),
('RI', 'annual_report', 5000, NULL, 'annual', 'Between February 1 and May 1', 5, 1, FALSE, NULL),
('SC', 'annual_report', 0, NULL, 'none', NULL, NULL, NULL, FALSE, 'No annual report for standard LLCs'),
('SD', 'annual_report', 5500, NULL, 'annual', 'Anniversary month', NULL, NULL, TRUE, NULL),
('TN', 'annual_report', 30000, 'Min $300 ($300/member, max $3,000)', 'annual', 'Anniversary month', NULL, NULL, TRUE, NULL),
('TX', 'franchise_tax', 0, '$0 for most LLCs (< $2.47M revenue)', 'annual', 'May 15', 5, 15, FALSE, 'Must file Public Information Report regardless'),
('UT', 'annual_report', 1800, NULL, 'annual', 'Anniversary month', NULL, NULL, TRUE, 'Called "Annual Renewal"'),
('VT', 'annual_report', 4500, NULL, 'annual', 'March 15', 3, 15, FALSE, NULL),
('VA', 'annual_report', 5000, NULL, 'annual', 'Anniversary month', NULL, NULL, TRUE, 'Called "Annual Registration Fee"'),
('WA', 'annual_report', 6000, NULL, 'annual', 'Anniversary month', NULL, NULL, TRUE, NULL),
('WV', 'annual_report', 2500, NULL, 'annual', 'July 1', 7, 1, FALSE, NULL),
('WI', 'annual_report', 2500, NULL, 'annual', 'Anniversary quarter', NULL, NULL, TRUE, NULL),
('WY', 'annual_report', 6000, 'Min $60, scales with WY assets', 'annual', 'Anniversary month', NULL, NULL, TRUE, NULL)
ON CONFLICT (state_code, obligation_type) DO UPDATE SET
fee_cents = EXCLUDED.fee_cents,
fee_notes = EXCLUDED.fee_notes,
frequency = EXCLUDED.frequency,
due_description = EXCLUDED.due_description,
due_month = EXCLUDED.due_month,
due_day = EXCLUDED.due_day,
is_anniversary = EXCLUDED.is_anniversary,
notes = EXCLUDED.notes;
COMMIT;

View file

@ -0,0 +1,123 @@
-- 068_usac_filing_history.sql
--
-- Stores scraped USAC E-File filing history per telecom entity.
-- Populated by the USAC audit scraper (usac_audit_scraper.py) when a
-- client grants us delegate access to their USAC account.
--
-- Each row represents one 499-A or 499-Q filing for a specific
-- reporting year. The audit compares what's filed vs what should
-- be filed to identify gaps and errors.
BEGIN;
CREATE TABLE IF NOT EXISTS usac_filing_history (
id BIGSERIAL PRIMARY KEY,
telecom_entity_id INTEGER REFERENCES telecom_entities(id),
frn TEXT NOT NULL,
filer_id_499 TEXT,
-- Filing identity
form_type TEXT NOT NULL CHECK (form_type IN ('499-A', '499-Q')),
reporting_year SMALLINT NOT NULL,
reporting_quarter SMALLINT, -- 1-4 for 499-Q; NULL for 499-A
-- Filing status from USAC
filing_status TEXT NOT NULL DEFAULT 'unknown'
CHECK (filing_status IN (
'filed', 'amended', 'missing', 'draft',
'rejected', 'unknown'
)),
date_filed DATE,
date_amended DATE, -- most recent amendment date
confirmation_number TEXT,
-- Revenue data from the filed form (cents)
total_revenue_cents BIGINT,
interstate_revenue_cents BIGINT,
international_revenue_cents BIGINT,
contribution_amount_cents BIGINT, -- what they owed USAC
-- Line 105 categories reported
line_105_primary TEXT,
line_105_categories JSONB,
-- De minimis / contributor status
is_deminimis BOOLEAN,
is_contributor BOOLEAN,
-- Audit findings (populated by the audit report generator)
audit_flag TEXT CHECK (audit_flag IN (
NULL, 'ok', 'missing', 'revenue_suspect',
'wrong_category', 'should_amend', 'too_old'
)),
audit_notes TEXT,
estimated_usf_owed_cents BIGINT, -- retroactive estimate for missing years
estimated_interest_cents BIGINT,
-- Metadata
scraped_at TIMESTAMPTZ, -- when we pulled this from USAC
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (frn, form_type, reporting_year, reporting_quarter)
);
CREATE INDEX IF NOT EXISTS idx_usac_history_frn ON usac_filing_history(frn);
CREATE INDEX IF NOT EXISTS idx_usac_history_entity ON usac_filing_history(telecom_entity_id);
CREATE INDEX IF NOT EXISTS idx_usac_history_audit ON usac_filing_history(audit_flag)
WHERE audit_flag IS NOT NULL AND audit_flag != 'ok';
-- Audit run tracking — one row per audit engagement
CREATE TABLE IF NOT EXISTS usac_audit_runs (
id BIGSERIAL PRIMARY KEY,
telecom_entity_id INTEGER REFERENCES telecom_entities(id),
frn TEXT NOT NULL,
order_number TEXT, -- compliance_orders.order_number
-- Access verification
delegate_access_verified BOOLEAN NOT NULL DEFAULT FALSE,
delegate_access_checked_at TIMESTAMPTZ,
delegate_access_error TEXT,
-- Audit results summary
years_audited SMALLINT, -- how many years we checked
years_filed_ok SMALLINT DEFAULT 0,
years_missing SMALLINT DEFAULT 0,
years_needs_amendment SMALLINT DEFAULT 0,
years_too_old SMALLINT DEFAULT 0, -- >5 years, needs USAC correspondence
total_estimated_usf_owed_cents BIGINT DEFAULT 0,
total_estimated_interest_cents BIGINT DEFAULT 0,
-- Quote generated
quote_new_filings_cents BIGINT, -- price for missing years
quote_amendments_cents BIGINT, -- price for amendments
quote_total_cents BIGINT,
quote_sent_at TIMESTAMPTZ,
-- Report
report_minio_path TEXT, -- path to the generated PDF
report_sent_at TIMESTAMPTZ,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN (
'pending', -- ordered, awaiting access
'access_verified', -- delegate access confirmed
'scraping', -- pulling filing history
'analyzing', -- generating audit findings
'report_ready', -- report + quote generated
'quote_sent', -- emailed to client
'approved', -- client approved the quote
'filing', -- filing the missing/amended years
'completed',
'failed'
)),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_usac_audit_frn ON usac_audit_runs(frn);
CREATE INDEX IF NOT EXISTS idx_usac_audit_status ON usac_audit_runs(status)
WHERE status NOT IN ('completed', 'failed');
COMMIT;

View file

@ -0,0 +1,7 @@
-- Add government filing fee columns to compliance_orders
-- Gov fees are passthrough costs (e.g., $100 FCC RMD filing fee) that are
-- never subject to bundle discounts or promo codes.
ALTER TABLE compliance_orders
ADD COLUMN IF NOT EXISTS gov_fee_cents integer NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS gov_fee_label text;

View file

@ -0,0 +1,34 @@
-- 070_rmd_audit_results.sql
-- Cache table for RMD filing deficiency audit results.
-- Populated by scripts/workers/fcc_rmd_auditor.py.
BEGIN;
CREATE TABLE IF NOT EXISTS fcc_rmd_audit_results (
id SERIAL PRIMARY KEY,
fcc_rmd_id INTEGER, -- soft FK to fcc_rmd(id) if table exists
rmd_number TEXT NOT NULL,
frn TEXT,
business_name TEXT,
-- Structured data findings
structured_checks JSONB NOT NULL DEFAULT '[]',
structured_score INTEGER NOT NULL DEFAULT 0,
-- PDF analysis findings (null until PDF analyzed)
pdf_checks JSONB,
pdf_score INTEGER,
pdf_downloaded BOOLEAN NOT NULL DEFAULT FALSE,
pdf_text_length INTEGER,
-- Overall
total_deficiencies INTEGER NOT NULL DEFAULT 0,
severity TEXT CHECK (severity IN ('critical', 'major', 'minor', 'clean')),
-- Timestamps
audited_at TIMESTAMPTZ NOT NULL DEFAULT now(),
pdf_audited_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_rmd_audit_rmd_number ON fcc_rmd_audit_results(rmd_number);
CREATE INDEX IF NOT EXISTS idx_rmd_audit_frn ON fcc_rmd_audit_results(frn);
CREATE INDEX IF NOT EXISTS idx_rmd_audit_severity ON fcc_rmd_audit_results(severity) WHERE severity != 'clean';
COMMIT;

View file

@ -0,0 +1,8 @@
-- Add RMD client review columns to compliance_orders.
-- The client must review and approve the certification before we submit to FCC.
ALTER TABLE compliance_orders
ADD COLUMN IF NOT EXISTS rmd_review_status TEXT CHECK (rmd_review_status IN ('pending', 'approved', 'rejected')),
ADD COLUMN IF NOT EXISTS rmd_reviewed_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS rmd_reviewer_email TEXT,
ADD COLUMN IF NOT EXISTS rmd_packet_minio_paths TEXT[];

View file

@ -0,0 +1,564 @@
-- 072_state_puc_requirements.sql
--
-- State PUC/PSC registration requirements for VoIP/IPES and broadband providers.
-- Seeded with all 50 US states + DC.
--
-- Data sources: state PUC websites, regulatory statutes, industry databases.
-- Fee amounts are best-known as of 2025 — verify before filing.
BEGIN;
CREATE TABLE IF NOT EXISTS state_puc_requirements (
id SERIAL PRIMARY KEY,
state_code CHAR(2) NOT NULL UNIQUE REFERENCES jurisdictions(code),
agency_name TEXT NOT NULL,
agency_url TEXT,
filing_portal_url TEXT,
-- VoIP/IPES requirements
voip_registration_required BOOLEAN NOT NULL DEFAULT FALSE,
voip_registration_type TEXT, -- 'cpcn','certificate_of_authority','registration','notification','exempt'
voip_registration_fee_cents INTEGER NOT NULL DEFAULT 0,
voip_annual_fee_cents INTEGER NOT NULL DEFAULT 0,
voip_bond_required BOOLEAN NOT NULL DEFAULT FALSE,
voip_bond_amount_cents INTEGER NOT NULL DEFAULT 0,
voip_bond_type TEXT, -- 'surety','cash','letter_of_credit'
-- Reseller vs facilities-based differentiation
voip_reseller_exempt BOOLEAN NOT NULL DEFAULT FALSE, -- resellers exempt from full registration?
voip_reseller_bond_cents INTEGER NOT NULL DEFAULT 0, -- lower bond for resellers (0 = same as full)
voip_reseller_fee_cents INTEGER NOT NULL DEFAULT 0, -- different app fee for resellers (0 = same)
voip_reseller_notes TEXT,
voip_ott_exempt BOOLEAN NOT NULL DEFAULT FALSE, -- over-the-top VoIP exempt?
voip_notes TEXT,
-- Broadband/ISP requirements
broadband_registration_required BOOLEAN NOT NULL DEFAULT FALSE,
broadband_registration_type TEXT,
broadband_registration_fee_cents INTEGER NOT NULL DEFAULT 0,
broadband_annual_fee_cents INTEGER NOT NULL DEFAULT 0,
broadband_notes TEXT,
-- CLEC certification (often separate from VoIP registration)
clec_certification_required BOOLEAN NOT NULL DEFAULT FALSE,
clec_certification_fee_cents INTEGER NOT NULL DEFAULT 0,
clec_bond_required BOOLEAN NOT NULL DEFAULT FALSE,
clec_bond_amount_cents INTEGER NOT NULL DEFAULT 0,
-- Ongoing obligations
annual_report_required BOOLEAN NOT NULL DEFAULT FALSE,
annual_report_due TEXT,
usf_surcharge_required BOOLEAN NOT NULL DEFAULT FALSE,
usf_surcharge_description TEXT,
-- Metadata
last_verified_date DATE DEFAULT CURRENT_DATE,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_state_puc_voip_required
ON state_puc_requirements(voip_registration_required)
WHERE voip_registration_required = TRUE;
-- Updated_at trigger
CREATE OR REPLACE FUNCTION set_updated_at_state_puc_req() RETURNS trigger AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_state_puc_req_updated_at ON state_puc_requirements;
CREATE TRIGGER trg_state_puc_req_updated_at
BEFORE UPDATE ON state_puc_requirements
FOR EACH ROW EXECUTE FUNCTION set_updated_at_state_puc_req();
-- ──────────────────────────────────────────────────────────────────────
-- Seed all 51 jurisdictions
-- ──────────────────────────────────────────────────────────────────────
INSERT INTO state_puc_requirements (
state_code, agency_name, agency_url,
voip_registration_required, voip_registration_type, voip_registration_fee_cents,
voip_annual_fee_cents, voip_bond_required, voip_bond_amount_cents, voip_bond_type,
voip_reseller_exempt, voip_reseller_bond_cents, voip_reseller_notes,
voip_ott_exempt,
voip_notes,
broadband_registration_required, broadband_notes,
clec_certification_required, clec_certification_fee_cents, clec_bond_required, clec_bond_amount_cents,
annual_report_required, usf_surcharge_required, usf_surcharge_description,
notes
) VALUES
-- TIER 1: States that REQUIRE VoIP registration
-- Format: state_code, agency_name, agency_url,
-- voip_registration_required, voip_registration_type, voip_registration_fee_cents,
-- voip_annual_fee_cents, voip_bond_required, voip_bond_amount_cents, voip_bond_type,
-- voip_reseller_exempt, voip_reseller_bond_cents, voip_reseller_notes, voip_ott_exempt,
-- voip_notes,
-- broadband_registration_required, broadband_notes,
-- clec_certification_required, clec_certification_fee_cents, clec_bond_required, clec_bond_amount_cents,
-- annual_report_required, usf_surcharge_required, usf_surcharge_description, notes
('AL', 'Alabama Public Service Commission', 'https://psc.alabama.gov',
TRUE, 'certificate_of_authority', 10000, 0, FALSE, 0, NULL,
FALSE, 0, NULL, TRUE,
'Certificate of Authority required for telephone companies including VoIP',
FALSE, NULL,
TRUE, 10000, TRUE, 2500000,
TRUE, TRUE, 'Annual regulatory assessment based on intrastate revenue',
NULL),
('CA', 'California Public Utilities Commission', 'https://www.cpuc.ca.gov/industries-and-topics/telecommunications',
TRUE, 'registration', 15000, 0, TRUE, 10000000, 'surety',
FALSE, 2500000, 'Resellers may qualify for lower $25K bond vs $100K for facilities-based', TRUE,
'CPUC Resolution T-17002 requires VoIP registration. Bond $25K-$100K depending on category.',
FALSE, 'Facilities-based broadband providers may need CPCN',
TRUE, 15000, TRUE, 10000000,
TRUE, TRUE, 'User fee surcharge 0.1-0.5% of CA intrastate revenue + CHCF-A/B + CTF + DDTP + LifeLine',
'One of the most complex states. Multiple surcharge funds with detailed reporting.'),
('CO', 'Colorado Public Utilities Commission', 'https://puc.colorado.gov/telecom',
TRUE, 'registration', 10000, 0, FALSE, 0, NULL,
FALSE, 0, NULL, FALSE,
'Must register as Telecommunications Provider. SB 14-180 deregulated many services but registration remains.',
FALSE, NULL,
TRUE, 10000, FALSE, 0,
TRUE, TRUE, 'RRIF assessment + High Cost Support Mechanism + TRS fund',
NULL),
('CT', 'Connecticut Public Utilities Regulatory Authority', 'https://portal.ct.gov/pura/telecommunications',
TRUE, 'registration', 25000, 0, TRUE, 5000000, 'surety',
FALSE, 0, 'Bond may be waived for VoIP-only resellers', TRUE,
'Certified Telecommunications Provider (CTP). $50K bond may be waived for VoIP-only.',
FALSE, NULL,
TRUE, 25000, TRUE, 5000000,
TRUE, TRUE, 'CTUSF contribution + annual assessment',
NULL),
('DC', 'DC Public Service Commission', 'https://dcpsc.org',
TRUE, 'certificate_of_authority', 50000, 0, TRUE, 5000000, 'surety',
FALSE, 0, NULL, FALSE,
'Certificate of Authority required. $50K surety bond.',
FALSE, NULL,
TRUE, 50000, TRUE, 5000000,
TRUE, TRUE, 'Assessment based on DC revenue',
NULL),
('FL', 'Florida Public Service Commission', 'https://www.psc.state.fl.us/ElectronicFiling',
TRUE, 'registration', 0, 0, FALSE, 0, NULL,
FALSE, 0, 'Resellers and facilities-based have same lightweight registration', TRUE,
'Register as ALEC or VoIP provider under FL Stat. 364.02. No application fee. HB 247 (2021) deregulated but registration remains.',
FALSE, NULL,
TRUE, 0, FALSE, 0,
TRUE, TRUE, 'RAF ~0.09% of FL gross operating revenue + Relay Service + Lifeline',
'Substantially deregulated under HB 247 (2021). Registration is lightweight.'),
('GA', 'Georgia Public Service Commission', 'https://psc.ga.gov/telecommunications/',
TRUE, 'certificate_of_authority', 25000, 0, TRUE, 2500000, 'surety',
FALSE, 0, 'Bond may be reduced based on revenue for smaller resellers', TRUE,
'Certificate of Authority required. $25K bond may be reduced based on revenue.',
FALSE, NULL,
TRUE, 25000, TRUE, 2500000,
TRUE, TRUE, 'GA Universal Access Fund + GA Relay Service',
NULL),
('IL', 'Illinois Commerce Commission', 'https://www.icc.illinois.gov/filings',
TRUE, 'certificate_of_authority', 10000, 0, TRUE, 5000000, 'surety',
FALSE, 2500000, 'Resellers typically $25K bond vs $50K for facilities-based', TRUE,
'Certificate of Service Authority required. Bond $25K-$50K depending on service type.',
FALSE, NULL,
TRUE, 10000, TRUE, 5000000,
TRUE, TRUE, 'IL TRS fund + Universal Service contribution',
NULL),
('IN', 'Indiana Utility Regulatory Commission', 'https://www.in.gov/iurc/',
TRUE, 'certificate_of_authority', 15000, 0, FALSE, 0, NULL,
TRUE, 0, 'HEA 1279 (2006) exempts VoIP resellers from most IURC regulation', TRUE,
'Certificate of Territorial Authority (CTA). HEA 1279 (2006) limited PUC authority but registration remains.',
FALSE, NULL,
TRUE, 15000, TRUE, 2500000,
TRUE, TRUE, 'Indiana USF contribution',
NULL),
('KS', 'Kansas Corporation Commission', 'https://kcc.ks.gov/telecommunications',
TRUE, 'certificate_of_authority', 20000, 0, FALSE, 0, NULL,
FALSE, 0, NULL, TRUE,
'Certificate of Convenience and Authority. K.S.A. 66-2005 partially deregulated VoIP but registration/USF remain.',
FALSE, NULL,
TRUE, 20000, FALSE, 0,
TRUE, TRUE, 'KUSF contribution',
NULL),
('KY', 'Kentucky Public Service Commission', 'https://psc.ky.gov',
TRUE, 'cpcn', 0, 0, FALSE, 0, NULL,
FALSE, 0, NULL, FALSE,
'CPCN required. No filing fee. SB 99 (2006) limited regulation but CPCN still required.',
FALSE, NULL,
TRUE, 0, FALSE, 0,
TRUE, TRUE, 'KY USF assessment',
NULL),
('LA', 'Louisiana Public Service Commission', 'https://lpsc.louisiana.gov',
TRUE, 'certificate_of_authority', 50000, 0, TRUE, 2500000, 'surety',
FALSE, 0, NULL, FALSE,
'Certificate of Authority required. $25K surety bond.',
FALSE, NULL,
TRUE, 50000, TRUE, 2500000,
TRUE, TRUE, 'LA USF contribution',
NULL),
('ME', 'Maine Public Utilities Commission', 'https://www.maine.gov/mpuc/',
TRUE, 'registration', 20000, 0, TRUE, 2500000, 'surety',
FALSE, 0, 'Bond may be reduced for VoIP resellers', FALSE,
'Register as CLEC or VoIP provider. 35-A M.R.S. Chapter 7. $25K bond may be reduced.',
FALSE, NULL,
TRUE, 20000, TRUE, 2500000,
TRUE, FALSE, NULL,
NULL),
('MD', 'Maryland Public Service Commission', 'https://www.psc.state.md.us',
TRUE, 'cpcn', 25000, 0, TRUE, 5000000, 'surety',
FALSE, 0, NULL, FALSE,
'CPCN or registration required. $50K standard surety bond. MD Code PU Section 8-101 et seq.',
FALSE, NULL,
TRUE, 25000, TRUE, 5000000,
TRUE, TRUE, 'MD Relay fund + Universal Service contribution',
NULL),
('MA', 'Massachusetts Department of Telecommunications and Cable', 'https://www.mass.gov/orgs/department-of-telecommunications-and-cable',
TRUE, 'registration', 25000, 0, TRUE, 10000000, 'surety',
FALSE, 2500000, 'Resellers may qualify for reduced $25K bond', FALSE,
'Register as CLEC or obtain authorization. $100K surety bond — one of the highest.',
TRUE, 'MA is one of the few states requiring ISP registration',
TRUE, 25000, TRUE, 10000000,
TRUE, TRUE, 'MA TRS + Universal Service contribution',
'One of the few states requiring broadband/ISP registration.'),
('MI', 'Michigan Public Service Commission', 'https://www.michigan.gov/mpsc',
TRUE, 'registration', 0, 0, FALSE, 0, NULL,
TRUE, 0, 'PA 48 of 2014 substantially deregulated VoIP resellers', TRUE,
'Must obtain authorization (less than full CPCN). PA 48 of 2014 substantially deregulated but registration persists.',
FALSE, NULL,
TRUE, 0, FALSE, 0,
TRUE, TRUE, 'MI USF assessment',
NULL),
('MN', 'Minnesota Public Utilities Commission', 'https://mn.gov/puc/',
TRUE, 'registration', 10000, 0, FALSE, 0, NULL,
FALSE, 0, NULL, TRUE,
'Simplified registration process post-2015. Minn. Stat. 237.01. Registration serves as CLEC authorization.',
FALSE, NULL,
FALSE, 0, FALSE, 0,
TRUE, TRUE, 'TAM (Telephone Assistance Mechanism) fund contribution',
NULL),
('MS', 'Mississippi Public Service Commission', 'https://www.psc.ms.gov',
TRUE, 'cpcn', 50000, 0, TRUE, 2500000, 'surety',
FALSE, 0, NULL, FALSE,
'Certificate of Convenience and Necessity (CCN). $25K surety bond. MS Code 77-3-1 et seq.',
FALSE, NULL,
TRUE, 50000, TRUE, 2500000,
TRUE, FALSE, NULL,
NULL),
('MO', 'Missouri Public Service Commission', 'https://psc.mo.gov',
TRUE, 'certificate_of_authority', 5000, 0, FALSE, 0, NULL,
FALSE, 0, NULL, TRUE,
'Certificate of Service Authority (CSA). RSMo 392.611 — VoIP providers must register but exempt from rate regulation.',
FALSE, NULL,
FALSE, 0, FALSE, 0,
TRUE, TRUE, 'MO USF contribution',
NULL),
('NE', 'Nebraska Public Service Commission', 'https://psc.nebraska.gov',
TRUE, 'registration', 20000, 0, TRUE, 2500000, 'surety',
FALSE, 0, NULL, FALSE,
'Must register as telecommunications carrier. $25K surety bond.',
FALSE, NULL,
TRUE, 20000, TRUE, 2500000,
TRUE, TRUE, 'NUSF contribution + regulatory fee',
NULL),
('NV', 'Public Utilities Commission of Nevada', 'https://puc.nv.gov',
TRUE, 'cpcn', 100000, 0, TRUE, 10000000, 'surety',
FALSE, 2500000, 'Resellers may qualify for lower bond ($25K vs $100K for facilities-based)', TRUE,
'CPCN required. $1,000 app fee. Bond $25K-$100K varies by size. NRS 704 governs.',
FALSE, NULL,
TRUE, 100000, TRUE, 10000000,
TRUE, TRUE, 'NV USF contribution + ~0.4% annual regulatory assessment',
'High application fee ($1,000) and potentially high bond.'),
('NH', 'New Hampshire Public Utilities Commission', 'https://www.puc.nh.gov',
TRUE, 'registration', 20000, 0, FALSE, 0, NULL,
FALSE, 0, NULL, TRUE,
'Registration/authorization required. RSA 362 governs. VoIP lightly regulated but must register.',
FALSE, NULL,
TRUE, 20000, FALSE, 0,
TRUE, FALSE, NULL,
NULL),
('NJ', 'New Jersey Board of Public Utilities', 'https://www.nj.gov/bpu/',
TRUE, 'registration', 10000, 0, TRUE, 5000000, 'surety',
FALSE, 0, NULL, FALSE,
'Register as CLEC or VoIP provider. $50K surety bond. N.J.S.A. 48:2-1 et seq.',
FALSE, NULL,
TRUE, 10000, TRUE, 5000000,
TRUE, TRUE, 'NJ USF + NJ TRS fund',
NULL),
('NM', 'New Mexico Public Regulation Commission', 'https://www.nmprc.state.nm.us',
TRUE, 'certificate_of_authority', 10000, 0, TRUE, 2500000, 'surety',
FALSE, 0, NULL, FALSE,
'Certificate of Authority required. $25K surety bond. NMSA 63-7-1 et seq.',
FALSE, NULL,
TRUE, 10000, TRUE, 2500000,
TRUE, TRUE, 'NM USF contribution',
NULL),
('NY', 'New York Public Service Commission', 'https://www.dps.ny.gov',
TRUE, 'registration', 50000, 0, TRUE, 10000000, 'surety',
FALSE, 5000000, 'Resellers may qualify for reduced $50K bond vs $100K', FALSE,
'Must obtain authorization. Bond $50K-$100K. Section 18-a assessment on gross intrastate revenue is one of the most significant ongoing obligations.',
FALSE, 'NY broadband mapping program requires data reporting but no PUC registration',
TRUE, 50000, TRUE, 10000000,
TRUE, TRUE, 'Section 18-a assessment (% of gross intrastate revenue) + NY USF + NY TRS',
'Section 18-a assessment can be substantial. Often the most expensive ongoing state obligation.'),
('NC', 'North Carolina Utilities Commission', 'https://www.ncuc.gov',
TRUE, 'certificate_of_authority', 50000, 0, TRUE, 2500000, 'surety',
FALSE, 0, NULL, TRUE,
'Certificate of Authority required. $25K surety bond. G.S. 62-2 et seq.',
FALSE, NULL,
TRUE, 50000, TRUE, 2500000,
TRUE, TRUE, 'NC USF contribution + E-911 fund',
NULL),
('OH', 'Public Utilities Commission of Ohio', 'https://puco.ohio.gov',
TRUE, 'registration', 25000, 0, FALSE, 0, NULL,
FALSE, 0, NULL, TRUE,
'Register as Competitive Telecommunications Service Provider. ORC 4927 governs. Light regulation post-SB 162 (2010).',
FALSE, NULL,
FALSE, 0, FALSE, 0,
TRUE, TRUE, 'OH USF assessment',
NULL),
('OK', 'Oklahoma Corporation Commission', 'https://oklahoma.gov/occ.html',
TRUE, 'cpcn', 5000, 0, TRUE, 1000000, 'surety',
FALSE, 0, NULL, FALSE,
'Certificate of Convenience and Necessity. $10K surety bond. 17 O.S. Section 131 et seq.',
FALSE, NULL,
TRUE, 5000, TRUE, 1000000,
TRUE, TRUE, 'OK USF + annual gross receipts assessment',
NULL),
('OR', 'Oregon Public Utility Commission', 'https://www.oregon.gov/puc',
TRUE, 'registration', 10000, 0, FALSE, 0, NULL,
FALSE, 0, NULL, TRUE,
'Register as Competitive Telecommunications Provider. ORS 759 governs. Light regulation.',
FALSE, NULL,
FALSE, 0, FALSE, 0,
TRUE, TRUE, 'OR USF + OR TRS fund',
NULL),
('PA', 'Pennsylvania Public Utility Commission', 'https://www.puc.pa.gov',
TRUE, 'cpcn', 35000, 0, TRUE, 5000000, 'surety',
FALSE, 0, 'Resellers use same Certificate of Public Convenience but may have expedited review', FALSE,
'Certificate of Public Convenience required (Act 183 of 2004 streamlined process). $50K standard bond.',
FALSE, NULL,
TRUE, 35000, TRUE, 5000000,
TRUE, TRUE, 'PA USF + PA TRS + ~0.3-0.5% annual assessment on intrastate revenue',
NULL),
('RI', 'Rhode Island Public Utilities Commission', 'https://ripuc.ri.gov',
TRUE, 'registration', 10000, 0, TRUE, 2500000, 'surety',
FALSE, 0, NULL, FALSE,
'Must register. $25K surety bond. R.I.G.L. 39-1 et seq.',
FALSE, NULL,
TRUE, 10000, TRUE, 2500000,
TRUE, FALSE, NULL,
NULL),
('SC', 'South Carolina Public Service Commission', 'https://www.psc.sc.gov',
TRUE, 'cpcn', 30000, 0, TRUE, 2500000, 'surety',
FALSE, 0, NULL, FALSE,
'CPCN required. $25K surety bond. S.C. Code 58-9 et seq.',
FALSE, NULL,
TRUE, 30000, TRUE, 2500000,
TRUE, TRUE, 'SC USF contribution',
NULL),
('TN', 'Tennessee Regulatory Authority', 'https://www.tn.gov/tra',
TRUE, 'certificate_of_authority', 0, 0, FALSE, 0, NULL,
TRUE, 0, 'VoIP resellers substantially deregulated under T.C.A. 65-4-101', TRUE,
'Certificate of Authority or simplified registration. No filing fee. T.C.A. 65-4-101 et seq.',
FALSE, NULL,
TRUE, 0, FALSE, 0,
TRUE, TRUE, 'TN USF',
NULL),
('TX', 'Public Utility Commission of Texas', 'https://www.puc.texas.gov',
TRUE, 'certificate_of_authority', 100000, 0, TRUE, 10000000, 'surety',
FALSE, 2500000, 'SPCOA (reseller type) has lower $25K bond vs COA $100K for facilities-based', TRUE,
'COA or SPCOA required. $1,000 app fee. Bond $25K-$100K. PURA 55.002-55.008 provides some VoIP exemptions.',
FALSE, NULL,
TRUE, 100000, TRUE, 10000000,
TRUE, TRUE, 'TUSF 3.3%+ of intrastate revenue (quarterly) + TRS contribution',
'TX distinguishes COA (facilities-based) from SPCOA (reseller). TUSF is one of the largest state USF programs.'),
('UT', 'Utah Public Service Commission', 'https://psc.utah.gov',
TRUE, 'cpcn', 10000, 0, TRUE, 2500000, 'surety',
FALSE, 1000000, 'Resellers may qualify for $10K bond vs $25K', FALSE,
'CPCN required. Bond $10K-$25K. UCA 54-8b-1 et seq.',
FALSE, NULL,
TRUE, 10000, TRUE, 2500000,
TRUE, TRUE, 'UT USF contribution (relatively small)',
NULL),
('VT', 'Vermont Public Utility Commission', 'https://puc.vermont.gov',
TRUE, 'cpcn', 10000, 0, TRUE, 1000000, 'surety',
FALSE, 0, NULL, FALSE,
'Certificate of Public Good (CPG) required. $10K bond. 30 V.S.A. Chapter 1.',
TRUE, 'VT is one of the few states requiring broadband provider PUC registration under CPG',
TRUE, 10000, TRUE, 1000000,
TRUE, FALSE, NULL,
'One of the few states that regulates broadband providers under PUC authority.'),
('VA', 'Virginia State Corporation Commission', 'https://www.scc.virginia.gov',
TRUE, 'certificate_of_authority', 25000, 0, TRUE, 2500000, 'surety',
FALSE, 0, NULL, TRUE,
'Certificate of Authority required. $25K surety bond. VA Code 56-1 et seq.',
FALSE, NULL,
TRUE, 25000, TRUE, 2500000,
TRUE, TRUE, 'VA Relay Center contribution + ~0.04-0.06% annual assessment',
NULL),
('WA', 'Washington Utilities and Transportation Commission', 'https://www.utc.wa.gov',
TRUE, 'registration', 25000, 0, TRUE, 2500000, 'surety',
FALSE, 0, NULL, TRUE,
'Register as Competitive Telecommunications Company. $25K surety bond. RCW 80.36 governs.',
FALSE, NULL,
FALSE, 0, FALSE, 0,
TRUE, TRUE, 'WA USF assessment + TRS',
NULL),
('WV', 'West Virginia Public Service Commission', 'https://www.psc.state.wv.us',
TRUE, 'cpcn', 50000, 0, TRUE, 2500000, 'surety',
FALSE, 0, NULL, FALSE,
'Certificate of Convenience and Necessity. $25K surety bond. WV Code 24-2-1 et seq.',
FALSE, NULL,
TRUE, 50000, TRUE, 2500000,
TRUE, TRUE, 'WV USF + special revenue fund assessment',
NULL),
('WI', 'Public Service Commission of Wisconsin', 'https://psc.wi.gov',
TRUE, 'certificate_of_authority', 10000, 0, FALSE, 0, NULL,
FALSE, 0, NULL, TRUE,
'Certificate of Authority. Wis. Stat. 196.01 et seq. 2011 Act 22 substantially deregulated but registration remains.',
FALSE, NULL,
FALSE, 0, FALSE, 0,
TRUE, TRUE, 'WI USF contribution + TRS',
NULL),
-- TIER 2: States with LIMITED or NO VoIP registration requirement
('AK', 'Regulatory Commission of Alaska', 'https://rca.alaska.gov',
FALSE, 'notification', 0, 0, FALSE, 0, NULL,
TRUE, 0, NULL, TRUE,
'VoIP largely deregulated. May need to register for AUSF contribution purposes only.',
FALSE, NULL,
TRUE, 0, FALSE, 0,
FALSE, TRUE, 'AUSF contribution may apply',
'Remote state with few VoIP-specific requirements. RCA may assert jurisdiction for AUSF.'),
('AZ', 'Arizona Corporation Commission', 'https://www.azcc.gov',
FALSE, 'exempt', 0, 0, FALSE, 0, NULL,
TRUE, 0, NULL, TRUE,
'Arizona exempted VoIP from state regulation under ARS 9-582. Explicitly prohibits local government VoIP regulation.',
FALSE, NULL,
FALSE, 0, FALSE, 0,
FALSE, FALSE, NULL,
'Explicit legislative exemption for VoIP.'),
('AR', 'Arkansas Public Service Commission', 'https://www.arkansas.gov/psc/',
FALSE, 'exempt', 0, 0, FALSE, 0, NULL,
TRUE, 0, 'Act 781 of 2013 specifically exempts VoIP resellers', TRUE,
'Act 781 of 2013 deregulated VoIP. No CPCN required for VoIP-only providers. May still need to contribute to AR USF.',
FALSE, NULL,
TRUE, 0, FALSE, 0,
FALSE, TRUE, 'AR USF contribution may apply',
'CLECs still need certification; pure VoIP providers exempt from PSC regulation.'),
('DE', 'Delaware Public Service Commission', 'https://depsc.delaware.gov',
FALSE, 'exempt', 0, 0, FALSE, 0, NULL,
TRUE, 0, NULL, TRUE,
'Delaware does not require VoIP providers to register with the PSC. Very limited telecom regulation.',
FALSE, NULL,
TRUE, 0, FALSE, 0,
FALSE, FALSE, NULL,
'CLEC certification exists but VoIP-only providers generally exempt.'),
('HI', 'Hawaii Public Utilities Commission', 'https://puc.hawaii.gov',
FALSE, 'notification', 0, 0, FALSE, 0, NULL,
TRUE, 0, NULL, TRUE,
'HRS 269 governs but VoIP substantially deregulated. Registration may be required for E-911 purposes.',
FALSE, NULL,
FALSE, 0, FALSE, 0,
FALSE, FALSE, NULL,
'Relatively light regulatory touch for VoIP.'),
('ID', 'Idaho Public Utilities Commission', 'https://www.puc.idaho.gov',
FALSE, 'exempt', 0, 0, FALSE, 0, NULL,
TRUE, 0, NULL, TRUE,
'Idaho exempted VoIP from PUC regulation under Idaho Code 62-622. Explicit legislative exemption.',
FALSE, NULL,
FALSE, 0, FALSE, 0,
FALSE, FALSE, NULL,
'Explicit legislative exemption for VoIP/IP-enabled services.'),
('IA', 'Iowa Utilities Board', 'https://iub.iowa.gov',
FALSE, 'exempt', 0, 0, FALSE, 0, NULL,
TRUE, 0, 'SF 2325 (2014) exempts VoIP resellers; only facilities-based CLECs need certificate', TRUE,
'SF 2325 (2014) deregulated VoIP. Certificate required only for facilities-based CLECs.',
FALSE, NULL,
TRUE, 0, FALSE, 0,
FALSE, TRUE, 'Iowa Communications Network fund contribution may apply',
'VoIP providers exempt from most IUB regulation.'),
('MT', 'Montana Public Service Commission', 'https://psc.mt.gov',
FALSE, 'exempt', 0, 0, FALSE, 0, NULL,
TRUE, 0, NULL, TRUE,
'Montana exempted VoIP from PSC jurisdiction. MCA 69-3-802 exempts IP-enabled services.',
FALSE, NULL,
FALSE, 0, FALSE, 0,
FALSE, FALSE, NULL,
'Explicit legislative exemption.'),
('ND', 'North Dakota Public Service Commission', 'https://www.psc.nd.gov',
FALSE, 'notification', 0, 0, FALSE, 0, NULL,
TRUE, 0, NULL, TRUE,
'NDCC 49-21 governs. VoIP providers may need to register for USF contribution purposes.',
FALSE, NULL,
FALSE, 0, FALSE, 0,
FALSE, TRUE, 'ND USF contribution may apply',
'Relatively light regulation; registration may be for assessment/USF purposes only.'),
('SD', 'South Dakota Public Utilities Commission', 'https://puc.sd.gov',
FALSE, 'notification', 0, 0, FALSE, 0, NULL,
TRUE, 0, NULL, TRUE,
'SDCL 49-31 governs. VoIP registration exists but is simplified.',
FALSE, NULL,
FALSE, 0, FALSE, 0,
FALSE, FALSE, NULL,
'Relatively light telecom regulation for VoIP.'),
('WY', 'Wyoming Public Service Commission', 'https://psc.wyo.gov',
FALSE, 'exempt', 0, 0, FALSE, 0, NULL,
TRUE, 0, NULL, TRUE,
'Wyoming exempted VoIP from PSC regulation under W.S. 37-15-501. Explicit legislative exemption.',
FALSE, NULL,
FALSE, 0, FALSE, 0,
FALSE, FALSE, NULL,
'Explicit legislative exemption.')
ON CONFLICT (state_code) DO NOTHING;
COMMIT;

View file

@ -0,0 +1,116 @@
-- 073_state_puc_registrations.sql
--
-- Per-state PUC/PSC registration tracking, one row per state per order.
-- Follows the foreign_qualification_registrations pattern from migration 066.
BEGIN;
CREATE TABLE IF NOT EXISTS state_puc_registrations (
id BIGSERIAL PRIMARY KEY,
-- Parent order
compliance_order_id INTEGER REFERENCES compliance_orders(id) ON DELETE SET NULL,
order_number TEXT NOT NULL,
-- Linked carrier
telecom_entity_id INTEGER,
state_code CHAR(2) NOT NULL,
registration_type TEXT NOT NULL, -- 'voip','broadband','clec','bundle'
entity_legal_name TEXT NOT NULL,
frn TEXT,
-- Provider classification (affects requirements in many states)
provider_type TEXT CHECK (provider_type IN (
'facilities_based', 'reseller', 'over_the_top', 'hybrid'
)),
-- Status pipeline
status TEXT NOT NULL DEFAULT 'received' CHECK (status IN (
'received', -- paid, queued
'docs_pending', -- waiting on supporting documents
'bond_pending', -- waiting on surety bond procurement
'filing', -- preparing/submitting to state PUC
'admin_review', -- manual step needed
'submitted', -- filed, awaiting state approval
'approved', -- certificate/registration issued
'rejected',
'cancelled'
)),
-- Fees (cents)
state_fee_cents INTEGER NOT NULL DEFAULT 0,
bond_amount_cents INTEGER NOT NULL DEFAULT 0,
service_fee_cents INTEGER NOT NULL DEFAULT 0,
retail_total_cents INTEGER NOT NULL DEFAULT 0,
-- Registration details
puc_registration_number TEXT,
puc_certificate_number TEXT,
filed_at TIMESTAMPTZ,
approved_at TIMESTAMPTZ,
certificate_minio_path TEXT,
-- Bond info
bond_company TEXT,
bond_policy_number TEXT,
bond_expiration_date DATE,
-- Tracking
attempt_count INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
admin_todo_id INTEGER,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (telecom_entity_id, state_code, order_number)
);
CREATE INDEX IF NOT EXISTS idx_puc_reg_order_number
ON state_puc_registrations(order_number);
CREATE INDEX IF NOT EXISTS idx_puc_reg_state_code
ON state_puc_registrations(state_code);
CREATE INDEX IF NOT EXISTS idx_puc_reg_status
ON state_puc_registrations(status)
WHERE status NOT IN ('approved','cancelled','rejected');
CREATE INDEX IF NOT EXISTS idx_puc_reg_telecom_entity
ON state_puc_registrations(telecom_entity_id)
WHERE telecom_entity_id IS NOT NULL;
-- Updated_at trigger
CREATE OR REPLACE FUNCTION set_updated_at_puc_reg() RETURNS trigger AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_puc_reg_updated_at ON state_puc_registrations;
CREATE TRIGGER trg_puc_reg_updated_at
BEFORE UPDATE ON state_puc_registrations
FOR EACH ROW EXECUTE FUNCTION set_updated_at_puc_reg();
-- Admin pipeline view
CREATE OR REPLACE VIEW v_puc_registrations_pipeline AS
SELECT
pr.id,
pr.order_number,
pr.entity_legal_name,
pr.state_code,
j.name AS state_name,
pr.registration_type,
pr.provider_type,
pr.status,
pr.state_fee_cents,
pr.bond_amount_cents,
pr.retail_total_cents,
pr.filed_at,
pr.approved_at,
pr.attempt_count,
pr.last_error,
pr.created_at
FROM state_puc_registrations pr
LEFT JOIN jurisdictions j ON j.code = pr.state_code
ORDER BY pr.created_at DESC;
COMMIT;

1984
api/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

39
api/package.json Normal file
View file

@ -0,0 +1,39 @@
{
"name": "@performancewest/api",
"version": "0.1.0",
"private": true,
"description": "Performance West API — compliance consulting platform backend",
"scripts": {
"dev": "tsx watch --env-file=.env src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@types/cookie-parser": "^1.4.10",
"@types/nodemailer": "^7.0.11",
"bcryptjs": "^3.0.3",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"express": "^4.21.0",
"express-rate-limit": "^7.4.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.3",
"nodemailer": "^8.0.4",
"pg": "^8.13.0",
"stripe": "^21.0.1",
"uuid": "^10.0.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^22.0.0",
"@types/pg": "^8.11.0",
"@types/uuid": "^10.0.0",
"tsx": "^4.19.0",
"typescript": "^5.6.0"
}
}

111
api/src/config.ts Normal file
View file

@ -0,0 +1,111 @@
// Environment configuration — singleton, loaded once at startup.
function required(name: string): string {
const v = process.env[name];
if (!v) throw new Error(`Missing required env var: ${name}`);
return v;
}
function optional(name: string, fallback: string): string {
return process.env[name] ?? fallback;
}
export interface Config {
port: number;
nodeEnv: string;
postgres: {
connectionString: string;
};
erpnext: {
url: string;
apiKey: string;
apiSecret: string;
siteName: string;
};
listmonk: {
url: string;
user: string;
password: string;
};
minio: {
endpoint: string;
port: number;
accessKey: string;
secretKey: string;
};
stripe: {
secretKey: string;
webhookSecret: string;
};
smtp: {
host: string;
port: number;
user: string;
pass: string;
from: string;
};
}
// Env-var guard for production: refuse to boot with placeholder secrets.
// Catches the common footgun of deploying with `change-this-in-production`
// still in effect on ADMIN_JWT_SECRET, WEBHOOK_SECRET, etc.
function refuseInsecureProduction(): void {
if (process.env.NODE_ENV !== "production") return;
const bad: string[] = [];
const check = (name: string, sentinels: string[] = []) => {
const v = process.env[name] ?? "";
if (!v) bad.push(`${name} is unset`);
else if (sentinels.includes(v)) bad.push(`${name} is still set to a placeholder`);
};
check("ADMIN_JWT_SECRET", ["change-this-in-production"]);
check("WEBHOOK_SECRET", ["change-this-in-production"]);
check("SHKEEPER_API_KEY");
check("STRIPE_WEBHOOK_SECRET");
if (bad.length) {
throw new Error(
`[config] Refusing to start in production with insecure settings:\n` +
bad.map(b => ` - ${b}`).join("\n"),
);
}
}
function loadConfig(): Config {
refuseInsecureProduction();
return {
port: parseInt(optional("PORT", "3001"), 10),
nodeEnv: optional("NODE_ENV", "development"),
postgres: {
connectionString: required("DATABASE_URL"),
},
erpnext: {
url: optional("ERPNEXT_URL", "http://erpnext:8000"),
apiKey: optional("ERPNEXT_API_KEY", ""),
apiSecret: optional("ERPNEXT_API_SECRET", ""),
siteName: optional("ERPNEXT_SITE_NAME", optional("ERPNEXT_HOST_HEADER", "performancewest.net")),
},
listmonk: {
url: optional("LISTMONK_URL", "http://listmonk:9000"),
user: optional("LISTMONK_USER", "api"),
password: optional("LISTMONK_PASSWORD", ""),
},
minio: {
endpoint: optional("MINIO_ENDPOINT", "localhost"),
port: parseInt(optional("MINIO_PORT", "9000"), 10),
accessKey: optional("MINIO_ACCESS_KEY", ""),
secretKey: optional("MINIO_SECRET_KEY", ""),
},
stripe: {
secretKey: optional("STRIPE_SECRET_KEY", ""),
webhookSecret: optional("STRIPE_WEBHOOK_SECRET", ""),
},
smtp: {
host: optional("SMTP_HOST", "mail.smtp2go.com"),
port: parseInt(optional("SMTP_PORT", "587"), 10),
user: optional("SMTP_USER", ""),
pass: optional("SMTP_PASS", ""),
from: optional("SMTP_FROM", "Performance West <noreply@performancewest.net>"),
},
};
}
export const config = loadConfig();

56
api/src/create-admin.ts Normal file
View file

@ -0,0 +1,56 @@
/**
* Create an admin user for the dashboard.
*
* Usage:
* npx tsx src/create-admin.ts <username> <password> [display_name] [email]
*
* Example:
* npx tsx src/create-admin.ts justin MySecurePass123 "Justin" "justin@performancewest.net"
*
* Requires DATABASE_URL environment variable.
*/
import bcrypt from "bcryptjs";
import pg from "pg";
async function main() {
const [, , username, password, displayName, email] = process.argv;
if (!username || !password) {
console.error("Usage: npx tsx src/create-admin.ts <username> <password> [display_name] [email]");
process.exit(1);
}
const dbUrl = process.env.DATABASE_URL;
if (!dbUrl) {
console.error("DATABASE_URL environment variable is required.");
process.exit(1);
}
const pool = new pg.Pool({ connectionString: dbUrl });
try {
// Hash password with bcrypt (12 rounds)
const hash = await bcrypt.hash(password, 12);
const result = await pool.query(
`INSERT INTO admin_users (username, password_hash, display_name, email)
VALUES ($1, $2, $3, $4)
ON CONFLICT (username) DO UPDATE SET
password_hash = $2, display_name = $3, email = $4, active = TRUE
RETURNING id, username`,
[username.toLowerCase().trim(), hash, displayName || username, email || null],
);
const user = result.rows[0];
console.log(`Admin user created/updated: ${user.username} (id: ${user.id})`);
console.log(`Login at: https://performancewest.net/admin`);
} catch (err) {
console.error("Failed to create admin user:", err);
process.exit(1);
} finally {
await pool.end();
}
}
main();

19
api/src/db.ts Normal file
View file

@ -0,0 +1,19 @@
import pg from "pg";
import { config } from "./config.js";
export const pool = new pg.Pool({
connectionString: config.postgres.connectionString,
max: 10,
idleTimeoutMillis: 30_000,
connectionTimeoutMillis: 5_000,
});
/** Quick health check — resolves true if DB responds. */
export async function pgHealthy(): Promise<boolean> {
try {
await pool.query("SELECT 1");
return true;
} catch {
return false;
}
}

303
api/src/email.ts Normal file
View file

@ -0,0 +1,303 @@
/**
* Email utility outbound SMTP via nodemailer.
*
* Used for:
* - Order confirmation emails (sent immediately after payment)
* - 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.)
* Env vars: SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_FROM
*/
import nodemailer from "nodemailer";
// ─── SMTP transport ───────────────────────────────────────────────────────────
const SMTP_HOST = process.env.SMTP_HOST || "co.carrierone.com";
const SMTP_PORT = parseInt(process.env.SMTP_PORT || "587", 10);
const SMTP_USER = process.env.SMTP_USER || "";
const SMTP_PASS = process.env.SMTP_PASS || "";
const SMTP_FROM = process.env.SMTP_FROM || "Performance West <noreply@performancewest.net>";
let _transporter: nodemailer.Transporter | null = null;
function getTransporter(): nodemailer.Transporter {
if (!_transporter) {
_transporter = nodemailer.createTransport({
host: SMTP_HOST,
port: SMTP_PORT,
secure: false, // STARTTLS
auth: { user: SMTP_USER, pass: SMTP_PASS },
});
}
return _transporter;
}
// ─── Generic send ─────────────────────────────────────────────────────────────
export async function sendEmail(opts: { to: string; subject: string; html: string; text?: string }): Promise<void> {
const t = getTransporter();
await t.sendMail({
from: SMTP_FROM,
to: opts.to,
subject: opts.subject,
html: opts.html,
text: opts.text || "",
});
}
// ─── Branded HTML wrapper ─────────────────────────────────────────────────────
function htmlEmail(title: string, body: string): string {
const logo = "https://performancewest.net/images/logo.png";
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${title}</title>
</head>
<body style="margin:0;padding:0;background:#eef0f3;font-family:Arial,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#eef0f3;padding:20px 0;">
<tr><td align="center">
<table width="620" cellpadding="0" cellspacing="0" style="width:620px;max-width:620px;background:#ffffff;border-radius:8px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,0.08);">
<!-- Header with logo -->
<tr>
<td style="background:#1a2744;padding:18px 40px 14px;">
<table cellpadding="0" cellspacing="0" border="0"><tr>
<td style="vertical-align:middle;padding-right:12px;"><img src="${logo}" width="90" alt="Performance West" style="display:block;width:90px;height:auto;"></td>
<td style="vertical-align:middle;border-left:1px solid #2d4e78;padding-left:12px;"><span style="color:#8fa8d0;font-family:Arial,sans-serif;font-size:11px;letter-spacing:1.5px;text-transform:uppercase;">Telecom Compliance</span></td>
</tr></table>
</td>
</tr>
<tr><td style="background:#059669;height:3px;font-size:0;line-height:0;">&nbsp;</td></tr>
<!-- Body -->
<tr>
<td style="padding:32px 40px;">
${body}
</td>
</tr>
<!-- Footer -->
<tr>
<td style="background:#f4f5f7;border-top:1px solid #e8ecf0;padding:16px 40px;text-align:center;">
<img src="${logo}" width="60" alt="Performance West" style="display:block;margin:0 auto 8px;width:60px;height:auto;opacity:0.5;">
<p style="margin:0;font-family:Arial,sans-serif;font-size:11px;color:#9ca3af;">
Performance West Inc. &middot; <a href="https://performancewest.net" style="color:#9ca3af;">performancewest.net</a> &middot; 1-888-411-0383
</p>
</td>
</tr>
</table>
</td></tr>
</table>
</body>
</html>`;
}
// ─── Types ────────────────────────────────────────────────────────────────────
export interface OrderConfirmationParams {
order_id: string;
order_type: string;
customer_email: string;
customer_name: string;
session_id: string;
amount_cents?: number;
service_name?: string;
payment_method?: string;
}
// ─── Order confirmation email ─────────────────────────────────────────────────
const ORDER_TIMELINES: Record<string, string> = {
canada_crtc: "610 weeks",
formation: "35 business days",
bundle: "510 business days",
compliance: "110 business days (varies by service)",
compliance_batch: "110 business days (varies by service)",
};
const ORDER_LABELS: Record<string, string> = {
canada_crtc: "Canada CRTC Telecom Carrier Package",
formation: "Business Formation",
bundle: "Compliance Bundle",
compliance: "Compliance Service",
compliance_batch: "FCC Compliance Services",
};
const ORDER_NEXT_STEPS: Record<string, string[]> = {
canada_crtc: [
"We will begin your BC corporation incorporation within 12 business days.",
"You will receive an email to choose your .ca domain name once your corporation is registered.",
"Your CRTC registration letter will be prepared after incorporation.",
"Your complete corporate binder will be delivered by email when all steps are complete.",
"A Canadian business banking referral will be sent upon delivery.",
],
formation: [
"We will begin your state filing within 1 business day.",
"You will receive your filed documents by email when processing is complete.",
"If you ordered an EIN, we will obtain it from the IRS after filing.",
],
bundle: [
"Our team will review your order within 1 business day.",
"You will be contacted to schedule any required consultations.",
"Completed deliverables will be emailed to you as they are ready.",
],
compliance: [
"Our team will review your order within 1 business day.",
"You will be contacted if we need any additional information.",
"Your completed deliverable will be emailed when ready.",
],
compliance_batch: [
"We will begin processing your services within 1 business day.",
"Some services (CPNI, RMD) may require your review and approval before we submit to the FCC.",
"You will receive a separate confirmation email for each filing as it is completed.",
"If we need any additional information, we will contact you by email.",
],
};
export async function sendOrderConfirmationEmail(params: OrderConfirmationParams): Promise<void> {
const { order_id, order_type, customer_email, customer_name, session_id,
amount_cents, service_name: svcName, payment_method } = params;
if (!customer_email) {
console.warn("[email] sendOrderConfirmationEmail: no customer_email for", order_id);
return;
}
if (!SMTP_USER || !SMTP_PASS) {
console.warn("[email] SMTP not configured — skipping confirmation email for", order_id);
return;
}
const firstName = customer_name.split(" ")[0] || customer_name;
const label = svcName || ORDER_LABELS[order_type] || "Your Order";
const timeline = ORDER_TIMELINES[order_type] || "110 business days";
const nextSteps = ORDER_NEXT_STEPS[order_type] || [];
const stepsHtml = nextSteps
.map((s, i) => `<p style="margin:4px 0 0;font-size:14px;color:#374151;"><span style="color:#1e3a5f;font-weight:600;">${i + 1}.</span> ${s}</p>`)
.join("\n");
const body = `
<h1 style="margin:0 0 8px;font-size:22px;font-weight:700;color:#111827;">Order Confirmed</h1>
<p style="margin:0 0 24px;font-size:15px;color:#6b7280;">Hi ${firstName}, your payment has been received. Here is your order summary.</p>
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f9fafb;border:1px solid #e5e7eb;border-radius:6px;margin-bottom:24px;">
<tr>
<td style="padding:16px 20px;">
<p style="margin:0;font-size:13px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:.5px;">Service</p>
<p style="margin:4px 0 0;font-size:15px;font-weight:700;color:#111827;">${svcName || label}</p>
</td>
</tr>
<tr><td style="border-top:1px solid #e5e7eb;padding:16px 20px;">
<p style="margin:0;font-size:13px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:.5px;">Order Number</p>
<p style="margin:4px 0 0;font-size:15px;font-weight:600;color:#1a2744;font-family:monospace;">${order_id}</p>
</td></tr>
${amount_cents ? `<tr><td style="border-top:1px solid #e5e7eb;padding:16px 20px;">
<p style="margin:0;font-size:13px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:.5px;">Amount Paid</p>
<p style="margin:4px 0 0;font-size:18px;font-weight:700;color:#059669;">$${(amount_cents / 100).toFixed(2)}</p>
${payment_method ? `<p style="margin:2px 0 0;font-size:12px;color:#9ca3af;">via ${payment_method}</p>` : ""}
</td></tr>` : ""}
<tr><td style="border-top:1px solid #e5e7eb;padding:16px 20px;">
<p style="margin:0;font-size:13px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:.5px;">Estimated Turnaround</p>
<p style="margin:4px 0 0;font-size:15px;color:#111827;">${timeline}</p>
</td></tr>
</table>
<p style="margin:0 0 16px;font-size:13px;color:#6b7280;line-height:1.5;">
FCC compliance fees are tax deductible as ordinary business expenses under IRC &sect; 162.
A formal receipt will be sent separately.
</p>
<h2 style="margin:0 0 12px;font-size:16px;font-weight:700;color:#111827;">What happens next</h2>
${stepsHtml}
<div style="margin:24px 0 16px;padding:16px;background:#f0f9ff;border:1px solid #bae6fd;border-radius:8px;">
<p style="margin:0 0 8px;font-size:14px;font-weight:700;color:#0c4a6e;">Set up your client portal</p>
<p style="margin:0 0 12px;font-size:13px;color:#0369a1;">Track your orders, download documents, and manage your services.</p>
<a href="https://portal.performancewest.net" style="display:inline-block;background:#1e3a5f;color:#fff;padding:8px 20px;border-radius:6px;text-decoration:none;font-size:13px;font-weight:600;">Access Client Portal &rarr;</a>
<p style="margin:8px 0 0;font-size:11px;color:#64748b;">First time? <a href="https://portal.performancewest.net/login#forgot" style="color:#0369a1;">Set your password here</a></p>
</div>
<p style="margin:0;font-size:14px;color:#6b7280;">
Questions? Reply to this email or reach us at
<a href="mailto:support@performancewest.net" style="color:#1e3a5f;">support@performancewest.net</a>
or call <a href="tel:18884110383" style="color:#1e3a5f;">1-888-411-0383</a>.
</p>
`;
const transporter = getTransporter();
await transporter.sendMail({
from: SMTP_FROM,
to: customer_email,
subject: `Order Confirmed — ${order_id}${label}`,
html: htmlEmail("Order Confirmed — Performance West", body),
text: [
`Hi ${firstName},`,
``,
`Your payment has been received for order ${order_id}.`,
`Service: ${label}`,
`Estimated turnaround: ${timeline}`,
``,
`What happens next:`,
...nextSteps.map((s, i) => `${i + 1}. ${s}`),
``,
`Questions? Email support@performancewest.net or call 1-888-411-0383.`,
``,
`Performance West Inc.`,
].join("\n"),
});
console.log(`[email] Confirmation sent to ${customer_email} for ${order_id}`);
}
// ─── Portal access link email ─────────────────────────────────────────────────
export interface PortalLinkParams {
customer_email: string;
customer_name: string;
portal_url: string; // full signed URL
order_id: string;
link_purpose: string; // e.g. "domain selection", "manage your services"
}
export async function sendPortalLinkEmail(params: PortalLinkParams): Promise<void> {
const { customer_email, customer_name, portal_url, order_id, link_purpose } = params;
if (!customer_email || !SMTP_USER || !SMTP_PASS) return;
const firstName = customer_name.split(" ")[0] || customer_name;
const body = `
<h1 style="margin:0 0 8px;font-size:22px;font-weight:700;color:#111827;">Action Required</h1>
<p style="margin:0 0 24px;font-size:15px;color:#6b7280;">Hi ${firstName}, a step in your order requires your input.</p>
<p style="margin:0 0 16px;font-size:15px;color:#374151;">
Please click the button below to complete <strong>${link_purpose}</strong> for order
<strong style="font-family:monospace;">${order_id}</strong>.
</p>
<p style="margin:0 0 24px;">
<a href="${portal_url}"
style="display:inline-block;background:#1e3a5f;color:#ffffff;font-size:15px;font-weight:600;padding:12px 28px;border-radius:6px;text-decoration:none;">
Continue
</a>
</p>
<p style="font-size:12px;color:#9ca3af;">
This link expires in 72 hours. If you did not request this, please ignore this email.
</p>
`;
const transporter = getTransporter();
await transporter.sendMail({
from: SMTP_FROM,
to: customer_email,
subject: `Action needed for order ${order_id}${link_purpose}`,
html: htmlEmail("Action Needed — Performance West", body),
text: `Hi ${firstName},\n\nPlease complete ${link_purpose} for order ${order_id}:\n\n${portal_url}\n\nThis link expires in 72 hours.\n\nPerformance West Inc.`,
});
console.log(`[email] Portal link sent to ${customer_email} for ${order_id} (${link_purpose})`);
}

1030
api/src/erpnext-client.ts Normal file

File diff suppressed because it is too large Load diff

118
api/src/fx.ts Normal file
View file

@ -0,0 +1,118 @@
/**
* CAD USD foreign exchange conversion.
*
* Source: Bank of Canada daily exchange rate (Valet API).
* Caches the rate for 24 hours.
*
* cadToUsdCents(cadCents):
* 1. Fetch current CAD/USD rate (e.g., 1 CAD = 0.73 USD)
* 2. Convert CAD cents to USD cents
* 3. Add 10% buffer to cover fluctuations
* 4. Round UP to the nearest whole dollar (100 cents)
*/
let cachedRate: { rate: number; fetchedAt: number } | null = null;
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
const BUFFER_PCT = 0.10; // 10% buffer on top of spot rate
const FALLBACK_RATE = 0.72; // conservative fallback if API is unreachable
/**
* Fetch the current CAD/USD exchange rate from the Bank of Canada.
* Returns how many USD 1 CAD buys (e.g., 0.73).
*/
async function fetchCadUsdRate(): Promise<number> {
// Bank of Canada Valet API — FXCADUSD is the official daily rate
const url = "https://www.bankofcanada.ca/valet/observations/FXCADUSD/json?recent=1";
try {
const resp = await fetch(url, {
signal: AbortSignal.timeout(8000),
headers: { "Accept": "application/json" },
});
if (!resp.ok) {
throw new Error(`Bank of Canada API returned ${resp.status}`);
}
const data = await resp.json() as {
observations: Array<{ d: string; FXCADUSD: { v: string } }>;
};
const obs = data.observations;
if (!obs || obs.length === 0) {
throw new Error("No observations returned from Bank of Canada");
}
const rate = parseFloat(obs[obs.length - 1].FXCADUSD.v);
if (isNaN(rate) || rate <= 0 || rate > 2) {
throw new Error(`Invalid rate value: ${obs[obs.length - 1].FXCADUSD.v}`);
}
console.log(`[fx] Bank of Canada CAD/USD rate: ${rate} (date: ${obs[obs.length - 1].d})`);
return rate;
} catch (err) {
console.warn(`[fx] Failed to fetch Bank of Canada rate: ${err}. Using fallback ${FALLBACK_RATE}`);
return FALLBACK_RATE;
}
}
/**
* Get the current CAD/USD rate (cached for 24h).
*/
async function getCadUsdRate(): Promise<number> {
const now = Date.now();
if (cachedRate && (now - cachedRate.fetchedAt) < CACHE_TTL_MS) {
return cachedRate.rate;
}
const rate = await fetchCadUsdRate();
cachedRate = { rate, fetchedAt: now };
return rate;
}
/**
* Convert CAD cents to USD cents with 10% buffer, rounded UP to nearest whole dollar.
*
* Example: C$350 (35000 cents) at rate 0.73:
* 35000 * 0.73 = 25550 USD cents
* 25550 * 1.10 = 28105 (with 10% buffer)
* Round up to $282.00 = 28200 cents
*/
export async function cadToUsdCents(cadCents: number): Promise<number> {
const rate = await getCadUsdRate();
const usdCentsRaw = cadCents * rate;
const withBuffer = usdCentsRaw * (1 + BUFFER_PCT);
// Round UP to nearest 100 (whole dollar)
const rounded = Math.ceil(withBuffer / 100) * 100;
return rounded;
}
/**
* Synchronous version using cached rate (returns 0 if not yet fetched).
* Use for display purposes only call cadToUsdCents() for order pricing.
*/
export function cadToUsdCentsSync(cadCents: number): number {
if (!cachedRate) return 0;
const usdCentsRaw = cadCents * cachedRate.rate;
const withBuffer = usdCentsRaw * (1 + BUFFER_PCT);
return Math.ceil(withBuffer / 100) * 100;
}
/**
* Get the current cached rate info (for display/debugging).
*/
export async function getFxInfo(): Promise<{ rate: number; withBuffer: number; fetchedAt: string }> {
const rate = await getCadUsdRate();
return {
rate,
withBuffer: rate * (1 + BUFFER_PCT),
fetchedAt: cachedRate ? new Date(cachedRate.fetchedAt).toISOString() : "never",
};
}
/**
* Pre-warm the cache at startup.
*/
export async function warmFxCache(): Promise<void> {
await getCadUsdRate();
}

184
api/src/index.ts Normal file
View file

@ -0,0 +1,184 @@
import express from "express";
import cookieParser from "cookie-parser";
import { config } from "./config.js";
import { pool, pgHealthy } from "./db.js";
import { securityHeaders, extractClientIp } from "./middleware/security.js";
import { corsMiddleware } from "./middleware/cors.js";
import { globalLimiter } from "./middleware/rate-limit.js";
import { errorHandler } from "./middleware/error-handler.js";
import { accessLog } from "./middleware/access-log.js";
import healthRouter from "./routes/health.js";
import subscribeRouter from "./routes/subscribe.js";
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 adminRouter from "./routes/admin.js";
import webhooksRouter from "./routes/webhooks.js";
import identityRouter from "./routes/identity.js";
import refundsRouter from "./routes/refunds.js";
import agentsRouter from "./routes/agents.js";
import bundlesRouter from "./routes/bundles.js";
import entitiesRouter from "./routes/entities.js";
import idUploadRouter from "./routes/id-upload.js";
import canadaCrtcRouter from "./routes/canada-crtc.js";
import checkoutRouter from "./routes/checkout.js";
import ambLocationsRouter from "./routes/amb-locations.js";
import paymentMethodsRouter from "./routes/payment-methods.js";
import paypalRouter from "./routes/paypal.js";
import portalAuthRouter from "./routes/portal-auth.js";
import portalRouter from "./routes/portal.js";
import portalSetupRouter from "./routes/portal-setup.js";
import portalEsignRouter from "./routes/portal-esign.js";
import fccLookupRouter from "./routes/fcc-lookup.js";
import telecomEntitiesRouter from "./routes/telecom-entities.js";
import complianceOrdersRouter from "./routes/compliance-orders.js";
import cdrRouter from "./routes/cdr.js";
import iccRouter from "./routes/icc.js";
import resellerCertsRouter from "./routes/reseller-certs.js";
import lnpaRegionsRouter from "./routes/lnpa-regions.js";
import fccFilingsRouter from "./routes/fcc-filings.js";
import adminCryptoRouter from "./routes/admin-crypto.js";
import foreignQualRouter from "./routes/foreign-qualification.js";
import corpStatusRouter from "./routes/corp-status.js";
import portalRmdReviewRouter from "./routes/portal-rmd-review.js";
import pucRouter from "./routes/puc.js";
const app = express();
// Trust first proxy (nginx) in production
if (config.nodeEnv === "production") {
app.set("trust proxy", 1);
}
// --- Middleware stack (order matters) ---
app.use(securityHeaders);
app.use(extractClientIp);
app.use(accessLog);
app.use(corsMiddleware);
app.use(cookieParser());
app.use(globalLimiter);
// Stripe webhook — raw body MUST be preserved for signature verification.
// Mount BEFORE express.json() so the Buffer is not parsed away.
app.use("/api/v1/webhooks/stripe", express.raw({ type: "application/json" }));
app.use(identityRouter); // identity webhook uses raw() internally on its specific route
app.use(express.json({ limit: "512kb" })); // 512kb for eSign signature PNG base64
// Reject non-JSON content types on POST/PUT/PATCH
app.use((req, res, next) => {
if (["POST", "PUT", "PATCH"].includes(req.method) && !req.is("json")) {
res.status(415).json({ error: "Content-Type must be application/json" });
return;
}
next();
});
// --- Routes ---
app.use(healthRouter);
app.use(subscribeRouter);
app.use(ticketsRouter);
app.use(quotesRouter);
app.use(formationsRouter);
app.use(discountsRouter);
app.use(adminRouter);
app.use(webhooksRouter);
app.use(refundsRouter);
app.use(agentsRouter);
app.use(bundlesRouter);
app.use(entitiesRouter);
app.use(idUploadRouter);
app.use(canadaCrtcRouter);
app.use(checkoutRouter);
app.use(ambLocationsRouter);
app.use(paymentMethodsRouter);
app.use(paypalRouter);
app.use("/api/v1/auth", portalAuthRouter);
app.use(portalSetupRouter);
app.use(portalEsignRouter);
app.use(portalRmdReviewRouter);
app.use("/api/v1/portal", portalRouter); // Must be AFTER specific portal routes (uses catch-all customer-auth)
app.use(fccLookupRouter);
app.use(corpStatusRouter);
app.use(telecomEntitiesRouter);
app.use(complianceOrdersRouter);
app.use(cdrRouter);
app.use(iccRouter);
app.use(resellerCertsRouter);
app.use(lnpaRegionsRouter);
app.use(fccFilingsRouter);
app.use(foreignQualRouter);
app.use(pucRouter);
app.use(adminCryptoRouter);
// Note: identityRouter mounted above express.json() for webhook route,
// but also handles non-webhook routes (create-session, poll) which work fine with json()
// --- Error handler (must be last) ---
app.use(errorHandler);
// --- Start ---
async function start() {
// Verify database connection
const dbOk = await pgHealthy();
if (dbOk) {
console.log(`[db] PostgreSQL connected`);
} else {
console.warn(`[db] PostgreSQL unreachable — API will start but DB-dependent routes will fail`);
}
// Pre-warm FX rate cache
import("./fx.js").then(fx => fx.warmFxCache()).catch(err => console.warn("[fx] warmup failed:", err));
// Log ERPNext/Listmonk configuration status (non-blocking)
if (config.erpnext.apiKey) {
console.log(`[erpnext] Configured at ${config.erpnext.url}`);
try {
const r = await fetch(`${config.erpnext.url.replace(/\/$/, "")}/api/method/ping`, {
headers: {
Authorization: `token ${config.erpnext.apiKey}:${config.erpnext.apiSecret}`,
"X-Frappe-Site-Name": config.erpnext.siteName,
},
});
if (r.ok) {
console.log("[erpnext] Connectivity check passed");
} else {
console.warn(`[erpnext] Connectivity check failed (HTTP ${r.status})`);
}
} catch (err) {
console.warn("[erpnext] Connectivity check failed (network error):", err);
}
} else {
console.warn(`[erpnext] No API key configured — ERPNext integration disabled`);
}
if (config.listmonk.password) {
console.log(`[listmonk] Configured at ${config.listmonk.url}`);
} else {
console.warn(`[listmonk] No credentials configured — Listmonk integration disabled`);
}
const host = "0.0.0.0"; // bind all interfaces — nginx on host proxies to Docker container port
app.listen(config.port, host, () => {
console.log(`[api] Performance West API listening on ${host}:${config.port} (${config.nodeEnv})`);
});
}
start().catch((err) => {
console.error("[api] Fatal startup error:", err);
process.exit(1);
});
// Graceful shutdown
process.on("SIGTERM", async () => {
console.log("[api] SIGTERM received, shutting down...");
await pool.end();
process.exit(0);
});
process.on("SIGINT", async () => {
console.log("[api] SIGINT received, shutting down...");
await pool.end();
process.exit(0);
});

View file

@ -0,0 +1,244 @@
// FCC Form 499-A shared utilities (TypeScript / API side)
//
// Mirrors scripts/workers/services/telecom/fcc_499_utils.py — same logic
// is available to both the API (for /validate dry-run) and the worker
// handler (for actual form submission). Keep in sync.
//
// Authority: 2026 Form 499-A Instructions.
import { pool } from "../db.js";
// ── Line 105 box-tick derivation ──────────────────────────────────────────
export const LINE_105_BOX_NUMBERS: Record<string, number> = {
voip_interconnected: 1,
voip_non_interconnected: 2,
clec: 3,
ilec: 4,
local_reseller: 5,
toll_reseller: 6,
ixc: 7,
wireless: 8,
mvno: 9,
prepaid_calling_card: 10,
private_line: 11,
satellite: 12,
payphone: 13,
osp: 14,
shared_tenant: 15,
audio_bridging: 16,
toll_free: 17,
paging: 18,
smr: 19,
fixed_wireless: 20,
mobile_satellite: 21,
other: 22,
};
export interface Line105Entry {
id: string;
rank: number;
infra_type?: "facilities" | "reseller" | "mvno";
is_tdm_service?: boolean;
}
export function derivedLine105Boxes(
categoryId: string,
infraType: string | undefined,
): number[] {
const boxes: number[] = [];
if (infraType === "reseller") {
if (categoryId === "clec") boxes.push(5);
if (categoryId === "ixc") boxes.push(6);
}
if (infraType === "mvno" && categoryId === "wireless") boxes.push(9);
return boxes;
}
export function allLine105BoxesToTick(categories: Line105Entry[]): number[] {
const boxes = new Set<number>();
for (const cat of categories ?? []) {
if (cat.id && LINE_105_BOX_NUMBERS[cat.id] !== undefined) {
boxes.add(LINE_105_BOX_NUMBERS[cat.id]);
}
for (const b of derivedLine105Boxes(cat.id, cat.infra_type)) {
boxes.add(b);
}
}
return Array.from(boxes).sort((a, b) => a - b);
}
// ── Safe harbor lookup ────────────────────────────────────────────────────
export const SAFE_HARBOR_DISALLOWED = new Set(["voip_non_interconnected"]);
export function safeHarborAllowed(categoryId: string): boolean {
return !SAFE_HARBOR_DISALLOWED.has(categoryId);
}
export async function loadSafeHarborPct(
formYear: number,
categoryId: string,
): Promise<number | null> {
if (SAFE_HARBOR_DISALLOWED.has(categoryId)) return null;
const r = await pool.query(
`SELECT interstate_pct FROM fcc_safe_harbor_percentages
WHERE form_year = $1 AND line_105_category = $2`,
[formYear, categoryId],
);
return r.rows[0] ? Number(r.rows[0].interstate_pct) : null;
}
// ── De minimis calculator (Appendix A) ───────────────────────────────────
export interface DeMinimisWorksheet {
form_year: number;
line_1_filer_interstate_cents: number;
line_2_filer_intl_cents: number;
line_3_affiliates_interstate_cents: number;
line_4_affiliates_intl_cents: number;
line_5_consolidated_interstate_cents: number;
line_6_consolidated_total_cents: number;
line_7_interstate_pct: number;
line_8_lire_exempt: boolean;
line_9_contribution_base_cents: number;
line_10_factor: number;
line_11_estimated_contrib_cents: number;
is_de_minimis: boolean;
threshold_usd: number;
notes: string[];
}
export interface AffiliateRevenue {
total_revenue_cents: number;
interstate_pct: number;
international_pct: number;
}
export async function loadDeMinimisFactor(formYear: number): Promise<number> {
const r = await pool.query(
`SELECT factor FROM fcc_deminimis_factors WHERE form_year = $1`,
[formYear],
);
if (!r.rows[0]) {
throw new Error(`No de minimis factor configured for form year ${formYear}`);
}
return Number(r.rows[0].factor);
}
export async function calculateDeMinimis(opts: {
form_year: number;
filer_total_revenue_cents: number;
filer_interstate_pct: number;
filer_international_pct: number;
affiliates?: AffiliateRevenue[];
}): Promise<DeMinimisWorksheet> {
const affiliates = opts.affiliates ?? [];
const notes: string[] = [];
const line_1 = Math.round(
opts.filer_total_revenue_cents * (opts.filer_interstate_pct / 100),
);
const line_2 = Math.round(
opts.filer_total_revenue_cents * (opts.filer_international_pct / 100),
);
let line_3 = 0;
let line_4 = 0;
for (const a of affiliates) {
line_3 += Math.round(a.total_revenue_cents * a.interstate_pct / 100);
line_4 += Math.round(a.total_revenue_cents * a.international_pct / 100);
}
const line_5 = line_1 + line_3;
const intl_total = line_2 + line_4;
const line_6 = line_5 + intl_total;
const line_7 = line_6 > 0
? Math.round(100 * line_5 / line_6 * 10000) / 10000
: 0;
const line_8 = line_7 <= 12.0;
const line_9 = line_5 + (line_8 ? 0 : intl_total);
const line_10 = await loadDeMinimisFactor(opts.form_year);
const line_11 = Math.round(line_9 * line_10);
const threshold_usd = 10000;
const is_de_minimis = line_11 < threshold_usd * 100;
if (is_de_minimis) {
notes.push(
`De minimis: estimated contribution $${(line_11 / 100).toFixed(2)} < ` +
`$${threshold_usd.toLocaleString()} threshold.`,
);
} else {
notes.push(
`NOT de minimis: estimated contribution $${(line_11 / 100).toFixed(2)} ` +
`$${threshold_usd.toLocaleString()} threshold.`,
);
}
if (line_8) {
notes.push(
`LIRE exempt: interstate (${line_7.toFixed(2)}%) ≤ 12% of combined` +
` interstate+intl — international revenue excluded.`,
);
}
return {
form_year: opts.form_year,
line_1_filer_interstate_cents: line_1,
line_2_filer_intl_cents: line_2,
line_3_affiliates_interstate_cents: line_3,
line_4_affiliates_intl_cents: line_4,
line_5_consolidated_interstate_cents: line_5,
line_6_consolidated_total_cents: line_6,
line_7_interstate_pct: line_7,
line_8_lire_exempt: line_8,
line_9_contribution_base_cents: line_9,
line_10_factor: line_10,
line_11_estimated_contrib_cents: line_11,
is_de_minimis,
threshold_usd,
notes,
};
}
// ── Line 612 filing type ─────────────────────────────────────────────────
export type FilingType =
| "original_april_1"
| "registration_new_filer"
| "revised_registration"
| "revised_revenue";
export function detectFilingType(opts: {
entity: { filer_id_499?: string | null };
current_year_filing_exists?: boolean;
revised_reason?: "registration" | "revenue" | null;
}): FilingType {
if (!opts.entity.filer_id_499) return "registration_new_filer";
if (opts.current_year_filing_exists) {
if (opts.revised_reason === "registration") return "revised_registration";
if (opts.revised_reason === "revenue") return "revised_revenue";
}
return "original_april_1";
}
// ── TRS contribution base (Lines 512-514) ────────────────────────────────
export const TRS_BASE_LINE_KEYS = [
"line_403", "line_404", "line_404_1", "line_404_3",
"line_405", "line_406", "line_407", "line_408",
"line_409", "line_410", "line_411", "line_412",
"line_413", "line_414_1", "line_414_2",
"line_415", "line_416", "line_417",
"line_418_4",
] as const;
export function computeTrsContributionBase(
revenueLines: Record<string, number | undefined | null>,
): { line_512: number; line_513: number; line_514: number } {
const sum = TRS_BASE_LINE_KEYS.reduce(
(acc, k) => acc + (Number(revenueLines[k]) || 0),
0,
);
const line_512 = sum - (Number(revenueLines.line_511) || 0);
const line_513 = Number(revenueLines.line_513) || 0;
const line_514 = line_512 - line_513;
return { line_512, line_513, line_514 };
}

View file

@ -0,0 +1,30 @@
import type { Request, Response, NextFunction } from "express";
/**
* Structured access logger. Outputs one JSON line per request to stdout
* prefixed with [ACCESS] for easy parsing by fail2ban and log aggregation.
*/
export function accessLog(req: Request, res: Response, next: NextFunction): void {
const start = Date.now();
res.on("finish", () => {
const ms = Date.now() - start;
const log = {
ts: new Date().toISOString(),
ip: (req as any).clientIp || req.ip || "-",
method: req.method,
path: req.originalUrl,
status: res.statusCode,
ms,
ua: req.headers["user-agent"]?.slice(0, 200) || "-",
bytes: parseInt(res.getHeader("content-length") as string, 10) || 0,
};
// Only log non-health requests (reduces noise)
if (req.originalUrl !== "/api/v1/status") {
console.log(`[ACCESS] ${JSON.stringify(log)}`);
}
});
next();
}

View file

@ -0,0 +1,41 @@
import type { Request, Response, NextFunction } from "express";
import jwt from "jsonwebtoken";
import { config } from "../config.js";
const JWT_SECRET = process.env.ADMIN_JWT_SECRET || "change-this-in-production";
export interface AdminPayload {
id: number;
username: string;
}
declare global {
namespace Express {
interface Request {
admin?: AdminPayload;
}
}
}
/** Sign a JWT for an admin user. */
export function signAdminToken(payload: AdminPayload): string {
return jwt.sign(payload, JWT_SECRET, { expiresIn: "8h" });
}
/** Verify admin JWT from Authorization: Bearer <token> header. */
export function requireAdmin(req: Request, res: Response, next: NextFunction): void {
const header = req.headers.authorization;
if (!header || !header.startsWith("Bearer ")) {
res.status(401).json({ error: "Authentication required." });
return;
}
const token = header.slice(7);
try {
const decoded = jwt.verify(token, JWT_SECRET) as AdminPayload;
req.admin = decoded;
next();
} catch {
res.status(401).json({ error: "Invalid or expired token." });
}
}

View file

@ -0,0 +1,41 @@
import cors from "cors";
import { config } from "../config.js";
const PRODUCTION_ORIGINS = [
"https://performancewest.net",
"https://www.performancewest.net",
"https://dev.performancewest.net",
"http://192.168.7.4:4322",
];
const DEV_ORIGINS = [
"http://localhost:4322",
"http://localhost:3001",
"http://127.0.0.1:4322",
"http://127.0.0.1:3001",
];
// In dev mode, also allow any origin on common dev ports (LAN access)
const isDev = config.nodeEnv !== "production";
const allowedOrigins =
config.nodeEnv === "production"
? PRODUCTION_ORIGINS
: [...PRODUCTION_ORIGINS, ...DEV_ORIGINS];
export const corsMiddleware = cors({
origin: (origin, cb) => {
// Allow requests with no origin (server-to-server, curl, etc.)
if (!origin) { cb(null, true); return; }
if (allowedOrigins.includes(origin)) { cb(null, true); return; }
// In dev mode, allow any origin on known dev ports (LAN access from other machines)
if (isDev && /^http:\/\/[\d.]+:(4322|3001)$/.test(origin)) { cb(null, true); return; }
if (isDev && /^http:\/\/192\.168\./.test(origin)) { cb(null, true); return; }
cb(new Error(`Origin ${origin} not allowed by CORS`));
},
methods: ["GET", "POST", "PATCH", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization"],
exposedHeaders: ["RateLimit-Limit", "RateLimit-Remaining", "RateLimit-Reset"],
credentials: true,
maxAge: 86_400,
});

View file

@ -0,0 +1,57 @@
import { Request, Response, NextFunction } from "express";
import jwt from "jsonwebtoken";
const JWT_SECRET = process.env.ADMIN_JWT_SECRET || "changeme";
const COOKIE_NAME = "pw_customer";
export interface CustomerPayload {
customerId: number;
email: string;
}
declare global {
namespace Express {
interface Request {
customer?: CustomerPayload;
}
}
}
/** Middleware: attach customer from cookie JWT. Never blocks — sets req.customer if valid. */
export function optionalCustomerAuth(req: Request, _res: Response, next: NextFunction) {
const token = req.cookies?.[COOKIE_NAME];
if (!token) return next();
try {
req.customer = jwt.verify(token, JWT_SECRET) as CustomerPayload;
} catch {
// expired or invalid — ignore, let route decide
}
next();
}
/** Middleware: require valid customer session. Returns 401 if not logged in. */
export function requireCustomerAuth(req: Request, res: Response, next: NextFunction) {
optionalCustomerAuth(req, res, () => {
if (!req.customer) {
return res.status(401).json({ error: "Login required", code: "UNAUTHENTICATED" });
}
next();
});
}
/** Issue a customer session JWT cookie (7-day). */
export function issueCustomerCookie(res: Response, payload: CustomerPayload) {
const token = jwt.sign(payload, JWT_SECRET, { expiresIn: "7d" });
res.cookie(COOKIE_NAME, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 7 * 24 * 60 * 60 * 1000,
path: "/",
});
}
/** Clear the customer session cookie. */
export function clearCustomerCookie(res: Response) {
res.clearCookie(COOKIE_NAME, { path: "/" });
}

View file

@ -0,0 +1,33 @@
import type { Request, Response, NextFunction } from "express";
interface AppError extends Error {
statusCode?: number;
details?: unknown;
}
/** Create a typed error with status code. */
export function createError(message: string, statusCode: number, details?: unknown): AppError {
const err: AppError = new Error(message);
err.statusCode = statusCode;
err.details = details;
return err;
}
/** Express error-handling middleware — must be mounted last. */
export function errorHandler(
err: AppError,
_req: Request,
res: Response,
_next: NextFunction,
): void {
const status = err.statusCode || 500;
const isDev = process.env.NODE_ENV !== "production";
console.error(`[ERROR] ${status} ${err.message}`, isDev ? err.stack : "");
res.status(status).json({
error: err.message || "Internal server error",
...(err.details ? { details: err.details } : {}),
...(isDev && err.stack ? { stack: err.stack } : {}),
});
}

View file

@ -0,0 +1,27 @@
// internal-auth.ts — Shared-secret authentication for internal API endpoints
// Used by Verilex Data to access bulk entity export and name search endpoints.
import type { Request, Response, NextFunction } from "express";
const INTERNAL_API_KEY = process.env.PW_INTERNAL_API_KEY || "";
export function internalAuth(req: Request, res: Response, next: NextFunction): void {
if (!INTERNAL_API_KEY) {
res.status(503).json({ error: "Internal API not configured" });
return;
}
const authHeader = req.headers.authorization || "";
if (!authHeader.startsWith("Bearer ")) {
res.status(401).json({ error: "Missing Authorization header" });
return;
}
const token = authHeader.slice(7);
if (token !== INTERNAL_API_KEY) {
res.status(401).json({ error: "Invalid API key" });
return;
}
next();
}

View file

@ -0,0 +1,107 @@
/**
* Portal authentication middleware.
*
* Customer portal pages (/portal/*) are accessed via signed JWT links that
* are emailed to customers. No password is needed the link IS the credential.
*
* Token format (JWT, HS256):
* payload: { order_id, order_type, email, iat, exp }
* secret: CUSTOMER_JWT_SECRET env var
*
* Token is passed as:
* 1. Query param: ?token=... (email links)
* 2. Authorization header: Bearer ... (XHR/fetch from portal page)
* 3. Cookie: pw_portal_token=... (set by the portal page on first load)
*
* Helper: generatePortalToken(order_id, order_type, email) signed JWT
*/
import { type Request, type Response, type NextFunction } from "express";
import jwt from "jsonwebtoken";
const CUSTOMER_JWT_SECRET = process.env.CUSTOMER_JWT_SECRET || "changeme_long_random_string";
const TOKEN_TTL_SECONDS = 72 * 60 * 60; // 72 hours
export interface PortalTokenPayload {
order_id: string;
order_type: string;
email: string;
}
// ─── Generate a signed portal link token ─────────────────────────────────────
export function generatePortalToken(
order_id: string,
order_type: string,
email: string,
): string {
return jwt.sign(
{ order_id, order_type, email } satisfies PortalTokenPayload,
CUSTOMER_JWT_SECRET,
{ expiresIn: TOKEN_TTL_SECONDS },
);
}
// ─── Build a signed portal URL ────────────────────────────────────────────────
export function portalUrl(
path: string, // e.g. "/portal/domain-search"
order_id: string,
order_type: string,
email: string,
): string {
const token = generatePortalToken(order_id, order_type, email);
const domain = process.env.DOMAIN ? `https://${process.env.DOMAIN}` : "http://localhost:4321";
return `${domain}${path}?token=${encodeURIComponent(token)}`;
}
// ─── Middleware: verify portal token ─────────────────────────────────────────
// Attaches req.portalAuth = { order_id, order_type, email } on success.
// Returns 401 if token is missing, 403 if invalid/expired.
declare global {
namespace Express {
interface Request {
portalAuth?: PortalTokenPayload;
}
}
}
export function requirePortalAuth(req: Request, res: Response, next: NextFunction): void {
// 1. Query param (email link on first load)
let rawToken = (req.query.token as string) || null;
// 2. Authorization header (XHR from portal page after first load)
if (!rawToken) {
const authHeader = req.headers.authorization || "";
if (authHeader.startsWith("Bearer ")) {
rawToken = authHeader.slice(7);
}
}
// 3. Cookie (set by portal page JS after extracting from URL)
if (!rawToken) {
rawToken = (req.cookies?.pw_portal_token as string) || null;
}
if (!rawToken) {
res.status(401).json({ error: "Authentication required. Please use the link from your email.", code: "AUTH_REQUIRED" });
return;
}
try {
const payload = jwt.verify(rawToken, CUSTOMER_JWT_SECRET) as PortalTokenPayload & { iat: number; exp: number };
req.portalAuth = {
order_id: payload.order_id,
order_type: payload.order_type,
email: payload.email,
};
next();
} catch (err: any) {
if (err?.name === "TokenExpiredError") {
res.status(403).json({ error: "Your portal link has expired. Please request a new one.", code: "TOKEN_EXPIRED" });
} else {
res.status(403).json({ error: "Invalid portal link.", code: "TOKEN_INVALID" });
}
}
}

View file

@ -0,0 +1,21 @@
import rateLimit from "express-rate-limit";
/** Global rate limiter — 200 requests per minute per IP. */
export const globalLimiter = rateLimit({
windowMs: 60_000,
max: 200,
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => (req as any).clientIp || req.ip || "unknown",
message: { error: "Too many requests. Please wait and try again." },
});
/** Strict limiter for form submissions — 5 per minute per IP (50 in dev/test). */
export const submitLimiter = rateLimit({
windowMs: 60_000,
max: process.env.NODE_ENV === "production" ? 5 : 50,
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => (req as any).clientIp || req.ip || "unknown",
message: { error: "Too many submissions. Please wait a moment." },
});

View file

@ -0,0 +1,38 @@
import helmet from "helmet";
import type { Request, Response, NextFunction } from "express";
// Strict security headers via Helmet.
export const securityHeaders = helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'none'"],
frameAncestors: ["'none'"],
},
},
hsts: { maxAge: 31_536_000, includeSubDomains: true, preload: true },
frameguard: { action: "deny" },
noSniff: true,
referrerPolicy: { policy: "no-referrer" },
hidePoweredBy: true,
// This is a public API accessed cross-origin — must be cross-origin not same-origin
crossOriginResourcePolicy: { policy: "cross-origin" },
// Allow cross-origin opener for Stripe Identity redirect flows
crossOriginOpenerPolicy: { policy: "unsafe-none" },
});
// Attach normalised client IP to req (handles IPv6-mapped IPv4).
declare global {
namespace Express {
interface Request {
clientIp?: string;
}
}
}
export function extractClientIp(req: Request, _res: Response, next: NextFunction): void {
let ip = (req.headers["x-forwarded-for"] as string)?.split(",")[0]?.trim() || req.ip || "";
// Normalise ::ffff:127.0.0.1 → 127.0.0.1
if (ip.startsWith("::ffff:")) ip = ip.slice(7);
req.clientIp = ip;
next();
}

View file

@ -0,0 +1,337 @@
/**
* Admin crypto treasury endpoints.
*
* All endpoints require the admin token (X-Admin-Token header) same
* gate used by admin-filings and reseller-certs. Read-only endpoints
* return 200; mutating endpoints audit to order_audit_log.
*/
import crypto from "node:crypto";
import { Router } from "express";
import type { Request, Response } from "express";
import { pool } from "../db.js";
const router = Router();
// ── Auth middleware ─────────────────────────────────────────────────────
function requireAdminToken(req: Request, res: Response): boolean {
const expected = process.env.ADMIN_API_TOKEN || "";
const supplied = (req.headers["x-admin-token"] || "").toString().trim();
if (!expected) {
// If token not configured, reject — fail-closed.
res.status(503).json({ error: "ADMIN_API_TOKEN not set" });
return false;
}
// Timing-safe compare. timingSafeEqual throws on length mismatch, so
// guard with a length check first (a cheap length-disclosure trade-off
// that's negligible compared to the attack surface of naïve ==).
const sb = Buffer.from(supplied);
const eb = Buffer.from(expected);
const ok = sb.length === eb.length && crypto.timingSafeEqual(sb, eb);
if (!ok) {
res.status(403).json({ error: "forbidden" });
return false;
}
return true;
}
async function auditLog(
actor: string, action: string, target: string, details?: unknown,
) {
// order_audit_log schema requires: order_type IN ('formation','service','quote'),
// order_id (integer, NOT NULL), action, actor_type IN
// ('system','admin','worker','customer') + optional order_number,
// actor_name, metadata. We use order_type='service' (closest match —
// crypto treasury is an internal service action) and store the real
// crypto-treasury target (order_number or 'sweep:N') in order_number.
try {
await pool.query(
`INSERT INTO order_audit_log
(order_type, order_id, order_number, action, actor_type, actor_name, metadata)
VALUES ('service', 0, $1, $2, 'admin', $3, $4::jsonb)`,
[target, action, actor, JSON.stringify(details ?? {})],
);
} catch (err) {
console.error("[admin-crypto] audit log failed:", err);
// non-fatal
}
}
// ── GET /api/v1/admin/crypto-payments ───────────────────────────────────
router.get(
"/api/v1/admin/crypto-payments",
async (req: Request, res: Response) => {
if (!requireAdminToken(req, res)) return;
const stateFilter = typeof req.query.state === "string" ? req.query.state : "";
const params: (string | number)[] = [];
const where: string[] = [];
if (stateFilter) {
where.push(`j.state = $${params.length + 1}`);
params.push(stateFilter);
}
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
const r = await pool.query(
`
SELECT j.order_id, j.order_type, j.state, j.coin,
j.amount_coin, j.amount_usd_cents, j.needed_usd_cents,
j.offramp_provider, j.offramp_ref, j.relay_deposit_id,
j.target_card_id, j.last_error, j.attempt_count,
j.next_retry_at, j.received_at, j.funds_at_relay_at, j.settled_at,
j.created_at, j.updated_at,
(
SELECT COALESCE(SUM(amount_usd_cents), 0)
FROM vendor_obligations
WHERE order_id = j.order_id
) AS total_obligations_cents,
(
SELECT COUNT(*)
FROM vendor_obligations
WHERE order_id = j.order_id AND status = 'paid'
) AS obligations_paid
FROM crypto_payment_jobs j
${whereSql}
ORDER BY j.created_at DESC
LIMIT 200
`,
params,
);
res.json({ jobs: r.rows, count: r.rows.length });
},
);
// NOTE: specific paths (sweeps, tax-export) are registered BELOW before
// the /:order_id param route to avoid Express pattern-match conflicts
// (order_id='sweeps' would otherwise match this handler).
// ── GET /api/v1/admin/crypto-payments/:order_id ────────────────────────
// Registered AFTER the specific sub-paths below. Express matches in
// registration order; putting this last ensures /sweeps and
// /tax-export aren't interpreted as order_ids.
// ── Detail view (must come AFTER specific sub-paths below) ────────────
//
// We re-register this at the end of the file so Express pattern-matches
// /sweeps and /tax-export first.
// ── POST /api/v1/admin/crypto-payments/:order_id/retry-offramp ─────────
router.post(
"/api/v1/admin/crypto-payments/:order_id/retry-offramp",
async (req: Request, res: Response) => {
if (!requireAdminToken(req, res)) return;
const orderId = req.params.order_id;
const r = await pool.query(
`UPDATE crypto_payment_jobs
SET state = 'sizing',
last_error = NULL,
next_retry_at = NULL,
updated_at = NOW()
WHERE order_id = $1
AND state IN ('manual','failed','offramping')
RETURNING state, attempt_count`,
[orderId],
);
if (r.rows.length === 0) {
res.status(409).json({
error: "job not in a retry-able state (must be manual / failed / offramping)",
});
return;
}
await auditLog(
req.headers["x-admin-user"]?.toString() || "admin",
"crypto_retry_offramp", orderId, { new_state: "sizing" },
);
res.json({ ok: true, state: r.rows[0].state });
},
);
// ── POST /api/v1/admin/crypto-payments/:order_id/mark-settled ──────────
router.post(
"/api/v1/admin/crypto-payments/:order_id/mark-settled",
async (req: Request, res: Response) => {
if (!requireAdminToken(req, res)) return;
const orderId = req.params.order_id;
const { note } = req.body ?? {};
const r = await pool.query(
`UPDATE crypto_payment_jobs
SET state = 'settled',
settled_at = NOW(),
last_error = NULL,
updated_at = NOW()
WHERE order_id = $1
RETURNING state`,
[orderId],
);
if (r.rows.length === 0) {
res.status(404).json({ error: "job not found" }); return;
}
await auditLog(
req.headers["x-admin-user"]?.toString() || "admin",
"crypto_manual_settle", orderId, { note },
);
res.json({ ok: true });
},
);
// ── GET /api/v1/admin/crypto-payments/sweeps ───────────────────────────
router.get(
"/api/v1/admin/crypto-payments/sweeps",
async (req: Request, res: Response) => {
if (!requireAdminToken(req, res)) return;
const r = await pool.query(
`SELECT * FROM cold_wallet_sweeps
WHERE status IN ('pending','approved','broadcast')
ORDER BY created_at DESC
LIMIT 100`,
);
res.json({ sweeps: r.rows });
},
);
// ── POST /api/v1/admin/crypto-payments/sweeps/:id/approve ──────────────
router.post(
"/api/v1/admin/crypto-payments/sweeps/:id/approve",
async (req: Request, res: Response) => {
if (!requireAdminToken(req, res)) return;
const sweepId = Number(req.params.id);
if (!Number.isFinite(sweepId)) {
res.status(400).json({ error: "bad sweep id" }); return;
}
const actor = req.headers["x-admin-user"]?.toString() || "admin";
const r = await pool.query(
`UPDATE cold_wallet_sweeps
SET status = 'approved',
approved_by = $2,
approved_at = NOW(),
updated_at = NOW()
WHERE id = $1
AND status = 'pending'
RETURNING coin, amount_coin`,
[sweepId, actor],
);
if (r.rows.length === 0) {
res.status(409).json({ error: "sweep not in pending state" }); return;
}
await auditLog(actor, "crypto_sweep_approve", `sweep:${sweepId}`,
{ coin: r.rows[0].coin, amount_coin: r.rows[0].amount_coin });
res.json({ ok: true });
},
);
// ── GET /api/v1/admin/crypto-payments/tax-export?year=YYYY ─────────────
//
// IRS Form 8949 columns: description, date_acquired, date_sold,
// proceeds, cost_basis, gain_loss. Covers all offramp/disposal rows
// whose disposed_at is in the given tax year.
router.get(
"/api/v1/admin/crypto-payments/tax-export",
async (req: Request, res: Response) => {
if (!requireAdminToken(req, res)) return;
const year = Number(req.query.year) || new Date().getUTCFullYear() - 1;
const r = await pool.query(
`
SELECT order_id, coin,
amount_coin, fx_rate_usd,
acquired_at, disposed_at,
basis_usd_cents, proceeds_usd_cents,
provider, provider_ref
FROM crypto_payment_ledger
WHERE movement_type = 'offramp'
AND state IN ('pending','confirmed')
AND disposed_at >= make_timestamptz($1, 1, 1, 0, 0, 0, 'UTC')
AND disposed_at < make_timestamptz($1 + 1, 1, 1, 0, 0, 0, 'UTC')
ORDER BY disposed_at ASC, id ASC
`,
[year],
);
// Build the CSV
const header = [
"Description", // e.g., "0.00873 BTC — Order CO-SMOKE02"
"Date Acquired", // MM/DD/YYYY
"Date Sold",
"Proceeds (USD)",
"Cost Basis (USD)",
"Gain/(Loss) (USD)",
"Provider Reference",
];
const lines: string[] = [header.join(",")];
const fmt = (d: Date) =>
`${String(d.getUTCMonth() + 1).padStart(2, "0")}/${String(d.getUTCDate()).padStart(2, "0")}/${d.getUTCFullYear()}`;
for (const row of r.rows) {
const amount = Number(row.amount_coin);
const proceeds = Number(row.proceeds_usd_cents || 0) / 100;
const basis = Number(row.basis_usd_cents || 0) / 100;
const gain = proceeds - basis;
const acquired = row.acquired_at ? fmt(new Date(row.acquired_at)) : "";
const sold = row.disposed_at ? fmt(new Date(row.disposed_at)) : "";
const desc = `${Math.abs(amount).toFixed(8)} ${row.coin} — Order ${row.order_id}`;
lines.push([
`"${desc}"`,
`"${acquired}"`,
`"${sold}"`,
proceeds.toFixed(2),
basis.toFixed(2),
gain.toFixed(2),
`"${row.provider_ref || ""}"`,
].join(","));
}
res.setHeader("Content-Type", "text/csv");
res.setHeader(
"Content-Disposition",
`attachment; filename="crypto-disposals-${year}.csv"`,
);
res.send(lines.join("\n"));
},
);
// ── GET /api/v1/admin/crypto-payments/:order_id (must be LAST) ─────────
// Registered last so specific paths above match first.
router.get(
"/api/v1/admin/crypto-payments/:order_id",
async (req: Request, res: Response) => {
if (!requireAdminToken(req, res)) return;
const orderId = req.params.order_id;
const job = await pool.query(
"SELECT * FROM crypto_payment_jobs WHERE order_id = $1",
[orderId],
);
if (job.rows.length === 0) {
res.status(404).json({ error: "job not found" }); return;
}
const [ledger, obligations, deposit] = await Promise.all([
pool.query(
`SELECT * FROM crypto_payment_ledger WHERE order_id = $1 ORDER BY created_at ASC`,
[orderId],
),
pool.query(
`SELECT * FROM vendor_obligations WHERE order_id = $1 ORDER BY obligation_kind, id`,
[orderId],
),
job.rows[0].relay_deposit_id
? pool.query("SELECT * FROM relay_deposits WHERE id = $1", [job.rows[0].relay_deposit_id])
: Promise.resolve({ rows: [] }),
]);
res.json({
job: job.rows[0],
ledger: ledger.rows,
obligations: obligations.rows,
relay_deposit: deposit.rows[0] || null,
});
},
);
export default router;

Some files were not shown because too many files have changed in this diff Show more