Initial commit — Performance West telecom compliance platform

Includes: API (Express/TypeScript), Astro site, Python workers,
document generators, FCC compliance tools, Canada CRTC formation,
Ansible infrastructure, and deployment scripts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
justin 2026-04-27 06:54:22 -05:00
commit f8cd37ac8c
1823 changed files with 145167 additions and 0 deletions

View file

@ -0,0 +1,6 @@
---
// Production site navigation — extracted from the static homepage.
// This component is included in Base.astro to give all Astro-built
// pages the same nav as the static pages in public/.
---
<Fragment set:html={await Astro.glob('../partials/nav.html').then(() => '').catch(() => '')} />

View file

@ -0,0 +1,119 @@
---
// TaxDeductibilityNotice — in-page notice that our compliance filing
// services are deductible as ordinary and necessary business expenses
// under IRC § 162.
//
// Placement: near the price on every order page. Drop in via
// <TaxDeductibilityNotice />. No props — same copy everywhere.
//
// Authority (last verified 2026): the service fees we charge qualify as
// "legal and professional services" and "licenses and regulatory fees"
// expense categories for US business filers. Reports on:
// - Schedule C (sole proprietors): Line 17 "Legal and professional
// services" OR Line 27a "Other expenses" with the description
// "Regulatory compliance filings (FCC/USAC)"
// - Form 1120 (C corps): Line 26 "Other deductions" itemized as
// "Legal and professional services" or "Regulatory filing fees"
// - Form 1120-S (S corps): Line 19 "Other deductions" — same itemization
// - Form 1065 (partnerships): Line 20 "Other deductions"
// Not tax advice; customer should confirm with their CPA.
---
<aside class="pw-taxdeduct" role="note" aria-label="Tax deductibility">
<div class="pw-taxdeduct-head">
<strong>Tax deductible.</strong>
<span class="pw-taxdeduct-sub">
This fee is an ordinary and necessary business expense under IRC § 162.
</span>
</div>
<details class="pw-taxdeduct-details">
<summary>How to deduct it</summary>
<div class="pw-taxdeduct-body">
<p>
FCC/USAC regulatory filing service fees are deductible as a
business expense on your return for the year you pay them. Where
it goes depends on your entity type:
</p>
<ul>
<li>
<strong>Sole proprietor / single-member LLC (Schedule C):</strong>
Line 17 "Legal and professional services" OR Line 27a "Other
expenses" (describe as "Regulatory compliance filings — FCC/USAC").
</li>
<li>
<strong>C-corporation (Form 1120):</strong> Line 26 "Other
deductions" — itemize as "Legal and professional services" on
the attached statement.
</li>
<li>
<strong>S-corporation (Form 1120-S):</strong> Line 19 "Other
deductions" — same itemization.
</li>
<li>
<strong>Partnership (Form 1065):</strong> Line 20 "Other
deductions."
</li>
</ul>
<p class="pw-taxdeduct-note">
Keep your receipt / invoice from us with your tax records — the
IRS wants documentation showing the service, amount paid, and
why it's related to your trade or business. We email an itemized
receipt after payment; your portal also keeps a permanent copy.
</p>
<p class="pw-taxdeduct-disclaimer">
This is general information, not tax advice. Confirm with your
CPA or tax advisor — deductibility can depend on your specific
tax situation.
</p>
</div>
</details>
</aside>
<style>
.pw-taxdeduct {
margin: 0.75rem 0 1.25rem;
padding: 0.6rem 0.9rem;
background: #ecfdf5;
border-left: 3px solid #059669;
border-radius: 0 6px 6px 0;
font-size: 0.88rem;
color: #065f46;
max-width: 48rem;
}
.pw-taxdeduct-head strong { color: #064e3b; }
.pw-taxdeduct-sub { color: #065f46; margin-left: 0.35rem; }
.pw-taxdeduct-details { margin-top: 0.35rem; }
.pw-taxdeduct-details > summary {
cursor: pointer; font-weight: 600; color: #047857;
font-size: 0.82rem;
list-style: none;
padding: 0.1rem 0;
}
.pw-taxdeduct-details > summary::-webkit-details-marker { display: none; }
.pw-taxdeduct-details > summary::before {
content: "▸ "; color: #059669; margin-right: 0.1rem;
display: inline-block; transition: transform 0.15s;
}
.pw-taxdeduct-details[open] > summary::before { content: "▾ "; }
.pw-taxdeduct-body {
padding: 0.5rem 0 0.25rem;
font-size: 0.85rem;
line-height: 1.55;
}
.pw-taxdeduct-body p { margin: 0.4rem 0; }
.pw-taxdeduct-body ul { margin: 0.4rem 0; padding-left: 1.25rem; }
.pw-taxdeduct-body li { margin: 0.25rem 0; }
.pw-taxdeduct-note {
background: rgba(255, 255, 255, 0.55);
padding: 0.4rem 0.6rem; border-radius: 4px;
margin: 0.5rem 0;
font-size: 0.82rem;
}
.pw-taxdeduct-disclaimer {
font-size: 0.78rem; color: #047857;
font-style: italic;
border-top: 1px dashed #a7f3d0;
padding-top: 0.35rem;
margin-top: 0.5rem;
}
</style>

View file

@ -0,0 +1,157 @@
---
// DeMinimisChoiceExplainer — in-wizard help panel that explains when a
// filer should elect de minimis exemption vs. file as a regular
// contributor. Embedded in Block6CertStep (where the final election
// lives) as an <details> block the customer can expand.
---
<details class="pw-demin-explainer">
<summary>📖 De minimis vs. regular filing — how to choose</summary>
<div class="pw-demin-body">
<p class="pw-lead">
If your Appendix A worksheet shows your estimated annual USF
contribution is under $10,000, you qualify as <strong>de minimis</strong>
and are <em>exempt from contributing to USF</em> (47 CFR § 54.706).
But exemption isn't always the better outcome. Here's how to think
about it.
</p>
<h4>What changes if you file as de minimis:</h4>
<ul>
<li><strong>You pay $0 USF contribution</strong> on your end-user revenue.</li>
<li>You still file Form 499-A annually (it's your status declaration).</li>
<li>You're also <strong>exempt from TRS, NANPA, LNP, and ITSP fees</strong>
(Line 422/512 math excludes your contribution base).</li>
<li>You <strong>cannot issue a reseller certification</strong> to your
upstream wholesale carriers — you don't contribute directly.</li>
<li>Your wholesale SIP / trunk provider <strong>will charge you their USF
surcharge</strong> on the wholesale bill — and you have no way to
pass that cost through to customers (you don't collect USF).</li>
</ul>
<h4>What changes if you waive and file as a regular contributor:</h4>
<ul>
<li>You owe quarterly USF contributions on your interstate revenue
at the current USAC factor (typically 25-30% of your contribution
base).</li>
<li>You can <strong>show your wholesale vendor your 499 Filer ID +
reseller certification</strong> and they must stop charging you
their USF surcharge on wholesale trunking.</li>
<li>You can collect USF surcharges from your own customers and remit
them directly to USAC.</li>
<li>You file quarterly 499-Q forms (not just the annual 499-A).</li>
</ul>
<h4>The break-even math:</h4>
<p>
Let <code>W</code> = your annual wholesale SIP/trunk spend and
<code>s</code> = your vendor's USF surcharge rate (typically 25-30%
of the interstate portion of your wholesale bill). If you file
de minimis, your unavoidable cost is roughly <code>W × s ×
wholesale_interstate_%</code>.
</p>
<p>
Let <code>R</code> = your own interstate end-user revenue. If you
waive and file regular, your direct USF contribution is
<code>R × current_factor</code>.
</p>
<p>
<strong>File de minimis when:</strong> your direct contribution
(<code>R × factor</code>) would exceed your wholesale-side USF
surcharge hit (<code>W × s × wholesale_interstate_%</code>).
Typical case: carriers with many end-user customers + low wholesale
purchasing.
</p>
<p>
<strong>Waive and file regular when:</strong> your wholesale-side
USF surcharge hit would exceed your direct contribution. Typical
case: small VoIP resellers who buy a lot of wholesale SIP trunks
and have few direct end-user customers yet.
</p>
<h4>Concrete example — small VoIP reseller:</h4>
<ul class="pw-example">
<li>Annual wholesale SIP spend: <code>$60,000</code></li>
<li>Vendor USF surcharge rate: <code>27%</code>, interstate portion:
<code>64.9%</code> (safe harbor)</li>
<li>Wholesale USF hit if de minimis:
<code>60,000 × 0.27 × 0.649 = $10,513</code>/year</li>
<li>Own interstate revenue: <code>$40,000</code></li>
<li>Direct contribution if regular filer:
<code>40,000 × 0.27 = $10,800</code>/year</li>
<li class="pw-verdict">These are nearly equal — the decision is
roughly a wash financially. But the regular filer also gets the
administrative benefit of showing wholesale vendors the Filer ID
+ reseller cert, which can be worth $200-500/year in handling
effort saved.</li>
</ul>
<h4>Other factors:</h4>
<ul>
<li><strong>Business plan / growth:</strong> If you expect to exceed
the de minimis threshold next year anyway, the paperwork overhead
of switching from de minimis to regular mid-year is worse than
just filing regular now.</li>
<li><strong>Customer-facing billing:</strong> Regular filers can add
a line-item "Federal USF Recovery Fee" on customer invoices
(Line 403). De minimis filers cannot — the fee must be bundled
into the rate.</li>
<li><strong>Audit risk:</strong> Both are equally audit-defensible,
but misrepresenting de minimis status is a forfeiture trigger.
If in doubt, waive.</li>
<li><strong>Multi-entity affiliates:</strong> De minimis is tested
on consolidated revenue across affiliated filers — if any
affiliate exceeds the threshold, you cannot claim de minimis.
Appendix A Lines 3-4 add affiliate interstate/intl revenue to
your own.</li>
</ul>
<p class="pw-summary">
<strong>Rule of thumb:</strong> If you mostly sell to end users and
your upstream spend is modest, claim de minimis. If you mostly
resell wholesale SIP and your upstream USF exposure exceeds your
own contribution base, waive and file regular. When in doubt, run
the numbers above with your actual wholesale invoice + revenue
data.
</p>
</div>
</details>
<style>
.pw-demin-explainer {
border: 1px solid #e2e8f0; background: #f8fafc;
border-radius: 8px; padding: 0.6rem 0.9rem;
margin: 0.75rem 0;
font-size: 0.88rem;
}
.pw-demin-explainer > summary {
cursor: pointer; font-weight: 600; color: #1a2744;
list-style: none;
}
.pw-demin-explainer > summary::-webkit-details-marker { display: none; }
.pw-demin-body { padding: 0.5rem 0.25rem 0.25rem; color: #334155; line-height: 1.5; }
.pw-demin-body h4 { color: #1a2744; margin: 0.9rem 0 0.35rem; font-size: 0.92rem; }
.pw-demin-body p, .pw-demin-body ul { margin: 0.4rem 0; }
.pw-demin-body code {
background: #e2e8f0; padding: 0.05rem 0.3rem; border-radius: 3px;
font-size: 0.82rem;
}
.pw-demin-body .pw-lead {
font-size: 0.92rem; padding: 0.5rem 0.75rem; background: #fff;
border-left: 3px solid #2d4e78; border-radius: 0 4px 4px 0;
}
.pw-demin-body .pw-example {
background: #fff; padding: 0.5rem 0.75rem 0.5rem 1.75rem;
border-radius: 4px; border: 1px solid #e2e8f0;
}
.pw-demin-body .pw-verdict {
margin-top: 0.4rem; font-style: italic; color: #1a2744;
list-style: none; margin-left: -1rem;
}
.pw-demin-body .pw-summary {
margin-top: 0.8rem; padding: 0.6rem 0.9rem;
background: #ecfdf5; border-left: 3px solid #059669;
border-radius: 0 4px 4px 0; color: #065f46;
}
</style>

View file

@ -0,0 +1,359 @@
---
/**
* Intake Wizard shell.
*
* Renders a step progress bar + back/next buttons; the active step's
* content is rendered inline from the matching component under
* ./steps/<kebab>Step.astro via dynamic import.
*
* Props:
* service_slug: which catalog entry this wizard targets
* steps: ordered list from INTAKE_MANIFEST[slug]
* title: page title shown above the wizard
*
* State lives in window.sessionStorage under the key
* "pw-intake-<slug>" so a reload doesn't wipe progress. Step components
* read/write via the shared `PWIntake` browser helper declared below.
*/
import EntityStep from "./steps/EntityStep.astro";
import CategoryStep from "./steps/CategoryStep.astro";
import OfficerStep from "./steps/OfficerStep.astro";
import JurisdictionStep from "./steps/JurisdictionStep.astro";
import HistoryStep from "./steps/HistoryStep.astro";
import WirelessStep from "./steps/WirelessStep.astro";
import EarthStationStep from "./steps/EarthStationStep.astro";
import AudioBridgingStep from "./steps/AudioBridgingStep.astro";
import RevenueStep from "./steps/RevenueStep.astro";
import BundledServiceStep from "./steps/BundledServiceStep.astro";
import IccImportStep from "./steps/IccImportStep.astro";
import ResellerCertStep from "./steps/ResellerCertStep.astro";
import LNPARegionStep from "./steps/LNPARegionStep.astro";
import Block6CertStep from "./steps/Block6CertStep.astro";
import BDCDataStep from "./steps/BDCDataStep.astro";
import STIRShakenStep from "./steps/STIRShakenStep.astro";
import CALEAStep from "./steps/CALEAStep.astro";
import ForeignCarrierStep from "./steps/ForeignCarrierStep.astro";
import ForeignQualStep from "./steps/ForeignQualStep.astro";
import DCAgentStep from "./steps/DCAgentStep.astro";
import CPNIStep from "./steps/CPNIStep.astro";
import CDRPeriodStep from "./steps/CDRPeriodStep.astro";
import OCNStep from "./steps/OCNStep.astro";
import ClassificationWizard from "./steps/ClassificationWizard.astro";
import ReviewStep from "./steps/ReviewStep.astro";
import PaymentStep from "./steps/PaymentStep.astro";
export interface Props {
service_slug: string;
steps: string[];
title: string;
}
const { service_slug, steps, title } = Astro.props;
const STEP_LABELS: Record<string, string> = {
entity: "Carrier",
category: "Line 105",
classification: "Carrier Type",
officer: "Officers",
jurisdiction: "Jurisdictions",
history: "Service Start",
wireless: "Wireless",
earth_station: "Satellite / Pvt Line",
audio_bridging: "Audio Bridging",
revenue: "Revenue",
bundled_service: "Bundles",
icc_import: "ICC Import",
reseller_cert: "Resellers",
lnpa_region: "LNPA Regions",
block6_cert: "Certifications",
bdc_data: "BDC Data",
stir_shaken: "STIR/SHAKEN",
calea: "CALEA",
foreign_carrier: "Foreign Affiliation",
foreign_qual: "State Registration",
dc_agent: "D.C. Agent",
cpni_questions: "CPNI Details",
cdr_period: "Reporting Period",
ocn: "OCN Details",
review: "Review",
payment: "Payment",
};
---
<div class="pw-wizard" data-service={service_slug} data-steps={JSON.stringify(steps)}>
<header class="pw-wizard-header">
<h1>{title}</h1>
<ol class="pw-wizard-stepbar">
{steps.map((step, i) => (
<li class="pw-step-chip" data-step={step} data-idx={i}>
<span class="pw-step-num">{i + 1}</span>
<span class="pw-step-label">{STEP_LABELS[step] ?? step}</span>
</li>
))}
</ol>
</header>
<section class="pw-wizard-body">
{steps.includes("entity") && <div data-step="entity" hidden><EntityStep /></div>}
{steps.includes("category") && <div data-step="category" hidden><CategoryStep /></div>}
{steps.includes("officer") && <div data-step="officer" hidden><OfficerStep /></div>}
{steps.includes("jurisdiction") && <div data-step="jurisdiction" hidden><JurisdictionStep /></div>}
{steps.includes("history") && <div data-step="history" hidden><HistoryStep /></div>}
<div data-step="wireless" hidden><WirelessStep /></div>
<div data-step="earth_station" hidden><EarthStationStep /></div>
<div data-step="audio_bridging" hidden><AudioBridgingStep /></div>
{steps.includes("revenue") && <div data-step="revenue" hidden><RevenueStep /></div>}
{steps.includes("bundled_service") && <div data-step="bundled_service" hidden><BundledServiceStep /></div>}
{steps.includes("icc_import") && <div data-step="icc_import" hidden><IccImportStep /></div>}
{steps.includes("reseller_cert") && <div data-step="reseller_cert" hidden><ResellerCertStep /></div>}
{steps.includes("lnpa_region") && <div data-step="lnpa_region" hidden><LNPARegionStep /></div>}
{steps.includes("block6_cert") && <div data-step="block6_cert" hidden><Block6CertStep /></div>}
{steps.includes("bdc_data") && <div data-step="bdc_data" hidden><BDCDataStep service_slug={service_slug} /></div>}
{steps.includes("stir_shaken") && <div data-step="stir_shaken" hidden><STIRShakenStep /></div>}
{steps.includes("calea") && <div data-step="calea" hidden><CALEAStep /></div>}
{steps.includes("foreign_carrier") && <div data-step="foreign_carrier" hidden><ForeignCarrierStep /></div>}
{steps.includes("foreign_qual") && <div data-step="foreign_qual" hidden><ForeignQualStep /></div>}
{steps.includes("dc_agent") && <div data-step="dc_agent" hidden><DCAgentStep /></div>}
{steps.includes("cpni_questions") && <div data-step="cpni_questions" hidden><CPNIStep /></div>}
{steps.includes("cdr_period") && <div data-step="cdr_period" hidden><CDRPeriodStep /></div>}
{steps.includes("ocn") && <div data-step="ocn" hidden><OCNStep /></div>}
{steps.includes("classification") && <div data-step="classification" hidden><ClassificationWizard /></div>}
{steps.includes("review") && <div data-step="review" hidden><ReviewStep service_slug={service_slug} /></div>}
{steps.includes("payment") && <div data-step="payment" hidden><PaymentStep service_slug={service_slug} /></div>}
</section>
<footer class="pw-wizard-nav">
<button type="button" class="pw-btn pw-btn-plain" id="pw-back">← Back</button>
<button type="button" class="pw-btn" id="pw-next">Next →</button>
</footer>
</div>
<style>
.pw-wizard { max-width: 820px; margin: 0 auto; padding: 1rem; }
.pw-wizard-header h1 { margin: 0 0 1rem; color: var(--pw-navy, #1a2744); }
.pw-wizard-stepbar {
display: flex; align-items: center; flex-wrap: wrap;
padding: 0; margin: 0 0 1.5rem; list-style: none;
gap: 0; background: #f8fafc; border-radius: 8px;
border: 1px solid #e2e8f0; overflow: hidden;
}
.pw-step-chip {
display: flex; align-items: center; gap: 0.35rem;
padding: 0.5rem 0.9rem;
color: #94a3b8;
font-size: 0.8rem;
transition: all 0.15s;
position: relative;
white-space: nowrap;
}
.pw-step-chip:not(:last-child)::after {
content: "";
position: absolute; right: -0.5rem; top: 50%;
transform: translateY(-50%) rotate(45deg);
width: 0.7rem; height: 0.7rem;
border-top: 1px solid #cbd5e1;
border-right: 1px solid #cbd5e1;
background: #f8fafc;
z-index: 1;
}
.pw-step-chip[data-active="true"] { background: #1a2744; color: #fff; }
.pw-step-chip[data-active="true"]::after { background: #1a2744; border-color: #1a2744; }
.pw-step-chip[data-done="true"] { color: #065f46; }
.pw-step-chip[data-done="true"]::after { border-color: #a7f3d0; }
.pw-step-num {
display: inline-flex; align-items: center; justify-content: center;
width: 1.2rem; height: 1.2rem; border-radius: 50%;
background: #e2e8f0; color: #64748b;
font-size: 0.7rem; font-weight: 700;
}
.pw-step-chip[data-active="true"] .pw-step-num { background: rgba(255,255,255,0.25); color: #fff; }
.pw-step-chip[data-done="true"] .pw-step-num { background: #d1fae5; color: #065f46; }
.pw-wizard-body {
background: #fff; border: 1px solid #e2e8f0; border-radius: 10px;
padding: 1.5rem; min-height: 280px;
}
.pw-wizard-nav {
display: flex; justify-content: space-between;
margin-top: 1.25rem;
}
.pw-btn {
padding: 0.65rem 1.4rem; border: 0; border-radius: 6px;
background: #059669; color: #fff; font-weight: 600;
cursor: pointer; font-size: 0.95rem;
}
.pw-btn-plain { background: #e2e8f0; color: #1f2937; }
.pw-btn:disabled { opacity: 0.5; cursor: not-allowed; }
</style>
<script>
// ── Client state machine + sessionStorage persistence ──────────────
type IntakeState = {
service_slug: string;
step_index: number;
entity: Record<string, any>;
officers: Record<string, any>[];
intake_data: Record<string, any>;
email: string;
name: string;
telecom_entity_id: number | null;
};
const wizard = document.querySelector(".pw-wizard")!;
const slug = wizard.getAttribute("data-service")!;
const initialSteps: string[] = JSON.parse(wizard.getAttribute("data-steps")!);
let steps: string[] = [...initialSteps];
const storageKey = `pw-intake-${slug}`;
// Step-label map used by the dynamic step bar rebuilder when
// line_105_categories changes. Mirror of the Astro-side STEP_LABELS.
(window as any).PW_STEP_LABELS = {
entity: "Carrier", category: "Line 105", officer: "Officers",
jurisdiction: "Jurisdictions", history: "Service Start",
wireless: "Wireless", earth_station: "Satellite / Pvt Line",
audio_bridging: "Audio Bridging", revenue: "Revenue",
bundled_service: "Bundles", icc_import: "ICC Import",
reseller_cert: "Resellers", lnpa_region: "LNPA Regions",
block6_cert: "Certifications", bdc_data: "BDC Data",
stir_shaken: "STIR/SHAKEN", calea: "CALEA",
foreign_carrier: "Foreign Affiliation", foreign_qual: "State Registration", dc_agent: "D.C. Agent", cpni_questions: "CPNI Details", cdr_period: "Reporting Period",
ocn: "OCN Details", review: "Review", payment: "Payment",
};
// Category-gated dynamic step insertion. After the user picks Line 105
// categories on the CategoryStep, this re-shapes steps[] to insert
// wireless / earth_station / audio_bridging between category and
// revenue (or between entity and revenue if no category step).
const CATEGORY_GATED: Record<string, string[]> = {
wireless: ["wireless"],
satellite: ["earth_station"],
mobile_satellite: ["earth_station"],
private_line: ["earth_station"],
audio_bridging: ["audio_bridging"],
};
function reshapeSteps(categories: Array<{id: string}>): string[] {
const base = [...initialSteps];
const gated = new Set<string>();
for (const cat of categories) {
for (const step of (CATEGORY_GATED[cat.id] || [])) {
gated.add(step);
}
}
if (gated.size === 0) return base;
// Insert gated steps after `history` if present, else after `category`,
// else before `revenue`.
const anchorIdx = Math.max(
base.indexOf("history"),
base.indexOf("category"),
);
const insertAt = anchorIdx >= 0 ? anchorIdx + 1 : base.indexOf("revenue");
if (insertAt < 0) return base;
return [
...base.slice(0, insertAt),
...Array.from(gated).filter((s) => !base.includes(s)),
...base.slice(insertAt),
];
}
function rebuildStepBar() {
const bar = wizard.querySelector(".pw-wizard-stepbar")!;
bar.innerHTML = "";
for (let i = 0; i < steps.length; i++) {
const li = document.createElement("li");
li.className = "pw-step-chip";
li.setAttribute("data-step", steps[i]);
li.setAttribute("data-idx", String(i));
const label = (window as any).PW_STEP_LABELS?.[steps[i]] ?? steps[i];
li.innerHTML = `<span class="pw-step-num">${i + 1}</span><span class="pw-step-label">${label}</span>`;
bar.appendChild(li);
}
}
function loadState(): IntakeState {
try {
const raw = sessionStorage.getItem(storageKey);
if (raw) return JSON.parse(raw);
} catch {}
return {
service_slug: slug,
step_index: 0,
entity: {},
officers: [{}, {}, {}],
intake_data: {},
email: "",
name: "",
telecom_entity_id: null,
};
}
function saveState(s: IntakeState) {
sessionStorage.setItem(storageKey, JSON.stringify(s));
}
// Expose helper on window for step components to read/write.
(window as any).PWIntake = {
get: (): IntakeState => loadState(),
set: (patch: Partial<IntakeState>) => {
const s = { ...loadState(), ...patch };
saveState(s);
return s;
},
patchIntakeData: (patch: Record<string, any>) => {
const s = loadState();
s.intake_data = { ...s.intake_data, ...patch };
saveState(s);
// If the patch touched line_105_categories, reshape the step list
if ("line_105_categories" in patch) {
const newSteps = reshapeSteps(patch.line_105_categories || []);
if (JSON.stringify(newSteps) !== JSON.stringify(steps)) {
steps = newSteps;
(window as any).PWIntake.steps = steps;
rebuildStepBar();
}
}
return s;
},
slug,
get steps() { return steps; },
set steps(s: string[]) { steps = s; },
};
function renderStep(idx: number) {
const state = loadState();
state.step_index = idx;
saveState(state);
wizard.querySelectorAll<HTMLElement>(".pw-wizard-body > [data-step]").forEach((el) => {
el.hidden = el.getAttribute("data-step") !== steps[idx];
});
wizard.querySelectorAll<HTMLElement>(".pw-step-chip").forEach((chip, i) => {
chip.setAttribute("data-active", String(i === idx));
chip.setAttribute("data-done", String(i < idx));
});
(document.getElementById("pw-back") as HTMLButtonElement).disabled = idx === 0;
const nextBtn = document.getElementById("pw-next") as HTMLButtonElement;
nextBtn.textContent = idx === steps.length - 1 ? "Finish" : "Next →";
// Let the active step hydrate itself
window.dispatchEvent(new CustomEvent("pw:step-shown", {
detail: { step: steps[idx], idx },
}));
}
document.getElementById("pw-back")!.addEventListener("click", () => {
const s = loadState();
if (s.step_index > 0) renderStep(s.step_index - 1);
});
document.getElementById("pw-next")!.addEventListener("click", () => {
const s = loadState();
// Validate the current step — it may cancel by setting a flag.
const cancelEvt = new CustomEvent<{ cancel: boolean; reason?: string }>(
"pw:step-next", { detail: { cancel: false }, cancelable: true },
);
window.dispatchEvent(cancelEvt);
// Step components call evt.preventDefault() to block.
if (cancelEvt.defaultPrevented) return;
if (s.step_index < steps.length - 1) renderStep(s.step_index + 1);
});
// Kick off
renderStep(loadState().step_index || 0);
</script>

View file

@ -0,0 +1,75 @@
---
// AudioBridgingStep — subscription vs per-minute revenue split
// Inserted into the wizard when line_105_categories contains 'audio_bridging'.
---
<div class="pw-step">
<h2>Audio bridging / conferencing</h2>
<p class="pw-help">
Since 2021 the FCC treats interstate audio conferencing as telecom
service. Split your conferencing revenue into subscription vs. per-minute
for proper Line 303/418 attribution.
</p>
<label class="pw-field">Platform</label>
<select id="pw-ab-platform" class="pw-input">
<option value="proprietary">Proprietary bridge</option>
<option value="cloud">Cloud platform (Zoom-style)</option>
<option value="toll_free">Toll-free access number</option>
</select>
<div class="pw-row">
<div><label class="pw-field">Subscription revenue (USD/year)</label>
<input type="number" step="0.01" id="pw-ab-sub-rev" class="pw-input" min="0" /></div>
<div><label class="pw-field">Per-minute / usage revenue (USD/year)</label>
<input type="number" step="0.01" id="pw-ab-pm-rev" class="pw-input" min="0" /></div>
</div>
<label class="pw-field">Total participant minutes (year)</label>
<input type="number" id="pw-ab-minutes" class="pw-input" min="0" />
<label>
<input type="checkbox" id="pw-ab-tf-accessible" />
Bridge is accessible via toll-free number
</label>
</div>
<style>
.pw-step h2 { margin: 0 0 0.5rem; color: #1a2744; }
.pw-help { color: #64748b; font-size: 0.9rem; margin-bottom: 1rem; }
.pw-field { display: block; font-weight: 600; color: #1f2937; margin: 0.6rem 0 0.2rem; font-size: 0.88rem; }
.pw-input { width: 100%; padding: 0.5rem 0.7rem; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 0.93rem; font-family: inherit; }
.pw-row { display: flex; gap: 1rem; flex-wrap: wrap; }
.pw-row > * { flex: 1 1 160px; }
</style>
<script>
window.addEventListener("pw:step-shown", (evt: any) => {
if (evt.detail.step !== "audio_bridging") return;
const s = (window as any).PWIntake.get();
const m = s.intake_data?.audio_bridging_meta || {};
(document.getElementById("pw-ab-platform") as HTMLSelectElement).value = m.platform || "proprietary";
(document.getElementById("pw-ab-sub-rev") as HTMLInputElement).value = m.subscription_revenue_usd ?? "";
(document.getElementById("pw-ab-pm-rev") as HTMLInputElement).value = m.per_minute_revenue_usd ?? "";
(document.getElementById("pw-ab-minutes") as HTMLInputElement).value = m.total_participant_minutes ?? "";
(document.getElementById("pw-ab-tf-accessible") as HTMLInputElement).checked = !!m.toll_free_accessible;
});
window.addEventListener("pw:step-next", (evt: any) => {
const PW = (window as any).PWIntake;
if (PW.steps[PW.get().step_index] !== "audio_bridging") return;
const subRev = Number((document.getElementById("pw-ab-sub-rev") as HTMLInputElement).value) || 0;
const pmRev = Number((document.getElementById("pw-ab-pm-rev") as HTMLInputElement).value) || 0;
PW.patchIntakeData({
audio_bridging_meta: {
platform: (document.getElementById("pw-ab-platform") as HTMLSelectElement).value,
subscription_revenue_usd: subRev,
per_minute_revenue_usd: pmRev,
subscription_revenue_cents: Math.round(subRev * 100),
per_minute_revenue_cents: Math.round(pmRev * 100),
total_participant_minutes:
Number((document.getElementById("pw-ab-minutes") as HTMLInputElement).value) || 0,
toll_free_accessible: (document.getElementById("pw-ab-tf-accessible") as HTMLInputElement).checked,
},
});
});
</script>

View file

@ -0,0 +1,89 @@
---
// BDCDataStep — availability rows (broadband) + voice subscriber count.
// Renders only the block(s) relevant to the selected BDC service slug.
export interface Props { service_slug: string; }
const { service_slug } = Astro.props;
const mode = service_slug === "bdc-voice" ? "voice" : service_slug === "bdc-broadband" ? "broadband" : "both";
---
<div class="pw-step" data-mode={mode}>
<h2>BDC filing data</h2>
{mode !== "voice" && (
<section>
<h3>Broadband deployment (availability)</h3>
<p class="pw-help">
Upload your availability CSV (one row per serviceable location +
technology). If you don't have the data handy, email it to us
after checkout and we'll process it.
</p>
<input type="file" id="pw-bdc-file" class="pw-input" accept=".csv,.csv.gz" />
<div class="pw-help" id="pw-bdc-file-status"></div>
</section>
)}
{mode !== "broadband" && (
<section style="margin-top:1.25rem;">
<h3>Voice subscription (formerly Form 477)</h3>
<p class="pw-help">
Your total count of voice subscribers at the snapshot date. Use
the count of billable retail + wholesale voice lines in service.
</p>
<label class="pw-field">Voice subscribers (count)</label>
<input type="number" id="pw-voice-subs" class="pw-input" min="0" />
<label class="pw-field">Snapshot date</label>
<input type="date" id="pw-snapshot" class="pw-input" />
</section>
)}
</div>
<style>
.pw-step h2 { margin: 0 0 0.5rem; color: #1a2744; }
.pw-step h3 { margin: 0.8rem 0 0.3rem; color: #1a2744; font-size: 1.05rem; }
.pw-help { color: #64748b; font-size: 0.9rem; margin-bottom: 0.75rem; }
.pw-field { display: block; font-weight: 600; margin: 0.6rem 0 0.2rem; font-size: 0.88rem; }
.pw-input { width: 100%; padding: 0.5rem 0.7rem; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 0.93rem; }
</style>
<script>
const mode = (document.querySelector(".pw-step[data-mode]") as HTMLElement).getAttribute("data-mode")!;
const voice = document.getElementById("pw-voice-subs") as HTMLInputElement | null;
const snap = document.getElementById("pw-snapshot") as HTMLInputElement | null;
const file = document.getElementById("pw-bdc-file") as HTMLInputElement | null;
const status = document.getElementById("pw-bdc-file-status");
file?.addEventListener("change", () => {
const f = file.files?.[0];
if (f && status) status.textContent = `Selected: ${f.name} (${(f.size/1024).toFixed(0)} KB) — uploads after checkout.`;
});
window.addEventListener("pw:step-shown", (evt: any) => {
if (evt.detail.step !== "bdc_data") return;
const s = (window as any).PWIntake.get();
if (voice) voice.value = s.intake_data?.voice_subscribers ?? "";
if (snap) snap.value = s.intake_data?.snapshot_date ?? "";
});
window.addEventListener("pw:step-next", (evt: any) => {
const PW = (window as any).PWIntake;
if (PW.steps[PW.get().step_index] !== "bdc_data") return;
// Explicitly null out fields that don't apply to the current mode —
// e.g. a customer who previously hit bdc-filing (both) then switched
// to bdc-voice would otherwise carry stale availability_filename.
const patch: Record<string, any> = { bdc_mode: mode };
if (mode === "voice") {
patch.voice_subscribers = voice ? Number(voice.value || 0) : undefined;
patch.snapshot_date = snap?.value || undefined;
patch.availability_filename = null;
} else if (mode === "broadband") {
patch.availability_filename = file?.files?.[0]?.name || undefined;
patch.voice_subscribers = null;
patch.snapshot_date = null;
} else {
patch.voice_subscribers = voice ? Number(voice.value || 0) : undefined;
patch.snapshot_date = snap?.value || undefined;
patch.availability_filename = file?.files?.[0]?.name || undefined;
}
PW.patchIntakeData(patch);
});
</script>

View file

@ -0,0 +1,242 @@
---
// Block6CertStep — Lines 603-605, 612 + de minimis election.
// Exemption certifications (USF/TRS/NANPA/LNP/ITSP), 501(c)/government,
// nondisclosure request, filing type, and the de minimis vs. regular
// election (with explainer).
import { NONDISCLOSURE_CERT_TEXT, FILING_TYPE_LABELS } from "../../../lib/fcc_constants";
import DeMinimisChoiceExplainer from "../DeMinimisChoiceExplainer.astro";
---
<div class="pw-step">
<h2>Certifications (Block 6)</h2>
<section class="pw-block" id="pw-demin-section">
<h3>De minimis filing election</h3>
<p class="pw-help">
Based on your revenue + safe-harbor interstate % and the current
year's de minimis factor, your estimated annual USF contribution is
<strong id="pw-demin-estimate">—</strong>. The exemption threshold
is $10,000 (Appendix A).
</p>
<div id="pw-demin-status-banner" class="pw-demin-banner" hidden></div>
<DeMinimisChoiceExplainer />
<label class="pw-field">How do you want to file?</label>
<select id="pw-demin-election" class="pw-input">
<option value="auto">Let the Appendix A calculation decide (standard)</option>
<option value="deminimis">File as de minimis — no USF contribution</option>
<option value="regular">File as regular contributor — pay USF directly (waive exemption)</option>
</select>
<div id="pw-waive-reason-wrap" hidden>
<label class="pw-field">Reason for filing regular (optional — shown to reviewers)</label>
<textarea id="pw-waive-reason" class="pw-input" rows="2"
placeholder="e.g., 'Wholesale SIP vendor charges us USF surcharge on trunking — filing regular so we can show Filer ID + reseller cert.'"></textarea>
</div>
</section>
<section class="pw-block">
<h3>Line 603 — Claim exemption from contribution mechanisms</h3>
<p class="pw-help">
Check any mechanism you're exempt from. Exemptions require a written
explanation and evidence your legal team can produce on audit.
</p>
<div class="pw-cert-grid">
<label><input type="checkbox" id="pw-ex-usf" /> USF (Universal Service Fund)</label>
<label><input type="checkbox" id="pw-ex-trs" /> TRS (Telecom Relay Service)</label>
<label><input type="checkbox" id="pw-ex-nanpa" /> NANPA (numbering)</label>
<label><input type="checkbox" id="pw-ex-lnp" /> LNP (number portability)</label>
<label><input type="checkbox" id="pw-ex-itsp" /> ITSP (regulatory fees)</label>
</div>
<div id="pw-ex-expl-wrap" hidden>
<label class="pw-field">Explanation (required for any exemption)</label>
<textarea id="pw-ex-expl" class="pw-input" rows="3"></textarea>
</div>
</section>
<section class="pw-block">
<h3>Line 604 — Organization type</h3>
<label><input type="checkbox" id="pw-gov" /> State or local government entity</label><br>
<label><input type="checkbox" id="pw-501c" /> 501(c) tax-exempt organization</label>
</section>
<section class="pw-block">
<h3>Line 605 — Confidential treatment of revenue data</h3>
<label>
<input type="checkbox" id="pw-nondisclosure" />
Request nondisclosure of revenue information
</label>
<p id="pw-nondisclosure-text" class="pw-cert-text" hidden></p>
</section>
<section class="pw-block">
<h3>Line 612 — Type of filing</h3>
<select id="pw-filing-type" class="pw-input">
<option value="original_april_1">Original Filing (April 1)</option>
<option value="registration_new_filer">Registration — New Filer</option>
<option value="revised_registration">Revised Filing (Registration Info)</option>
<option value="revised_revenue">Revised Filing (Revenue Info)</option>
</select>
</section>
<div id="pw-b6-err" class="pw-err" hidden></div>
</div>
<style>
.pw-step h2 { margin: 0 0 0.5rem; color: #1a2744; }
.pw-step h3 { margin: 0 0 0.4rem; color: #1a2744; font-size: 1rem; }
.pw-block { padding: 1rem; border: 1px solid #e2e8f0; border-radius: 8px; margin-bottom: 0.75rem; }
.pw-help { color: #64748b; font-size: 0.85rem; margin-bottom: 0.75rem; }
.pw-field { display: block; font-weight: 600; color: #1f2937; margin: 0.6rem 0 0.2rem; font-size: 0.88rem; }
.pw-input { width: 100%; padding: 0.5rem 0.7rem; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 0.93rem; font-family: inherit; }
.pw-demin-banner {
padding: 0.5rem 0.75rem; border-radius: 6px;
margin-top: 0.5rem; font-size: 0.88rem;
}
.pw-demin-banner.pw-exempt { background: #d1fae5; color: #065f46; border-left: 3px solid #059669; }
.pw-demin-banner.pw-no-exempt{ background: #fee2e2; color: #991b1b; border-left: 3px solid #dc2626; }
.pw-demin-banner.pw-near { background: #fef3c7; color: #92400e; border-left: 3px solid #d97706; }
.pw-cert-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 0.4rem; margin-bottom: 0.5rem; }
.pw-cert-grid label { font-size: 0.9rem; }
.pw-cert-text {
font-size: 0.82rem; color: #475569;
background: #f8fafc; padding: 0.5rem 0.75rem; border-radius: 6px;
margin-top: 0.5rem;
}
.pw-err { color: #b91c1c; margin-top: 0.75rem; font-size: 0.9rem; }
</style>
<script>
import { NONDISCLOSURE_CERT_TEXT } from "../../../lib/fcc_constants";
const exUsf = document.getElementById("pw-ex-usf") as HTMLInputElement;
const exTrs = document.getElementById("pw-ex-trs") as HTMLInputElement;
const exNanpa = document.getElementById("pw-ex-nanpa") as HTMLInputElement;
const exLnp = document.getElementById("pw-ex-lnp") as HTMLInputElement;
const exItsp = document.getElementById("pw-ex-itsp") as HTMLInputElement;
const exExplWrap = document.getElementById("pw-ex-expl-wrap") as HTMLElement;
const exExpl = document.getElementById("pw-ex-expl") as HTMLTextAreaElement;
const gov = document.getElementById("pw-gov") as HTMLInputElement;
const nfp = document.getElementById("pw-501c") as HTMLInputElement;
const nd = document.getElementById("pw-nondisclosure") as HTMLInputElement;
const ndText = document.getElementById("pw-nondisclosure-text") as HTMLElement;
const filingType = document.getElementById("pw-filing-type") as HTMLSelectElement;
const err = document.getElementById("pw-b6-err") as HTMLDivElement;
const deminElection = document.getElementById("pw-demin-election") as HTMLSelectElement;
const deminEstimate = document.getElementById("pw-demin-estimate") as HTMLElement;
const deminBanner = document.getElementById("pw-demin-status-banner") as HTMLElement;
const waiveReason = document.getElementById("pw-waive-reason") as HTMLTextAreaElement;
const waiveReasonWrap = document.getElementById("pw-waive-reason-wrap") as HTMLElement;
ndText.textContent = NONDISCLOSURE_CERT_TEXT;
async function refreshDeMinimisEstimate() {
const s = (window as any).PWIntake.get();
const year = Number(s.intake_data?.form_year) || new Date().getUTCFullYear() - 1;
const totalRev = Number(s.intake_data?.total_revenue_cents) || 0;
const interPct = Number(s.intake_data?.interstate_pct) || 0;
const intlPct = Number(s.intake_data?.international_pct) || 0;
if (!totalRev) {
deminEstimate.textContent = "— (enter revenue on the Revenue step first)";
deminBanner.hidden = true;
return;
}
// Quick estimate: contribution base × year factor. We fetch the
// factor via the late-filing-estimate endpoint (convenient wrapper).
try {
const r = await fetch(
`/api/v1/fcc/late-filing-estimate?year=${year}&total_revenue_cents=${totalRev}&interstate_pct=${interPct + intlPct}`,
);
if (!r.ok) throw new Error(`${r.status}`);
const data = await r.json();
const est = data.estimated_usf_cents;
const isExempt = est < 1000000; // $10,000 threshold
const nearThreshold = est < 1500000 && est >= 750000;
deminEstimate.textContent = `$${(est / 100).toLocaleString("en-US", { minimumFractionDigits: 2 })}`;
deminBanner.hidden = false;
if (isExempt) {
deminBanner.className = "pw-demin-banner pw-exempt";
deminBanner.textContent = `✓ You qualify as de minimis. Your estimated $${(est/100).toFixed(2)} annual contribution is below the $10,000 threshold.`;
} else if (nearThreshold) {
deminBanner.className = "pw-demin-banner pw-near";
deminBanner.textContent = `⚠ You're near the de minimis threshold. Small changes in interstate % could push you over — we recommend reviewing your traffic study carefully.`;
} else {
deminBanner.className = "pw-demin-banner pw-no-exempt";
deminBanner.textContent = `You do NOT qualify as de minimis. Estimated $${(est/100).toFixed(2)} exceeds the $10,000 threshold.`;
}
} catch {
deminEstimate.textContent = "— (could not reach server)";
}
}
deminElection.addEventListener("change", () => {
waiveReasonWrap.hidden = deminElection.value !== "regular";
});
function anyExempt() {
return exUsf.checked || exTrs.checked || exNanpa.checked || exLnp.checked || exItsp.checked;
}
function updateExplVisibility() {
exExplWrap.hidden = !anyExempt();
}
[exUsf, exTrs, exNanpa, exLnp, exItsp].forEach((cb) => cb.addEventListener("change", updateExplVisibility));
nd.addEventListener("change", () => { ndText.hidden = !nd.checked; });
window.addEventListener("pw:step-shown", (evt: any) => {
if (evt.detail.step !== "block6_cert") return;
const s = (window as any).PWIntake.get();
const d = s.intake_data || {};
exUsf.checked = !!d.exempt_usf;
exTrs.checked = !!d.exempt_trs;
exNanpa.checked = !!d.exempt_nanpa;
exLnp.checked = !!d.exempt_lnp;
exItsp.checked = !!d.exempt_itsp;
exExpl.value = d.exemption_explanation || "";
gov.checked = !!d.is_state_local_gov;
nfp.checked = !!d.is_tax_exempt_501c;
nd.checked = !!d.nondisclosure_requested;
filingType.value = d.filing_type || "original_april_1";
deminElection.value = d.deminimis_election || "auto";
waiveReasonWrap.hidden = deminElection.value !== "regular";
waiveReason.value = d.waive_deminimis_reason || "";
updateExplVisibility();
ndText.hidden = !nd.checked;
refreshDeMinimisEstimate();
});
window.addEventListener("pw:step-next", (evt: any) => {
const PW = (window as any).PWIntake;
if (PW.steps[PW.get().step_index] !== "block6_cert") return;
if (anyExempt() && !exExpl.value.trim()) {
err.hidden = false; err.textContent = "Exemption claims require a written explanation."; evt.preventDefault(); return;
}
err.hidden = true;
const election = deminElection.value;
PW.patchIntakeData({
exempt_usf: exUsf.checked,
exempt_trs: exTrs.checked,
exempt_nanpa: exNanpa.checked,
exempt_lnp: exLnp.checked,
exempt_itsp: exItsp.checked,
exemption_explanation: exExpl.value.trim() || null,
is_state_local_gov: gov.checked,
is_tax_exempt_501c: nfp.checked,
nondisclosure_requested: nd.checked,
filing_type: filingType.value,
deminimis_election: election,
// If the filer explicitly elected "regular", mark is_deminimis false
// and set the waive flag. If "deminimis", mark is_deminimis true.
// "auto" leaves is_deminimis to be set by the server-side calc.
is_deminimis: election === "regular" ? false : (election === "deminimis" ? true : undefined),
waive_deminimis_reason: election === "regular" ? (waiveReason.value.trim() || null) : null,
});
// Expose waive flag on the top-level state so order creation reads it.
const st = PW.get();
PW.set({
...st,
waive_deminimis_exemption: election === "regular",
waive_deminimis_reason: election === "regular" ? (waiveReason.value.trim() || null) : null,
});
});
</script>

View file

@ -0,0 +1,98 @@
---
// BundledServiceStep — local+toll bundle revenue allocation methodology.
// Only asked if the filer offers bundled local + toll service at a
// single price.
---
<div class="pw-step">
<h2>Bundled service allocation</h2>
<p class="pw-help">
If you offer bundled local + toll service at a single price, the FCC
requires you to allocate the bundle revenue between local (Line 404)
and toll (Line 414) based on your supporting books and records.
</p>
<label>
<input type="checkbox" id="pw-bs-has-bundle" />
We offer bundled local + toll service at a single price
</label>
<div id="pw-bs-details" hidden>
<label class="pw-field">Annual bundle revenue (USD)</label>
<input type="number" step="0.01" id="pw-bs-bundle-rev" class="pw-input" min="0" />
<div class="pw-row">
<div><label class="pw-field">% allocated to local (Line 404)</label>
<input type="number" step="0.1" min="0" max="100" id="pw-bs-local-pct" class="pw-input" /></div>
<div><label class="pw-field">% allocated to toll (Line 414)</label>
<input type="number" step="0.1" min="0" max="100" id="pw-bs-toll-pct" class="pw-input" /></div>
</div>
<label class="pw-field">Allocation methodology (required for audit defense)</label>
<textarea id="pw-bs-method" class="pw-input" rows="3"
placeholder="e.g., 'Based on our billing records, the toll component is priced at X/minute; with 700 average monthly minutes at a $50 bundle price, toll is ~$14 or 28% of the bundle.'"></textarea>
</div>
<div id="pw-bs-err" class="pw-err" hidden></div>
</div>
<style>
.pw-step h2 { margin: 0 0 0.5rem; color: #1a2744; }
.pw-help { color: #64748b; font-size: 0.9rem; margin-bottom: 1rem; }
.pw-field { display: block; font-weight: 600; color: #1f2937; margin: 0.6rem 0 0.2rem; font-size: 0.88rem; }
.pw-input { width: 100%; padding: 0.5rem 0.7rem; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 0.93rem; font-family: inherit; }
.pw-row { display: flex; gap: 1rem; flex-wrap: wrap; }
.pw-row > * { flex: 1 1 140px; }
.pw-err { color: #b91c1c; margin-top: 0.75rem; font-size: 0.9rem; }
</style>
<script>
const hasBundle = document.getElementById("pw-bs-has-bundle") as HTMLInputElement;
const details = document.getElementById("pw-bs-details") as HTMLElement;
const bundleRev = document.getElementById("pw-bs-bundle-rev") as HTMLInputElement;
const localPct = document.getElementById("pw-bs-local-pct") as HTMLInputElement;
const tollPct = document.getElementById("pw-bs-toll-pct") as HTMLInputElement;
const method = document.getElementById("pw-bs-method") as HTMLTextAreaElement;
const err = document.getElementById("pw-bs-err") as HTMLDivElement;
hasBundle.addEventListener("change", () => { details.hidden = !hasBundle.checked; });
window.addEventListener("pw:step-shown", (evt: any) => {
if (evt.detail.step !== "bundled_service") return;
const s = (window as any).PWIntake.get();
const b = s.intake_data?.bundled_service || {};
hasBundle.checked = !!b.has_bundle;
details.hidden = !hasBundle.checked;
bundleRev.value = b.bundle_revenue_usd ?? "";
localPct.value = b.local_allocation_pct ?? "";
tollPct.value = b.toll_allocation_pct ?? "";
method.value = b.methodology || "";
});
window.addEventListener("pw:step-next", (evt: any) => {
const PW = (window as any).PWIntake;
if (PW.steps[PW.get().step_index] !== "bundled_service") return;
if (hasBundle.checked) {
const lp = Number(localPct.value) || 0;
const tp = Number(tollPct.value) || 0;
if (Math.abs((lp + tp) - 100) > 0.01) {
err.hidden = false; err.textContent = `Local + Toll must sum to 100% (got ${(lp+tp).toFixed(2)}%).`;
evt.preventDefault(); return;
}
if (!method.value.trim()) {
err.hidden = false; err.textContent = "Allocation methodology is required — the FCC needs to see your basis for the split.";
evt.preventDefault(); return;
}
}
err.hidden = true;
PW.patchIntakeData({
bundled_service: hasBundle.checked ? {
has_bundle: true,
bundle_revenue_usd: Number(bundleRev.value) || 0,
local_allocation_pct: Number(localPct.value) || 0,
toll_allocation_pct: Number(tollPct.value) || 0,
methodology: method.value.trim(),
} : { has_bundle: false },
});
});
</script>

View file

@ -0,0 +1,97 @@
---
// CALEAStep — LE contact + network infrastructure for the SSI plan.
---
<div class="pw-step">
<h2>CALEA SSI Plan details</h2>
<p class="pw-help">
Every common carrier and interconnected-VoIP provider must maintain
a System Security and Integrity plan (47 USC § 229 / 47 CFR § 1.20003).
The plan is kept internally and provided to DOJ on subpoena.
</p>
<fieldset class="pw-fieldset">
<legend>Designated 24-hour law enforcement contact</legend>
<label class="pw-field">Name</label>
<input id="pw-le-name" class="pw-input" />
<label class="pw-field">Title</label>
<input id="pw-le-title" class="pw-input" placeholder="e.g. General Counsel" />
<label class="pw-field">24-hour phone</label>
<input id="pw-le-phone" class="pw-input" />
<label class="pw-field">24-hour email</label>
<input id="pw-le-email" class="pw-input" />
</fieldset>
<fieldset class="pw-fieldset">
<legend>CPNI Protection Officer</legend>
<label class="pw-field">Name</label>
<input id="pw-cpni-name" class="pw-input" />
<label class="pw-field">Title</label>
<input id="pw-cpni-title" class="pw-input" />
</fieldset>
<label class="pw-field">Network infrastructure summary</label>
<textarea id="pw-net-summary" class="pw-input" rows="3" placeholder="e.g. FreeSWITCH cluster + Ribbon SBC; trunking via Bandwidth.com"></textarea>
<label class="pw-field">How you support lawful intercept</label>
<textarea id="pw-intercept" class="pw-input" rows="3" placeholder="e.g. CALEA intercept via upstream provider Bandwidth.com under the CALEA Reference Model for VoIP"></textarea>
<div id="pw-calea-err" class="pw-err" hidden></div>
</div>
<style>
.pw-step h2 { margin: 0 0 0.5rem; color: #1a2744; }
.pw-help { color: #64748b; font-size: 0.9rem; margin-bottom: 1rem; }
.pw-field { display: block; font-weight: 600; margin: 0.7rem 0 0.2rem; font-size: 0.88rem; }
.pw-input { width: 100%; padding: 0.5rem 0.7rem; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 0.93rem; font-family: inherit; }
.pw-fieldset { border: 1px solid #e2e8f0; border-radius: 8px; padding: 0.75rem 1rem 1rem; margin: 1rem 0; }
.pw-fieldset legend { font-weight: 600; color: #1a2744; padding: 0 0.5rem; }
.pw-err { color: #b91c1c; margin-top: 0.75rem; font-size: 0.9rem; }
</style>
<script>
const g = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
const err = g<HTMLDivElement>("pw-calea-err");
window.addEventListener("pw:step-shown", (evt: any) => {
if (evt.detail.step !== "calea") return;
const s = (window as any).PWIntake.get();
const c = s.intake_data?.calea_ssi || {};
g<HTMLInputElement>("pw-le-name").value = c.law_enforcement_contact?.name || "";
g<HTMLInputElement>("pw-le-title").value = c.law_enforcement_contact?.title || "";
g<HTMLInputElement>("pw-le-phone").value = c.law_enforcement_contact?.phone || "";
g<HTMLInputElement>("pw-le-email").value = c.law_enforcement_contact?.email_24h || "";
g<HTMLInputElement>("pw-cpni-name").value = c.cpni_protection_officer?.name || "";
g<HTMLInputElement>("pw-cpni-title").value = c.cpni_protection_officer?.title || "";
g<HTMLTextAreaElement>("pw-net-summary").value = c.network_infrastructure_summary || "";
g<HTMLTextAreaElement>("pw-intercept").value = c.interception_support_method || "";
});
window.addEventListener("pw:step-next", (evt: any) => {
const PW = (window as any).PWIntake;
if (PW.steps[PW.get().step_index] !== "calea") return;
const leName = g<HTMLInputElement>("pw-le-name").value.trim();
const lePhone = g<HTMLInputElement>("pw-le-phone").value.trim();
const leEmail = g<HTMLInputElement>("pw-le-email").value.trim();
if (!leName || !lePhone || !leEmail) {
err.hidden = false; err.textContent = "Law enforcement 24-hour contact name, phone, and email are required."; evt.preventDefault(); return;
}
err.hidden = true;
PW.patchIntakeData({
calea_ssi: {
law_enforcement_contact: {
name: leName,
title: g<HTMLInputElement>("pw-le-title").value.trim(),
phone: lePhone,
email_24h: leEmail,
},
cpni_protection_officer: {
name: g<HTMLInputElement>("pw-cpni-name").value.trim(),
title: g<HTMLInputElement>("pw-cpni-title").value.trim(),
},
network_infrastructure_summary: g<HTMLTextAreaElement>("pw-net-summary").value.trim(),
interception_support_method: g<HTMLTextAreaElement>("pw-intercept").value.trim(),
},
});
});
</script>

View file

@ -0,0 +1,50 @@
---
// CDRPeriodStep — reporting year + period for a traffic-study order.
---
<div class="pw-step">
<h2>Traffic study period</h2>
<p class="pw-help">
Which reporting period do you want the traffic study for? Annual
covers the full calendar year and is what feeds the Form 499-A.
</p>
<div class="pw-row">
<div><label class="pw-field">Reporting year</label>
<input id="pw-cdr-year" class="pw-input" type="number" min="2020" max="2030" /></div>
<div><label class="pw-field">Period</label>
<select id="pw-cdr-period" class="pw-input">
<option value="ANNUAL">Annual</option>
<option value="Q1">Q1 (JanMar)</option>
<option value="Q2">Q2 (AprJun)</option>
<option value="Q3">Q3 (JulSep)</option>
<option value="Q4">Q4 (OctDec)</option>
</select></div>
</div>
</div>
<style>
.pw-step h2 { margin: 0 0 0.5rem; color: #1a2744; }
.pw-help { color: #64748b; font-size: 0.9rem; margin-bottom: 1rem; }
.pw-field { display: block; font-weight: 600; margin: 0.7rem 0 0.2rem; font-size: 0.88rem; }
.pw-input { width: 100%; padding: 0.5rem 0.7rem; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 0.93rem; }
.pw-row { display: flex; gap: 1rem; flex-wrap: wrap; }
.pw-row > * { flex: 1 1 180px; }
</style>
<script>
const g = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
window.addEventListener("pw:step-shown", (evt: any) => {
if (evt.detail.step !== "cdr_period") return;
const s = (window as any).PWIntake.get();
g<HTMLInputElement>("pw-cdr-year").value = s.intake_data?.reporting_year ?? (new Date().getUTCFullYear() - 1);
g<HTMLSelectElement>("pw-cdr-period").value = s.intake_data?.reporting_period ?? "ANNUAL";
});
window.addEventListener("pw:step-next", (evt: any) => {
const PW = (window as any).PWIntake;
if (PW.steps[PW.get().step_index] !== "cdr_period") return;
PW.patchIntakeData({
reporting_year: Number(g<HTMLInputElement>("pw-cdr-year").value),
reporting_period: g<HTMLSelectElement>("pw-cdr-period").value,
});
});
</script>

View file

@ -0,0 +1,157 @@
---
// CPNIStep — simplified CPNI certification confirmation.
// Most carriers have zero complaints/breaches/issues. Default to "all clean"
// with a single checkbox to confirm, and an expandable section for carriers
// that DID have issues during the reporting period.
---
<div class="pw-step">
<h2>CPNI Certification — <span id="pw-cpni-year"></span></h2>
<p class="pw-help">
Confirm your CPNI compliance status for the reporting period.
Most carriers can certify clean compliance — just confirm below.
</p>
<label class="pw-confirm-box">
<input type="checkbox" id="pw-cpni-clean" checked />
<div>
<strong>I confirm clean CPNI compliance for this reporting period:</strong>
<ul>
<li>No complaints regarding unauthorized release or use of CPNI</li>
<li>No data breaches involving CPNI</li>
<li>No employee disciplinary actions for CPNI violations</li>
<li>No unauthorized data broker access to CPNI</li>
<li>We do not use CPNI for marketing beyond subscribed services</li>
</ul>
</div>
</label>
<details id="pw-cpni-issues" class="pw-issues">
<summary>I had compliance issues during this period (complaints, breaches, etc.)</summary>
<div class="pw-issues-body">
<label class="pw-field">CPNI complaints received
<input type="number" id="pw-cpni-complaints-count" min="0" value="0" />
</label>
<label class="pw-field">Complaint details (if any)
<textarea id="pw-cpni-complaints-desc" rows="2" placeholder="Nature of complaints and how resolved"></textarea>
</label>
<label class="pw-field">Data breaches involving CPNI
<input type="number" id="pw-cpni-breaches-count" min="0" value="0" />
</label>
<label class="pw-field">Breach details (if any)
<textarea id="pw-cpni-breaches-desc" rows="2" placeholder="Description, notifications filed per 47 CFR § 64.2011"></textarea>
</label>
<label class="pw-field">Employees disciplined for CPNI violations
<input type="number" id="pw-cpni-disciplinary-count" min="0" value="0" />
</label>
<label class="pw-field">Data broker issues
<textarea id="pw-cpni-brokers-desc" rows="2" placeholder="Actions taken against data brokers, if any"></textarea>
</label>
<label class="pw-field">CPNI marketing usage
<select id="pw-cpni-marketing">
<option value="no">No — we do not use CPNI for marketing</option>
<option value="opt_in">Yes — with opt-in customer approval</option>
<option value="opt_out">Yes — with opt-out customer approval</option>
</select>
</label>
</div>
</details>
<div id="pw-cpni-err" class="pw-err" hidden></div>
</div>
<style>
.pw-step h2 { margin: 0 0 0.5rem; color: #1a2744; }
.pw-help { color: #64748b; font-size: 0.9rem; margin-bottom: 1rem; }
.pw-confirm-box {
display: flex; gap: 0.75rem; padding: 1rem; background: #f0fdf4;
border: 1px solid #86efac; border-radius: 8px; cursor: pointer;
margin-bottom: 1rem; align-items: flex-start;
}
.pw-confirm-box input { margin-top: 0.3rem; width: 18px; height: 18px; accent-color: #059669; }
.pw-confirm-box strong { display: block; font-size: 0.9rem; color: #065f46; margin-bottom: 0.3rem; }
.pw-confirm-box ul { margin: 0; padding-left: 1.25rem; font-size: 0.82rem; color: #047857; }
.pw-confirm-box li { margin: 0.15rem 0; }
.pw-issues { margin-top: 0.5rem; }
.pw-issues summary {
cursor: pointer; font-size: 0.85rem; color: #b45309; font-weight: 600;
padding: 0.5rem 0;
}
.pw-issues-body {
padding: 0.75rem; background: #fffbeb; border: 1px solid #fde68a;
border-radius: 6px; margin-top: 0.5rem;
}
.pw-field { display: block; font-size: 0.82rem; color: #475569; margin-bottom: 0.6rem; }
.pw-field input, .pw-field textarea, .pw-field select {
display: block; width: 100%; padding: 0.4rem 0.5rem;
border: 1px solid #cbd5e1; border-radius: 4px; font-size: 0.85rem; margin-top: 0.2rem;
}
.pw-field input[type="number"] { width: 80px; }
.pw-err { color: #b91c1c; margin-top: 0.75rem; font-size: 0.9rem; }
</style>
<script>
const yearEl = document.getElementById("pw-cpni-year")!;
const reportingYear = new Date().getFullYear() - 1;
yearEl.textContent = String(reportingYear);
const cleanBox = document.getElementById("pw-cpni-clean") as HTMLInputElement;
const issuesEl = document.getElementById("pw-cpni-issues") as HTMLDetailsElement;
// When "clean" is unchecked, auto-open issues
cleanBox.addEventListener("change", () => {
if (!cleanBox.checked) issuesEl.open = true;
});
// When issues are opened, uncheck clean
issuesEl.addEventListener("toggle", () => {
if (issuesEl.open && cleanBox.checked) {
// Only uncheck if they actually entered something
}
});
window.addEventListener("pw:step-next", (evt: any) => {
const PW = (window as any).PWIntake;
if (PW.steps[PW.get().step_index] !== "cpni_questions") return;
const g = (id: string) => (document.getElementById(id) as HTMLInputElement)?.value || "";
if (cleanBox.checked) {
// Clean compliance — simple case
PW.patchIntakeData({
cpni: {
reporting_year: reportingYear,
clean_compliance: true,
complaints: "no", complaints_count: 0,
breaches: "no", breaches_count: 0,
disciplinary: "no", disciplinary_count: 0,
data_brokers: "no",
marketing_usage: "no",
},
});
} else {
// Has issues — collect details
PW.patchIntakeData({
cpni: {
reporting_year: reportingYear,
clean_compliance: false,
complaints: parseInt(g("pw-cpni-complaints-count")) > 0 ? "yes" : "no",
complaints_count: parseInt(g("pw-cpni-complaints-count")) || 0,
complaints_description: g("pw-cpni-complaints-desc"),
breaches: parseInt(g("pw-cpni-breaches-count")) > 0 ? "yes" : "no",
breaches_count: parseInt(g("pw-cpni-breaches-count")) || 0,
breaches_description: g("pw-cpni-breaches-desc"),
disciplinary: parseInt(g("pw-cpni-disciplinary-count")) > 0 ? "yes" : "no",
disciplinary_count: parseInt(g("pw-cpni-disciplinary-count")) || 0,
data_brokers: g("pw-cpni-brokers-desc").trim() ? "yes" : "no",
data_brokers_description: g("pw-cpni-brokers-desc"),
marketing_usage: g("pw-cpni-marketing"),
},
});
}
});
</script>

View file

@ -0,0 +1,472 @@
---
// CategoryStep — Guided Q&A wizard that determines Line 105 categories.
//
// Instead of showing all 22 FCC categories and asking the user to rank them,
// this walks through plain-English questions and maps answers to the correct
// Line 105 category automatically. Falls back to the manual multi-select
// for edge cases.
//
// Outputs: intake_data.line_105_primary + intake_data.line_105_categories
import { LINE_105_CATALOG, LINE_105_BY_ID } from "../../../lib/line_105_catalog";
---
<div class="pw-step">
<h2>What type of carrier are you?</h2>
<p class="pw-help">
Answer a few questions and we'll determine your FCC carrier classification
automatically. This determines which Line 105 categories apply to your
499-A filing.
</p>
<!-- Wizard questions -->
<div id="pw-wizard">
<!-- Q1: Voice service -->
<div class="wiz-q" data-q="voice">
<p class="wiz-label">Do you provide voice / telephone service?</p>
<p class="wiz-hint">This includes VoIP, landline, mobile, or any service where customers can make or receive phone calls.</p>
<div class="wiz-opts">
<button class="wiz-btn" data-val="retail">Yes, to end customers (retail)</button>
<button class="wiz-btn" data-val="wholesale">Yes, wholesale only (to other carriers)</button>
<button class="wiz-btn" data-val="no">No voice service</button>
</div>
</div>
<!-- Q2: Voice technology (shown if voice=retail or wholesale) -->
<div class="wiz-q hidden" data-q="voice_tech">
<p class="wiz-label">How do you deliver voice service?</p>
<div class="wiz-opts">
<button class="wiz-btn" data-val="voip">VoIP / Internet-based calling</button>
<button class="wiz-btn" data-val="tdm">Traditional landline (TDM / copper / fiber-to-prem)</button>
<button class="wiz-btn" data-val="wireless">Wireless / cellular</button>
<button class="wiz-btn" data-val="both">Both VoIP and traditional/wireless</button>
</div>
</div>
<!-- Q3: VoIP interconnection (shown if voip or both) -->
<div class="wiz-q hidden" data-q="voip_pstn">
<p class="wiz-label">Can your VoIP customers call regular phone numbers?</p>
<p class="wiz-hint">If your customers can dial a landline or mobile number (not just app-to-app), your service interconnects with the PSTN.</p>
<div class="wiz-opts">
<button class="wiz-btn" data-val="yes">Yes, they can call any phone number</button>
<button class="wiz-btn" data-val="one_way">One-way only (inbound or outbound, not both)</button>
<button class="wiz-btn" data-val="no">No, app-to-app only (like FaceTime)</button>
</div>
</div>
<!-- Q4: Own infrastructure -->
<div class="wiz-q hidden" data-q="infra">
<p class="wiz-label">Do you own or lease your own network infrastructure?</p>
<p class="wiz-hint">This includes switches, fiber, towers, spectrum licenses, or co-location equipment.</p>
<div class="wiz-opts">
<button class="wiz-btn" data-val="facilities">Yes, I own/lease network equipment</button>
<button class="wiz-btn" data-val="reseller">No, I resell another carrier's service</button>
<button class="wiz-btn" data-val="hybrid">Some of both</button>
</div>
</div>
<!-- Q5: Wireless specifics (shown if wireless) -->
<div class="wiz-q hidden" data-q="wireless_type">
<p class="wiz-label">Do you own spectrum licenses or use another carrier's network?</p>
<div class="wiz-opts">
<button class="wiz-btn" data-val="own">Own spectrum / towers (MNO)</button>
<button class="wiz-btn" data-val="mvno">Use another carrier's network (MVNO)</button>
</div>
</div>
<!-- Q6: Long distance -->
<div class="wiz-q hidden" data-q="long_distance">
<p class="wiz-label">Do you provide long-distance or toll calling?</p>
<p class="wiz-hint">Interstate or international calls beyond your local calling area.</p>
<div class="wiz-opts">
<button class="wiz-btn" data-val="yes">Yes</button>
<button class="wiz-btn" data-val="no">No</button>
</div>
</div>
<!-- Q7: Internet / broadband -->
<div class="wiz-q hidden" data-q="internet">
<p class="wiz-label">Do you provide internet / broadband service?</p>
<div class="wiz-opts">
<button class="wiz-btn" data-val="yes">Yes, retail internet service</button>
<button class="wiz-btn" data-val="wholesale">Yes, wholesale bandwidth</button>
<button class="wiz-btn" data-val="no">No internet service</button>
</div>
</div>
<!-- Q8: Toll-free -->
<div class="wiz-q hidden" data-q="toll_free">
<p class="wiz-label">Do you provide toll-free (800/888/877/etc.) service?</p>
<div class="wiz-opts">
<button class="wiz-btn" data-val="yes">Yes</button>
<button class="wiz-btn" data-val="no">No</button>
</div>
</div>
<!-- Q9: Conferencing -->
<div class="wiz-q hidden" data-q="conferencing">
<p class="wiz-label">Do you provide audio conferencing / bridging?</p>
<div class="wiz-opts">
<button class="wiz-btn" data-val="yes">Yes</button>
<button class="wiz-btn" data-val="no">No</button>
</div>
</div>
</div>
<!-- Result -->
<div id="pw-result" class="hidden">
<div class="result-card">
<h3>Your Classification</h3>
<div id="pw-primary-result"></div>
<div id="pw-secondary-results"></div>
<p class="result-why" id="pw-result-why"></p>
</div>
<button class="wiz-restart" id="pw-restart">Start over</button>
<details class="manual-override">
<summary>Need to adjust? Use manual selection</summary>
<div id="pw-manual-list" class="manual-list"></div>
</details>
</div>
<div id="pw-cat-err" class="pw-err" hidden></div>
</div>
<style>
.pw-step h2 { margin: 0 0 0.5rem; color: #1a2744; }
.pw-help { color: #64748b; font-size: 0.9rem; margin-bottom: 1.25rem; }
.wiz-q { margin-bottom: 1rem; animation: fadeIn 0.2s ease-in; }
.wiz-label { font-weight: 600; color: #1f2937; font-size: 0.95rem; margin-bottom: 0.3rem; }
.wiz-hint { font-size: 0.82rem; color: #6b7280; margin-bottom: 0.6rem; }
.wiz-opts { display: flex; flex-direction: column; gap: 0.4rem; }
.wiz-btn {
text-align: left; padding: 0.65rem 0.9rem; border: 1.5px solid #d1d5db;
border-radius: 8px; background: #fff; font-size: 0.88rem; cursor: pointer;
color: #374151; transition: all 0.15s; font-family: inherit;
}
.wiz-btn:hover { border-color: #1e3a5f; background: #f0f4f8; }
.wiz-btn.selected { border-color: #1e3a5f; background: #eff6ff; color: #1e3a5f; font-weight: 600; }
.wiz-btn.selected::before { content: "\2713 "; }
.hidden { display: none; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
.result-card {
background: #f0fdf4; border: 2px solid #22c55e; border-radius: 10px;
padding: 1rem; margin-bottom: 0.75rem;
}
.result-card h3 { color: #166534; font-size: 1rem; margin-bottom: 0.5rem; }
.result-primary { font-size: 1.1rem; font-weight: 700; color: #111827; margin-bottom: 0.35rem; }
.result-primary .rank { font-size: 0.75rem; color: #059669; font-weight: 600; background: #dcfce7; padding: 0.15rem 0.5rem; border-radius: 4px; margin-right: 0.4rem; }
.result-secondary { font-size: 0.9rem; color: #374151; padding: 0.2rem 0; }
.result-secondary .rank { font-size: 0.7rem; color: #6b7280; background: #f1f5f9; padding: 0.1rem 0.4rem; border-radius: 3px; margin-right: 0.3rem; }
.result-why { font-size: 0.82rem; color: #6b7280; margin-top: 0.5rem; font-style: italic; }
.wiz-restart { background: none; border: 1px solid #d1d5db; padding: 0.4rem 0.9rem; border-radius: 6px; font-size: 0.82rem; cursor: pointer; color: #6b7280; font-family: inherit; }
.wiz-restart:hover { border-color: #1e3a5f; color: #1e3a5f; }
.manual-override { margin-top: 0.75rem; border: 1px solid #e5e7eb; border-radius: 8px; }
.manual-override summary { padding: 0.5rem 0.8rem; cursor: pointer; font-size: 0.82rem; color: #6b7280; }
.manual-list { padding: 0.5rem 0.8rem; max-height: 300px; overflow-y: auto; }
.manual-row { display: flex; align-items: center; gap: 0.5rem; padding: 0.3rem 0; border-bottom: 1px solid #f3f4f6; font-size: 0.85rem; }
.manual-row input { accent-color: #1e3a5f; }
.manual-row select { font-size: 0.78rem; padding: 0.15rem 0.3rem; border: 1px solid #d1d5db; border-radius: 4px; }
.pw-err { color: #b91c1c; margin-top: 0.75rem; font-size: 0.9rem; }
</style>
<script>
import { LINE_105_CATALOG, LINE_105_BY_ID } from "../../../lib/line_105_catalog";
const err = document.getElementById("pw-cat-err") as HTMLDivElement;
const wizard = document.getElementById("pw-wizard") as HTMLDivElement;
const result = document.getElementById("pw-result") as HTMLDivElement;
// Wizard state
const answers: Record<string, string> = {};
const questionFlow = [
"voice", "voice_tech", "voip_pstn", "infra", "wireless_type",
"long_distance", "internet", "toll_free", "conferencing"
];
// Wire up all buttons
wizard.querySelectorAll<HTMLButtonElement>(".wiz-btn").forEach((btn) => {
btn.addEventListener("click", () => {
const q = btn.closest<HTMLElement>(".wiz-q")!;
const qName = q.dataset.q!;
// Deselect siblings
q.querySelectorAll(".wiz-btn").forEach((b) => b.classList.remove("selected"));
btn.classList.add("selected");
answers[qName] = btn.dataset.val!;
advanceWizard(qName);
});
});
function showQ(name: string) {
const q = wizard.querySelector<HTMLElement>(`[data-q="${name}"]`);
if (q) q.classList.remove("hidden");
}
function hideQ(name: string) {
const q = wizard.querySelector<HTMLElement>(`[data-q="${name}"]`);
if (q) q.classList.add("hidden");
}
function advanceWizard(justAnswered: string) {
// Determine which question to show next based on answers so far
if (justAnswered === "voice") {
if (answers.voice === "no") {
// No voice — skip to internet question
hideQ("voice_tech"); hideQ("voip_pstn"); hideQ("infra");
hideQ("wireless_type"); hideQ("long_distance");
showQ("internet");
} else {
showQ("voice_tech");
}
}
else if (justAnswered === "voice_tech") {
if (answers.voice_tech === "voip" || answers.voice_tech === "both") {
showQ("voip_pstn");
} else if (answers.voice_tech === "wireless") {
hideQ("voip_pstn");
showQ("wireless_type");
} else {
// TDM — ask about infrastructure
hideQ("voip_pstn"); hideQ("wireless_type");
showQ("infra");
}
}
else if (justAnswered === "voip_pstn") {
showQ("infra");
}
else if (justAnswered === "infra") {
if (answers.voice_tech === "wireless" || answers.voice_tech === "both") {
showQ("wireless_type");
} else {
showQ("long_distance");
}
}
else if (justAnswered === "wireless_type") {
showQ("long_distance");
}
else if (justAnswered === "long_distance") {
showQ("internet");
}
else if (justAnswered === "internet") {
showQ("toll_free");
}
else if (justAnswered === "toll_free") {
showQ("conferencing");
}
else if (justAnswered === "conferencing") {
// All questions answered — compute result
computeResult();
}
}
function computeResult() {
const categories: Array<{id: string; rank: number; infra_type: string; is_tdm_service?: boolean; reason: string}> = [];
let rank = 1;
function add(id: string, infra: string, reason: string, tdm?: boolean) {
if (LINE_105_BY_ID[id]) {
categories.push({ id, rank: rank++, infra_type: infra, ...(tdm !== undefined ? { is_tdm_service: tdm } : {}), reason });
}
}
const infra = answers.infra || "facilities";
// Primary classification logic
if (answers.voice !== "no") {
// Voice provider
if (answers.voice_tech === "voip" || answers.voice_tech === "both") {
if (answers.voip_pstn === "yes") {
add("voip_interconnected", infra, "You provide VoIP service that connects to the phone network");
} else if (answers.voip_pstn === "one_way") {
add("voip_non_interconnected", infra, "Your VoIP service only handles one direction (inbound or outbound)");
}
// App-only VoIP is not a telecom service — no Line 105 box
}
if (answers.voice_tech === "tdm" || answers.voice_tech === "both") {
if (infra === "facilities" || infra === "hybrid") {
add("clec", infra, "You provide wireline local telephone service using your own facilities", true);
} else {
add("clec", "reseller", "You resell local telephone service", true);
}
}
if (answers.voice_tech === "wireless" || answers.voice_tech === "both") {
if (answers.wireless_type === "mvno") {
add("wireless", "mvno", "You provide wireless service using another carrier's network");
} else {
add("wireless", "facilities", "You provide wireless service with your own spectrum/towers");
}
}
// Long distance
if (answers.long_distance === "yes") {
add("ixc", infra, "You provide long-distance/toll calling service");
}
}
// Toll-free
if (answers.toll_free === "yes") {
add("toll_free", infra, "You provide toll-free (8YY) number service");
}
// Audio bridging
if (answers.conferencing === "yes") {
add("audio_bridging", infra, "You provide audio conferencing/bridging service");
}
// Internet-only providers without voice are not typically on Line 105
// but may file 499-A for broadband revenue reporting
if (answers.voice === "no" && (answers.internet === "yes" || answers.internet === "wholesale")) {
add("other", "n/a", "Internet/broadband provider — may need 499-A for broadband revenue reporting");
}
// Internet as secondary for voice carriers
if (answers.voice !== "no" && (answers.internet === "yes" || answers.internet === "wholesale")) {
add("private_line", infra, "You also provide internet/data service alongside voice");
}
if (categories.length === 0) {
// Edge case: no categories determined — show manual fallback
wizard.classList.add("hidden");
result.classList.remove("hidden");
err.hidden = false;
err.textContent = "Based on your answers, we couldn't determine a carrier category. Please use the manual selection below.";
renderManualList(categories);
return;
}
// Show results
wizard.classList.add("hidden");
result.classList.remove("hidden");
err.hidden = true;
const primary = categories[0];
const entry = LINE_105_BY_ID[primary.id];
document.getElementById("pw-primary-result")!.innerHTML =
`<div class="result-primary"><span class="rank">PRIMARY</span> ${entry?.label || primary.id}</div>`;
const secHtml = categories.slice(1).map((c, i) => {
const e = LINE_105_BY_ID[c.id];
return `<div class="result-secondary"><span class="rank">#${i + 2}</span> ${e?.label || c.id}</div>`;
}).join("");
document.getElementById("pw-secondary-results")!.innerHTML = secHtml;
const reasons = categories.map((c) => c.reason).join(". ") + ".";
document.getElementById("pw-result-why")!.textContent = reasons;
renderManualList(categories);
storeState(categories);
}
function renderManualList(preselected: Array<{id: string; rank: number; infra_type: string}>) {
const list = document.getElementById("pw-manual-list")!;
list.innerHTML = "";
for (const entry of LINE_105_CATALOG) {
if (entry.status === "subtype_of" || entry.status === "deprecated") continue;
const existing = preselected.find((c) => c.id === entry.id);
const row = document.createElement("div");
row.className = "manual-row";
row.innerHTML = `
<input type="checkbox" data-manual-cat="${entry.id}" ${existing ? "checked" : ""}>
<span style="flex:1">${entry.label}</span>
<select data-manual-rank="${entry.id}">
<option value="">—</option>
${[1,2,3,4,5].map((r) => `<option value="${r}" ${existing?.rank === r ? "selected" : ""}>${r === 1 ? "Primary" : "#" + r}</option>`).join("")}
</select>
`;
list.appendChild(row);
}
// Wire up manual changes
list.querySelectorAll("input, select").forEach((el) => {
el.addEventListener("change", () => {
const cats = readManualState();
storeState(cats);
});
});
}
function readManualState(): Array<{id: string; rank: number; infra_type: string}> {
const list = document.getElementById("pw-manual-list")!;
const cats: Array<{id: string; rank: number; infra_type: string}> = [];
list.querySelectorAll<HTMLInputElement>("input[data-manual-cat]").forEach((cb) => {
if (!cb.checked) return;
const id = cb.dataset.manualCat!;
const rankSel = list.querySelector<HTMLSelectElement>(`select[data-manual-rank="${id}"]`);
const rank = Number(rankSel?.value || 0);
if (rank) cats.push({ id, rank, infra_type: answers.infra || "facilities" });
});
cats.sort((a, b) => a.rank - b.rank);
return cats;
}
function storeState(categories: Array<{id: string; rank: number; infra_type: string; is_tdm_service?: boolean}>) {
const PW = (window as any).PWIntake;
if (!PW) return;
const primary = categories[0];
PW.patchIntakeData({
line_105_primary: primary?.id || "",
line_105_categories: categories.map(({ id, rank, infra_type, is_tdm_service }) => ({
id, rank, infra_type, ...(is_tdm_service !== undefined ? { is_tdm_service } : {}),
})),
});
}
// Restart wizard
document.getElementById("pw-restart")?.addEventListener("click", () => {
Object.keys(answers).forEach((k) => delete answers[k]);
wizard.querySelectorAll(".wiz-btn").forEach((b) => b.classList.remove("selected"));
questionFlow.slice(1).forEach((q) => hideQ(q));
wizard.classList.remove("hidden");
result.classList.add("hidden");
err.hidden = true;
});
// Restore state from intake data if coming back to this step
window.addEventListener("pw:step-shown", (evt: any) => {
if (evt.detail.step !== "category") return;
const s = (window as any).PWIntake?.get();
const existing = s?.intake_data?.line_105_categories;
if (existing && existing.length > 0) {
// Show results directly with existing data
wizard.classList.add("hidden");
result.classList.remove("hidden");
const primary = existing[0];
const entry = LINE_105_BY_ID[primary.id];
document.getElementById("pw-primary-result")!.innerHTML =
`<div class="result-primary"><span class="rank">PRIMARY</span> ${entry?.label || primary.id}</div>`;
const secHtml = existing.slice(1).map((c: any, i: number) => {
const e = LINE_105_BY_ID[c.id];
return `<div class="result-secondary"><span class="rank">#${i + 2}</span> ${e?.label || c.id}</div>`;
}).join("");
document.getElementById("pw-secondary-results")!.innerHTML = secHtml;
renderManualList(existing);
}
});
// Validate on step-next
window.addEventListener("pw:step-next", (evt: any) => {
const PW = (window as any).PWIntake;
if (!PW || PW.steps[PW.get().step_index] !== "category") return;
const s = PW.get();
const categories = s.intake_data?.line_105_categories || [];
if (categories.length === 0) {
err.hidden = false;
err.textContent = "Please complete the classification wizard or use manual selection.";
evt.preventDefault();
return;
}
const primary = categories.find((c: any) => c.rank === 1);
if (!primary) {
err.hidden = false;
err.textContent = "One category must be ranked as Primary.";
evt.preventDefault();
return;
}
err.hidden = true;
});
</script>

View file

@ -0,0 +1,309 @@
---
// ClassificationWizard — Guided Q&A to determine FCC Line 105 carrier category.
//
// Replaces the raw Line 105 multi-select with plain-English questions.
// Outputs the same data: line_105_primary + line_105_categories.
---
<div class="pw-step">
<h2>What type of carrier are you?</h2>
<p class="pw-help">
Answer a few questions about your services and we'll determine the correct FCC classification.
This affects your 499-A filing categories, USF obligations, and compliance requirements.
</p>
<div id="cw-questions"></div>
<div id="cw-result" style="display:none;margin-top:1.5rem">
<div style="padding:1rem;background:#f0fdf4;border:1px solid #86efac;border-radius:8px">
<p style="margin:0 0 .5rem;font-size:.85rem;font-weight:700;color:#166534">Your classification:</p>
<p id="cw-primary" style="margin:0 0 .25rem;font-size:1rem;font-weight:700;color:#111"></p>
<p id="cw-reason" style="margin:0;font-size:.8rem;color:#166534"></p>
<div id="cw-secondary" style="margin-top:.5rem;font-size:.8rem;color:#374151"></div>
</div>
<p style="font-size:.75rem;color:#94a3b8;margin-top:.5rem">
This is our recommendation based on your answers. You can change it on the next step if needed.
</p>
</div>
</div>
<style>
.pw-step h2 { margin: 0 0 0.5rem; color: #1a2744; }
.pw-help { color: #64748b; font-size: 0.9rem; margin-bottom: 1.25rem; }
.cw-q { margin-bottom: 1.25rem; animation: fadeIn 0.2s ease-in; }
.cw-q-text { font-size: .95rem; font-weight: 600; color: #1a2744; margin-bottom: .5rem; }
.cw-q-hint { font-size: .8rem; color: #64748b; margin-bottom: .5rem; }
.cw-opts { display: flex; flex-wrap: wrap; gap: .5rem; }
.cw-opt { padding: .5rem 1rem; border: 2px solid #d1d5db; border-radius: 8px; cursor: pointer;
font-size: .85rem; font-weight: 500; color: #374151; background: #fff; transition: all .15s; }
.cw-opt:hover { border-color: #1e3a5f; background: #f0f4f8; }
.cw-opt.selected { border-color: #059669; background: #f0fdf4; color: #166534; font-weight: 700; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
</style>
<script>
// Decision tree for carrier classification
const QUESTIONS = [
{
id: "voice",
text: "Do you provide voice telephone service (VoIP, landline, or wireless)?",
hint: "This includes any service that connects calls to the public telephone network (PSTN).",
options: [
{ label: "Yes, over the internet (VoIP)", value: "voip" },
{ label: "Yes, over our own network (copper/fiber/wireless)", value: "facilities" },
{ label: "Yes, we resell another carrier's voice service", value: "reseller" },
{ label: "No, we don't provide voice service", value: "no" },
],
},
{
id: "broadband",
text: "Do you provide broadband internet access to end users?",
hint: "Cable, fiber, fixed wireless, DSL, or satellite internet.",
options: [
{ label: "Yes, over our own infrastructure", value: "facilities" },
{ label: "Yes, we resell another provider's broadband", value: "resale" },
{ label: "No", value: "no" },
],
},
{
id: "switching",
text: "Do you own or operate telephone switching equipment?",
hint: "Softswitch, SBC, Class 5 switch, or media gateway that routes calls.",
options: [
{ label: "Yes", value: "yes" },
{ label: "No, we use a platform provider (UCaaS, hosted PBX)", value: "no" },
],
showIf: (a: any) => a.voice !== "no",
},
{
id: "local_exchange",
text: "Do you provide local telephone service in a specific geographic area?",
hint: "Local dial tone, local phone numbers assigned to your customers.",
options: [
{ label: "Yes", value: "yes" },
{ label: "No", value: "no" },
],
showIf: (a: any) => a.voice === "facilities" || (a.voice === "voip" && a.switching === "yes"),
},
{
id: "long_distance",
text: "Do you provide long-distance or interstate calling?",
hint: "Toll calls between states, or international calling.",
options: [
{ label: "Yes", value: "yes" },
{ label: "No", value: "no" },
],
showIf: (a: any) => a.voice !== "no",
},
{
id: "wireless",
text: "Do you provide wireless (cellular) service?",
options: [
{ label: "Yes, we operate our own wireless network", value: "facilities" },
{ label: "Yes, as an MVNO (reselling another carrier's network)", value: "mvno" },
{ label: "No", value: "no" },
],
showIf: (a: any) => a.voice === "facilities",
},
{
id: "wholesale",
text: "Do you sell services primarily to other carriers (wholesale)?",
options: [
{ label: "Yes, mostly wholesale", value: "yes" },
{ label: "No, mostly retail (end users)", value: "no" },
{ label: "Both", value: "both" },
],
showIf: (a: any) => a.voice !== "no",
},
{
id: "toll_free",
text: "Do you provide toll-free numbers (800, 888, etc.) to customers?",
options: [
{ label: "Yes", value: "yes" },
{ label: "No", value: "no" },
],
showIf: (a: any) => a.voice !== "no",
},
];
function classify(answers: Record<string, string>): { primary: string; categories: string[]; reason: string } {
const cats: string[] = [];
let primary = "";
let reason = "";
// No voice service
if (answers.voice === "no") {
if (answers.broadband === "facilities") {
primary = "broadband_isp";
reason = "You provide broadband over your own infrastructure without voice. You are primarily an ISP.";
cats.push("broadband_isp");
} else if (answers.broadband === "resale") {
primary = "broadband_reseller";
reason = "You resell broadband without providing voice service. You may not need to file 499-A.";
cats.push("broadband_reseller");
} else {
primary = "other";
reason = "Based on your answers, you don't appear to be a telecommunications carrier. Contact us if you're unsure.";
}
return { primary, categories: cats, reason };
}
// VoIP provider
if (answers.voice === "voip") {
primary = "interconnected_voip";
reason = "You provide VoIP service connected to the PSTN. This is the most common category for modern voice carriers.";
cats.push("interconnected_voip");
if (answers.switching === "yes" && answers.local_exchange === "yes") {
cats.push("clec");
reason += " You also provide local exchange service with your own switching, making you a CLEC as well.";
}
}
// Facilities-based voice
if (answers.voice === "facilities") {
if (answers.wireless === "facilities") {
primary = "cmrs";
reason = "You operate your own wireless network. You are a Commercial Mobile Radio Service (CMRS) provider.";
cats.push("cmrs");
} else if (answers.wireless === "mvno") {
primary = "cmrs_reseller";
reason = "You resell wireless service as an MVNO.";
cats.push("cmrs_reseller");
} else if (answers.local_exchange === "yes") {
primary = "clec";
reason = "You provide local telephone service over your own facilities. You are a Competitive Local Exchange Carrier (CLEC).";
cats.push("clec");
} else {
primary = "interconnected_voip";
reason = "You provide voice service over your own facilities.";
cats.push("interconnected_voip");
}
}
// Voice reseller
if (answers.voice === "reseller") {
primary = "voip_reseller";
reason = "You resell another carrier's voice service. You are a VoIP or voice reseller.";
cats.push("voip_reseller");
}
// IXC if long distance
if (answers.long_distance === "yes" && !cats.includes("cmrs")) {
cats.push("ixc");
if (!primary || primary === "interconnected_voip") {
// Only make IXC primary if they're primarily long-distance
}
}
// Toll-free
if (answers.toll_free === "yes") {
cats.push("toll_free_provider");
}
// Broadband
if (answers.broadband === "facilities") {
cats.push("broadband_isp");
}
// Wholesale
if (answers.wholesale === "yes" || answers.wholesale === "both") {
cats.push("wholesale");
}
if (!primary) primary = cats[0] || "other";
return { primary, categories: [...new Set(cats)], reason };
}
// Render Q&A
const container = document.getElementById("cw-questions")!;
const answers: Record<string, string> = {};
let questionIndex = 0;
function renderQuestion(idx: number) {
// Find the next applicable question
while (idx < QUESTIONS.length) {
const q = QUESTIONS[idx];
if (!q.showIf || q.showIf(answers)) break;
idx++;
}
if (idx >= QUESTIONS.length) {
showResult();
return;
}
questionIndex = idx;
const q = QUESTIONS[idx];
const div = document.createElement("div");
div.className = "cw-q";
div.innerHTML = `
<p class="cw-q-text">${q.text}</p>
${q.hint ? `<p class="cw-q-hint">${q.hint}</p>` : ""}
<div class="cw-opts">
${q.options.map((o) => `<button type="button" class="cw-opt" data-value="${o.value}">${o.label}</button>`).join("")}
</div>
`;
container.appendChild(div);
div.querySelectorAll(".cw-opt").forEach((btn) => {
btn.addEventListener("click", () => {
answers[q.id] = (btn as HTMLElement).dataset.value!;
// Highlight selected
div.querySelectorAll(".cw-opt").forEach((b) => b.classList.remove("selected"));
btn.classList.add("selected");
// Next question after brief delay
setTimeout(() => renderQuestion(idx + 1), 300);
});
});
}
function showResult() {
const result = classify(answers);
document.getElementById("cw-result")!.style.display = "block";
const labels: Record<string, string> = {
interconnected_voip: "Interconnected VoIP Provider",
clec: "Competitive Local Exchange Carrier (CLEC)",
ixc: "Interexchange Carrier (IXC)",
cmrs: "Commercial Mobile Radio Service (CMRS)",
cmrs_reseller: "CMRS Reseller (MVNO)",
voip_reseller: "VoIP Reseller",
broadband_isp: "Broadband Internet Service Provider (ISP)",
broadband_reseller: "Broadband Reseller",
toll_free_provider: "Toll-Free Provider",
wholesale: "Wholesale Provider",
other: "Other / Not Classified",
};
document.getElementById("cw-primary")!.textContent = labels[result.primary] || result.primary;
document.getElementById("cw-reason")!.textContent = result.reason;
const secondary = result.categories.filter((c) => c !== result.primary);
const secEl = document.getElementById("cw-secondary")!;
if (secondary.length) {
secEl.innerHTML = "<strong>Additional categories:</strong> " + secondary.map((c) => labels[c] || c).join(", ");
}
// Save to intake data
const PW = (window as any).PWIntake;
if (PW) {
PW.patchIntakeData({
carrier_classification_answers: answers,
line_105_primary: result.primary,
line_105_categories: result.categories,
});
}
}
// Start
window.addEventListener("pw:step-shown", (evt: any) => {
if (evt.detail.step !== "classification") return;
// Reset if re-entering
container.innerHTML = "";
Object.keys(answers).forEach((k) => delete answers[k]);
document.getElementById("cw-result")!.style.display = "none";
renderQuestion(0);
});
// Also start immediately if this is the current step
renderQuestion(0);
</script>

View file

@ -0,0 +1,172 @@
---
// DCAgentStep — D.C. Registered Agent for FCC filings.
// The FCC requires a D.C. agent for service of process (Form 499-A
// Block 2-B, Lines 209-213). Carrier must either use ours ($99/yr
// via NWRA) or provide their own agent's info.
---
<div class="pw-step">
<h2>D.C. Registered Agent</h2>
<p class="pw-help">
The FCC requires every filer to designate a registered agent in the
District of Columbia for service of process. This is listed on your
Form 499-A (Lines 209-213). You can use our agent service or provide
your own.
</p>
<div class="pw-dc-options">
<label class="pw-dc-option" id="pw-dc-ours">
<input type="radio" name="pw_dc_agent_choice" value="ours" checked />
<div class="pw-dc-card">
<strong>Use our D.C. agent — $99/yr</strong>
<span class="pw-dc-rec">Recommended</span>
<p>Northwest Registered Agent Service Inc.<br/>
1717 N Street NW STE 1, Washington, DC 20036</p>
<p class="pw-dc-note">Included at no extra charge with the New Carrier Bundle.
Annual renewal billed separately ($99/yr).</p>
</div>
</label>
<label class="pw-dc-option" id="pw-dc-own">
<input type="radio" name="pw_dc_agent_choice" value="own" />
<div class="pw-dc-card">
<strong>I have my own D.C. agent</strong>
<p>Provide your agent's name and address below.</p>
</div>
</label>
</div>
<!-- Own agent fields (hidden unless "own" selected) -->
<div id="pw-dc-own-fields" class="pw-dc-own-fields" hidden>
<div class="pw-dc-grid">
<div>
<label for="pw-dc-company">Agent company name *</label>
<input type="text" id="pw-dc-company" placeholder="e.g. CSC Global" />
</div>
<div>
<label for="pw-dc-street">Street address *</label>
<input type="text" id="pw-dc-street" placeholder="e.g. 1015 15th Street NW Suite 1000" />
</div>
<div>
<label for="pw-dc-city">City</label>
<input type="text" id="pw-dc-city" value="Washington" readonly />
</div>
<div class="pw-dc-row">
<div>
<label for="pw-dc-state">State</label>
<input type="text" id="pw-dc-state" value="DC" readonly />
</div>
<div>
<label for="pw-dc-zip">ZIP *</label>
<input type="text" id="pw-dc-zip" maxlength="10" placeholder="20001" />
</div>
</div>
<div>
<label for="pw-dc-phone">Agent phone</label>
<input type="tel" id="pw-dc-phone" placeholder="(202) 555-1234" />
</div>
</div>
</div>
<div id="pw-dc-err" class="pw-err" hidden></div>
</div>
<style>
.pw-step h2 { margin: 0 0 0.5rem; color: #1a2744; }
.pw-help { color: #64748b; font-size: 0.9rem; margin-bottom: 1rem; }
.pw-dc-options { display: flex; flex-direction: column; gap: 0.6rem; margin-bottom: 1rem; }
.pw-dc-option { display: flex; align-items: flex-start; gap: 0.5rem; cursor: pointer; }
.pw-dc-option input { margin-top: 0.35rem; }
.pw-dc-card {
flex: 1; padding: 0.75rem 1rem; border: 1px solid #e2e8f0; border-radius: 8px;
transition: border-color 0.15s;
}
.pw-dc-option:has(input:checked) .pw-dc-card { border-color: #2563eb; background: #eff6ff; }
.pw-dc-card strong { display: block; color: #1a2744; font-size: 0.9rem; }
.pw-dc-card p { margin: 0.3rem 0 0; font-size: 0.82rem; color: #64748b; }
.pw-dc-rec { display: inline-block; font-size: 0.7rem; background: #059669; color: #fff; padding: 1px 6px; border-radius: 4px; margin-left: 0.4rem; vertical-align: middle; }
.pw-dc-note { font-size: 0.78rem !important; color: #047857 !important; font-style: italic; }
.pw-dc-own-fields { margin-top: 0.75rem; }
.pw-dc-grid { display: flex; flex-direction: column; gap: 0.6rem; }
.pw-dc-grid label { display: block; font-size: 0.8rem; color: #475569; margin-bottom: 0.15rem; }
.pw-dc-grid input { width: 100%; padding: 0.45rem 0.6rem; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 0.9rem; }
.pw-dc-grid input[readonly] { background: #f1f5f9; color: #64748b; }
.pw-dc-row { display: flex; gap: 0.6rem; }
.pw-dc-row > div { flex: 1; }
.pw-err { color: #b91c1c; margin-top: 0.75rem; font-size: 0.9rem; }
</style>
<script>
const radios = document.querySelectorAll<HTMLInputElement>('input[name="pw_dc_agent_choice"]');
const ownFields = document.getElementById("pw-dc-own-fields") as HTMLElement;
const err = document.getElementById("pw-dc-err") as HTMLDivElement;
function syncVisibility() {
const choice = (document.querySelector<HTMLInputElement>('input[name="pw_dc_agent_choice"]:checked'))?.value;
ownFields.hidden = choice !== "own";
}
radios.forEach(r => r.addEventListener("change", syncVisibility));
// Prefill from wizard state
window.addEventListener("pw:step-shown", (evt: any) => {
if (evt.detail.step !== "dc_agent") return;
const s = (window as any).PWIntake.get();
const dc = s.intake_data?.dc_agent || {};
if (dc.choice === "own") {
(document.querySelector<HTMLInputElement>('input[value="own"]'))!.checked = true;
syncVisibility();
const g = (id: string) => document.getElementById(id) as HTMLInputElement;
if (dc.company) g("pw-dc-company").value = dc.company;
if (dc.street) g("pw-dc-street").value = dc.street;
if (dc.zip) g("pw-dc-zip").value = dc.zip;
if (dc.phone) g("pw-dc-phone").value = dc.phone;
}
});
// Persist on Next
window.addEventListener("pw:step-next", (evt: any) => {
const PW = (window as any).PWIntake;
if (PW.steps[PW.get().step_index] !== "dc_agent") return;
err.hidden = true;
const choice = (document.querySelector<HTMLInputElement>('input[name="pw_dc_agent_choice"]:checked'))?.value;
if (choice === "ours") {
PW.patchIntakeData({
dc_agent: {
choice: "ours",
company: "Northwest Registered Agent Service Inc.",
street: "1717 N Street NW STE 1",
city: "Washington",
state: "DC",
zip: "20036",
phone: "509-768-2249",
},
});
} else {
const g = (id: string) => (document.getElementById(id) as HTMLInputElement).value.trim();
const company = g("pw-dc-company");
const street = g("pw-dc-street");
const zip = g("pw-dc-zip");
if (!company || !street || !zip) {
err.textContent = "Agent company name, street address, and ZIP are required.";
err.hidden = false;
evt.preventDefault();
return;
}
PW.patchIntakeData({
dc_agent: {
choice: "own",
company,
street,
city: "Washington",
state: "DC",
zip,
phone: g("pw-dc-phone"),
},
});
}
});
</script>

View file

@ -0,0 +1,140 @@
---
// EarthStationStep — satellite license info + private-line circuit inventory.
// Shown when line_105_categories contains 'satellite' or 'private_line'.
---
<div class="pw-step">
<h2>Earth station / Private line details</h2>
<p class="pw-help">
Satellite and private-line services have specialized 499-A revenue
line treatment. We'll collect your earth-station licensing and/or
private-line circuit inventory here.
</p>
<section class="pw-block" id="pw-sat-section">
<h3>Satellite licensing</h3>
<label class="pw-field">Earth station FCC License IDs (one per line, IB-format)</label>
<textarea id="pw-sat-licenses" class="pw-input" rows="3" placeholder="e.g., E123456&#10;E123457"></textarea>
<div class="pw-row">
<div><label class="pw-field">Orbital slot</label>
<input id="pw-sat-slot" class="pw-input" placeholder="e.g., W 73.0" /></div>
<div><label class="pw-field">Satellite operator</label>
<input id="pw-sat-op" class="pw-input" placeholder="e.g., Intelsat" /></div>
<div><label class="pw-field">Service type</label>
<select id="pw-sat-type" class="pw-input">
<option value="">—</option>
<option value="FSS">FSS (fixed satellite)</option>
<option value="MSS">MSS (mobile satellite)</option>
<option value="BSS">BSS (broadcast satellite)</option>
</select></div>
</div>
<label class="pw-field">US MSS subscribers (if MSS)</label>
<input type="number" id="pw-sat-mss-subs" class="pw-input" min="0" />
</section>
<section class="pw-block">
<h3>Private line circuit inventory</h3>
<p class="pw-help">
One row per DS-1 / DS-3 / Ethernet circuit. Required for Lines
305.1, 305.2 (resold) and 416 (retail). You can paste a CSV or add
rows manually.
</p>
<div id="pw-pl-circuits"></div>
<button type="button" class="pw-btn-plain pw-btn" id="pw-add-circuit">+ Add circuit</button>
</section>
</div>
<style>
.pw-step h2 { margin: 0 0 0.5rem; color: #1a2744; }
.pw-step h3 { margin: 0 0 0.4rem; color: #1a2744; font-size: 1rem; }
.pw-help { color: #64748b; font-size: 0.9rem; margin-bottom: 1rem; }
.pw-block { padding: 1rem; border: 1px solid #e2e8f0; border-radius: 8px; margin-bottom: 0.75rem; }
.pw-field { display: block; font-weight: 600; color: #1f2937; margin: 0.4rem 0 0.15rem; font-size: 0.82rem; }
.pw-input { width: 100%; padding: 0.5rem 0.7rem; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 0.9rem; font-family: inherit; }
.pw-row { display: flex; gap: 1rem; flex-wrap: wrap; }
.pw-row > * { flex: 1 1 150px; }
.pw-btn { padding: 0.4rem 0.9rem; border: 0; border-radius: 6px; font-size: 0.85rem; cursor: pointer; margin-top: 0.3rem; }
.pw-btn-plain { background: #e2e8f0; color: #1f2937; }
.pw-btn-danger { background: #fee2e2; color: #991b1b; padding: 0.25rem 0.7rem; font-size: 0.78rem; }
.pw-circuit {
padding: 0.5rem; background: #f8fafc; border: 1px solid #e2e8f0;
border-radius: 6px; margin-bottom: 0.4rem;
display: grid; grid-template-columns: 1fr 1fr 1fr 140px 90px 90px; gap: 0.5rem;
}
</style>
<script>
const circuitsDiv = document.getElementById("pw-pl-circuits") as HTMLElement;
function newCircuitRow(init: any = {}) {
const row = document.createElement("div");
row.className = "pw-circuit";
row.innerHTML = `
<input data-f="endpoint_a_city" class="pw-input" placeholder="A: city, state" value="${init.endpoint_a_city || ""}" />
<input data-f="endpoint_b_city" class="pw-input" placeholder="B: city, state" value="${init.endpoint_b_city || ""}" />
<input data-f="bandwidth" class="pw-input" placeholder="e.g., DS-3, 10 GigE" value="${init.bandwidth || ""}" />
<input data-f="monthly_rev_usd" class="pw-input" placeholder="Monthly $ USD" type="number" step="0.01" value="${init.monthly_rev_usd || ""}" />
<label style="font-size:.75rem;">
<input type="checkbox" data-f="is_international" ${init.is_international ? "checked" : ""}/> Intl
</label>
<button type="button" class="pw-btn pw-btn-danger" data-remove="1">✕</button>
`;
row.querySelector<HTMLButtonElement>("[data-remove]")!.addEventListener("click", () => row.remove());
return row;
}
document.getElementById("pw-add-circuit")!.addEventListener("click", () => {
circuitsDiv.appendChild(newCircuitRow());
});
window.addEventListener("pw:step-shown", (evt: any) => {
if (evt.detail.step !== "earth_station") return;
const s = (window as any).PWIntake.get();
const sat = s.intake_data?.satellite_meta || {};
(document.getElementById("pw-sat-licenses") as HTMLTextAreaElement).value =
(sat.earth_station_license_ids || []).join("\n");
(document.getElementById("pw-sat-slot") as HTMLInputElement).value = sat.orbital_slot || "";
(document.getElementById("pw-sat-op") as HTMLInputElement).value = sat.satellite_operator || "";
(document.getElementById("pw-sat-type") as HTMLSelectElement).value = sat.service_type || "";
(document.getElementById("pw-sat-mss-subs") as HTMLInputElement).value = sat.mss_us_subscribers ?? "";
circuitsDiv.innerHTML = "";
for (const c of s.intake_data?.private_line_circuits || []) {
circuitsDiv.appendChild(newCircuitRow(c));
}
// Hide satellite section if this filer is only private_line, not satellite
const cats = s.intake_data?.line_105_categories || [];
const hasSat = cats.some((c: any) => c.id === "satellite" || c.id === "mobile_satellite");
const satSection = document.getElementById("pw-sat-section") as HTMLElement;
satSection.hidden = !hasSat;
});
window.addEventListener("pw:step-next", (evt: any) => {
const PW = (window as any).PWIntake;
if (PW.steps[PW.get().step_index] !== "earth_station") return;
const sat = {
earth_station_license_ids: (document.getElementById("pw-sat-licenses") as HTMLTextAreaElement).value
.split("\n").map((s) => s.trim()).filter(Boolean),
orbital_slot: (document.getElementById("pw-sat-slot") as HTMLInputElement).value.trim() || null,
satellite_operator: (document.getElementById("pw-sat-op") as HTMLInputElement).value.trim() || null,
service_type: (document.getElementById("pw-sat-type") as HTMLSelectElement).value || null,
mss_us_subscribers: Number((document.getElementById("pw-sat-mss-subs") as HTMLInputElement).value) || null,
};
const circuits: any[] = [];
for (const row of Array.from(circuitsDiv.querySelectorAll<HTMLElement>(".pw-circuit"))) {
const c: Record<string, any> = {};
for (const inp of Array.from(row.querySelectorAll<HTMLInputElement>("[data-f]"))) {
c[inp.getAttribute("data-f")!] = inp.type === "checkbox" ? inp.checked : inp.value;
}
if (!c.endpoint_a_city && !c.endpoint_b_city) continue;
c.monthly_rev_cents = Math.round((Number(c.monthly_rev_usd) || 0) * 100);
delete c.monthly_rev_usd;
circuits.push(c);
}
PW.patchIntakeData({
satellite_meta: sat,
private_line_circuits: circuits,
});
});
</script>

View file

@ -0,0 +1,449 @@
---
// EntityStep — collect or select the carrier identity. Also shown at
// the top: filing-mode selector (current / past-due / revise prior).
// On mount: fetches GET /api/v1/entities/telecom?email=... to offer pre-fill.
---
<div class="pw-step pw-step-entity">
<h2>Your carrier</h2>
<p class="pw-help">
Tell us about the entity we'll be filing for. If you've filed with us
before, pick your existing carrier from the list.
</p>
<fieldset class="pw-fieldset" id="pw-filing-mode-fs" hidden>
<legend>What are you filing?</legend>
<label class="pw-radio">
<input type="radio" name="pw-filing-mode" value="current" checked />
Current-year 499-A (annual filing, due April 1)
</label>
<label class="pw-radio">
<input type="radio" name="pw-filing-mode" value="past_due" />
Past-due filing for a prior year (late filing — expect penalties)
</label>
<label class="pw-radio">
<input type="radio" name="pw-filing-mode" value="revised" />
Revise a previously-filed 499-A (amendment)
</label>
<div id="pw-past-due-wrap" hidden>
<label class="pw-field">Reporting year (past-due)</label>
<input type="number" id="pw-past-due-year" min="2015" max="2035" class="pw-input" />
<div class="pw-help" id="pw-past-due-estimate"></div>
</div>
<div id="pw-revised-wrap" hidden>
<label class="pw-field">Which prior filing do you want to revise?</label>
<select id="pw-revised-prior" class="pw-input">
<option value="">— pick a prior filing —</option>
</select>
<label class="pw-field">What are you changing?</label>
<select id="pw-revised-reason" class="pw-input">
<option value="revenue">Revenue information</option>
<option value="registration">Registration / contact info</option>
<option value="both">Both</option>
</select>
</div>
</fieldset>
<div id="pw-entity-picker" hidden>
<label class="pw-field">Existing carrier on file</label>
<select id="pw-entity-select" class="pw-input">
<option value="">— New carrier —</option>
</select>
</div>
<label class="pw-field">Email address (yours)</label>
<input type="email" id="pw-email" class="pw-input" required />
<label class="pw-field">Your name</label>
<input type="text" id="pw-name" class="pw-input" required />
<label class="pw-field">Carrier legal name</label>
<input type="text" id="pw-legal-name" class="pw-input" required />
<label class="pw-field">DBA (if different)</label>
<input type="text" id="pw-dba" class="pw-input" />
<label class="pw-field">EIN</label>
<input type="text" id="pw-ein" class="pw-input" placeholder="12-3456789" />
<label class="pw-field">FCC Registration Number (FRN)</label>
<input type="text" id="pw-frn" class="pw-input" placeholder="10-digit (leave blank if applying via this order)" />
<label class="pw-field">USAC Filer ID (499)</label>
<input type="text" id="pw-filer-id" class="pw-input" />
<div class="pw-row">
<div>
<label class="pw-field">Entity structure</label>
<select id="pw-entity-structure" class="pw-input">
<option value="">Select…</option>
<option value="corp">Corporation (inc.)</option>
<option value="llc">LLC</option>
<option value="partnership">Partnership</option>
<option value="sole_prop">Sole proprietorship</option>
<option value="gov">Government entity</option>
<option value="nonprofit">Nonprofit / 501(c)</option>
<option value="other">Other</option>
</select>
</div>
<div style="display:none">
<label class="pw-field">Carrier category</label>
<select id="pw-carrier-category" class="pw-input">
<option value="">Select…</option>
<option value="interconnected_voip">Interconnected VoIP</option>
<option value="non_interconnected_voip">Non-Interconnected VoIP</option>
<option value="clec">CLEC</option>
<option value="ixc">Interexchange Carrier</option>
<option value="cmrs">CMRS / Wireless</option>
<option value="other">Other</option>
</select>
</div>
</div>
<fieldset class="pw-fieldset">
<legend>Affiliated filer (Line 106)</legend>
<p class="pw-help">If your company has affiliated filers (shared holding company), give the common name + holding company EIN. Must match Form 499-A Line 106 on every affiliate's filing.</p>
<div class="pw-row">
<div><label class="pw-field">Affiliated filer name (Line 106.1)</label>
<input type="text" id="pw-aff-name" class="pw-input" placeholder="(leave blank if no affiliates)" /></div>
<div><label class="pw-field">Holding company EIN (Line 106.2)</label>
<input type="text" id="pw-aff-ein" class="pw-input" placeholder="12-3456789" /></div>
</div>
</fieldset>
<label class="pw-field">Trade names / DBAs used in past 3 years (Line 112 — one per line)</label>
<textarea id="pw-trade-names" class="pw-input" rows="2" placeholder="Leave blank if only the legal name + DBA above"></textarea>
<fieldset class="pw-fieldset">
<legend>Principal address</legend>
<label class="pw-field">Street</label>
<input type="text" id="pw-addr-street" class="pw-input" />
<div class="pw-row">
<div><label class="pw-field">City</label>
<input type="text" id="pw-addr-city" class="pw-input" /></div>
<div><label class="pw-field">State</label>
<input type="text" id="pw-addr-state" class="pw-input" maxlength="2" /></div>
<div><label class="pw-field">ZIP</label>
<input type="text" id="pw-addr-zip" class="pw-input" maxlength="10" /></div>
</div>
</fieldset>
<div id="pw-entity-err" class="pw-err" hidden></div>
</div>
<style>
.pw-step h2 { margin: 0 0 0.5rem; color: #1a2744; }
.pw-help { color: #64748b; font-size: 0.9rem; margin-bottom: 1rem; }
.pw-field { display: block; font-weight: 600; color: #1f2937; margin: 1rem 0 0.25rem; font-size: 0.9rem; }
.pw-input { width: 100%; padding: 0.55rem 0.7rem; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 0.95rem; }
.pw-row { display: flex; gap: 1rem; flex-wrap: wrap; }
.pw-row > * { flex: 1 1 180px; }
.pw-fieldset { border: 1px solid #e2e8f0; border-radius: 8px; padding: 0.75rem 1rem 1rem; margin-top: 1.25rem; }
.pw-fieldset legend { font-weight: 600; color: #1a2744; padding: 0 0.5rem; }
.pw-err { color: #b91c1c; margin-top: 0.75rem; font-size: 0.9rem; }
</style>
<script>
const get = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
const INPUTS = {
email: get<HTMLInputElement>("pw-email"),
name: get<HTMLInputElement>("pw-name"),
legal_name: get<HTMLInputElement>("pw-legal-name"),
dba_name: get<HTMLInputElement>("pw-dba"),
ein: get<HTMLInputElement>("pw-ein"),
frn: get<HTMLInputElement>("pw-frn"),
filer_id_499: get<HTMLInputElement>("pw-filer-id"),
carrier_category: get<HTMLSelectElement>("pw-carrier-category"),
entity_structure: get<HTMLSelectElement>("pw-entity-structure"),
aff_name: get<HTMLInputElement>("pw-aff-name"),
aff_ein: get<HTMLInputElement>("pw-aff-ein"),
trade_names: get<HTMLTextAreaElement>("pw-trade-names"),
street: get<HTMLInputElement>("pw-addr-street"),
city: get<HTMLInputElement>("pw-addr-city"),
state: get<HTMLInputElement>("pw-addr-state"),
zip: get<HTMLInputElement>("pw-addr-zip"),
};
const picker = get<HTMLSelectElement>("pw-entity-select");
const pickerWrap = get<HTMLDivElement>("pw-entity-picker");
const err = get<HTMLDivElement>("pw-entity-err");
const fmFs = get<HTMLFieldSetElement>("pw-filing-mode-fs");
const pastDueWrap = get<HTMLDivElement>("pw-past-due-wrap");
const revisedWrap = get<HTMLDivElement>("pw-revised-wrap");
const pastDueYear = get<HTMLInputElement>("pw-past-due-year");
const pastDueEst = get<HTMLElement>("pw-past-due-estimate");
const revisedPrior = get<HTMLSelectElement>("pw-revised-prior");
const revisedReason = get<HTMLSelectElement>("pw-revised-reason");
// Show filing-mode fieldset only for 499-A family slugs
const FILING_MODE_SLUGS = new Set([
"fcc-499a", "fcc-499a-499q", "fcc-full-compliance",
]);
if (FILING_MODE_SLUGS.has((window as any).PWIntake?.slug || "")) {
fmFs.hidden = false;
}
function currentMode(): string {
const el = document.querySelector<HTMLInputElement>(
'input[name="pw-filing-mode"]:checked',
);
return el?.value || "current";
}
function syncFilingModeVisibility() {
const m = currentMode();
pastDueWrap.hidden = m !== "past_due";
revisedWrap.hidden = m !== "revised";
}
document.querySelectorAll<HTMLInputElement>('input[name="pw-filing-mode"]')
.forEach((r) => r.addEventListener("change", () => {
syncFilingModeVisibility();
if (currentMode() === "revised") loadPriorFilings();
}));
async function loadPriorFilings() {
const state = (window as any).PWIntake.get();
if (!state.telecom_entity_id) {
revisedPrior.innerHTML =
'<option value="">— save entity first, then revise —</option>';
return;
}
try {
const r = await fetch(`/api/v1/fcc/filings/entity/${state.telecom_entity_id}`);
if (!r.ok) return;
const data = await r.json();
revisedPrior.innerHTML = '<option value="">— pick a prior filing —</option>';
for (const f of (data.filings || [])) {
const yr = f.form_year_declared || new Date(f.created_at).getUTCFullYear();
const opt = document.createElement("option");
opt.value = f.order_number;
opt.textContent = `${f.order_number} (${f.service_slug}, year ${yr})`;
revisedPrior.appendChild(opt);
}
} catch {}
}
async function refreshPastDueEstimate() {
const year = Number(pastDueYear.value);
const state = (window as any).PWIntake.get();
const totalRev = Number(state.intake_data?.total_revenue_cents) || 0;
const interPct = Number(state.intake_data?.interstate_pct) || 0;
if (!year || !totalRev) {
pastDueEst.textContent = "";
return;
}
try {
const r = await fetch(
`/api/v1/fcc/late-filing-estimate?year=${year}&total_revenue_cents=${totalRev}&interstate_pct=${interPct}`,
);
if (!r.ok) {
const err = await r.json();
pastDueEst.textContent = `Estimator unavailable: ${err.error || r.status}`;
return;
}
const d = await r.json();
pastDueEst.innerHTML =
`Estimated retroactive USF owed for ${year}: <strong>$${(d.estimated_usf_cents/100).toLocaleString("en-US",{minimumFractionDigits:2})}</strong>` +
(d.estimated_interest_cents > 0 ? ` + ~$${(d.estimated_interest_cents/100).toLocaleString("en-US",{minimumFractionDigits:2})} interest (est.)` : "") +
`. USAC also may assess forfeitures separately.`;
} catch {
pastDueEst.textContent = "";
}
}
pastDueYear.addEventListener("change", refreshPastDueEstimate);
function intoInputs(entity: any) {
INPUTS.legal_name.value = entity?.legal_name || "";
INPUTS.dba_name.value = entity?.dba_name || "";
INPUTS.ein.value = entity?.ein || "";
INPUTS.frn.value = entity?.frn || "";
INPUTS.filer_id_499.value = entity?.filer_id_499 || "";
INPUTS.carrier_category.value = entity?.carrier_category || "";
INPUTS.entity_structure.value = entity?.entity_structure || "";
INPUTS.aff_name.value = entity?.affiliated_filer_name || "";
INPUTS.aff_ein.value = entity?.affiliated_filer_ein || "";
INPUTS.trade_names.value = (entity?.trade_names || []).join("\n");
INPUTS.street.value = entity?.address_street || "";
INPUTS.city.value = entity?.address_city || "";
INPUTS.state.value = entity?.address_state || "";
INPUTS.zip.value = entity?.address_zip || "";
}
async function loadExisting() {
const email = INPUTS.email.value.trim();
if (!email) return;
try {
const resp = await fetch(`/api/v1/entities/telecom?email=${encodeURIComponent(email)}`);
const data = await resp.json();
const entities = data.entities || [];
picker.innerHTML = '<option value="">— New carrier —</option>';
for (const e of entities) {
const opt = document.createElement("option");
opt.value = String(e.id);
opt.textContent = `${e.legal_name} ${e.frn ? "(FRN " + e.frn + ")" : ""}`;
picker.appendChild(opt);
}
pickerWrap.hidden = entities.length === 0;
} catch {}
}
picker.addEventListener("change", async () => {
const id = picker.value;
const PW = (window as any).PWIntake;
if (!id) {
PW.set({ telecom_entity_id: null });
intoInputs({});
return;
}
const email = INPUTS.email.value.trim();
const resp = await fetch(
`/api/v1/entities/telecom/${id}?email=${encodeURIComponent(email)}`,
);
const data = await resp.json();
intoInputs(data);
PW.set({ telecom_entity_id: Number(id), entity: data });
});
INPUTS.email.addEventListener("blur", loadExisting);
// When this step is shown, re-hydrate form from state
// Auto-fill entity from FRN query param (from compliance check tool)
async function autoFillFromFRN(frn: string) {
try {
const API = (window as any).__PW_API || "";
const r = await fetch(`${API}/api/v1/fcc/lookup?frn=${frn}`);
if (!r.ok) return;
const d = await r.json();
const entity: any = {};
// Map FCC lookup data to entity fields
entity.frn = d.frn || frn;
entity.legal_name = d.entity_name || "";
if (d.cores) {
entity.address_street = d.cores.address || "";
entity.address_city = d.cores.city || "";
entity.address_state = d.cores.state || "";
entity.address_zip = d.cores.zip || "";
}
if (d.filer_499) {
entity.filer_id_499 = d.filer_499.filer_id || "";
entity.dba_name = d.filer_499.trade_name || "";
if (!entity.legal_name) entity.legal_name = d.filer_499.legal_name || "";
}
if (d.rmd?.contact_name) {
entity.ceo_name = d.rmd.contact_name;
entity.contact_name = d.rmd.contact_name;
}
intoInputs(entity);
// Also try loading from our DB in case we have a richer record
const dbResp = await fetch(`${API}/api/v1/cdr/profile/by-entity/${frn}`).catch(() => null);
// Look up by FRN in telecom_entities
try {
const teResp = await fetch(`${API}/api/v1/entities/telecom?frn=${frn}`);
if (teResp.ok) {
const teData = await teResp.json();
const entities = teData.entities || [];
if (entities.length > 0) {
intoInputs(entities[0]); // DB record is richer, overwrite
const PW = (window as any).PWIntake;
PW.set({ telecom_entity_id: entities[0].id, entity: entities[0] });
}
}
} catch {}
} catch (e) {
console.warn("FRN auto-fill failed:", e);
}
}
window.addEventListener("pw:step-shown", (evt: any) => {
if (evt.detail.step !== "entity") return;
const state = (window as any).PWIntake.get();
INPUTS.email.value = state.email || "";
INPUTS.name.value = state.name || "";
intoInputs(state.entity || {});
if (state.email) loadExisting();
// Auto-fill from ?frn= URL param (first load only)
const urlFrn = new URLSearchParams(window.location.search).get("frn");
if (urlFrn && !state.entity?.frn) {
autoFillFromFRN(urlFrn);
}
// Restore filing mode
const mode = (state as any).filing_mode || "current";
const rb = document.querySelector<HTMLInputElement>(
`input[name="pw-filing-mode"][value="${mode}"]`,
);
if (rb) rb.checked = true;
syncFilingModeVisibility();
if ((state as any).form_year_override) {
pastDueYear.value = String((state as any).form_year_override);
refreshPastDueEstimate();
}
if (mode === "revised") loadPriorFilings();
});
// Gate advancing until required fields are filled
window.addEventListener("pw:step-next", (evt: any) => {
const active = (window as any).PWIntake.get();
if (active.step_index !== (window as any).PWIntake.steps.indexOf("entity")) return;
const missing: string[] = [];
if (!INPUTS.email.value.trim()) missing.push("email");
if (!INPUTS.name.value.trim()) missing.push("your name");
if (!INPUTS.legal_name.value.trim()) missing.push("legal name");
// Affiliated filer: name+EIN required together
const affName = INPUTS.aff_name.value.trim();
const affEin = INPUTS.aff_ein.value.trim();
if (affName && !affEin) {
missing.push("holding company EIN (required if affiliated filer name is given)");
}
if (missing.length) {
err.hidden = false;
err.textContent = `Required: ${missing.join(", ")}`;
evt.preventDefault();
return;
}
// Validate filing-mode fields
const mode = currentMode();
if (mode === "past_due" && !pastDueYear.value) {
missing.push("past-due reporting year");
}
if (mode === "revised" && !revisedPrior.value) {
missing.push("prior filing to revise");
}
if (missing.length) {
err.hidden = false;
err.textContent = `Required: ${missing.join(", ")}`;
evt.preventDefault();
return;
}
err.hidden = true;
const tradeNames = INPUTS.trade_names.value.split("\n")
.map((s) => s.trim()).filter(Boolean);
(window as any).PWIntake.set({
email: INPUTS.email.value.trim().toLowerCase(),
name: INPUTS.name.value.trim(),
filing_mode: mode,
form_year_override: mode === "past_due" ? Number(pastDueYear.value) : null,
revises_order_number: mode === "revised" ? revisedPrior.value : null,
revised_reason: mode === "revised" ? revisedReason.value : null,
entity: {
legal_name: INPUTS.legal_name.value.trim(),
dba_name: INPUTS.dba_name.value.trim(),
ein: INPUTS.ein.value.trim(),
frn: INPUTS.frn.value.trim(),
filer_id_499: INPUTS.filer_id_499.value.trim(),
carrier_category: INPUTS.carrier_category.value,
entity_structure: INPUTS.entity_structure.value,
affiliated_filer_name: affName || null,
affiliated_filer_ein: affEin || null,
trade_names: tradeNames,
address_street: INPUTS.street.value.trim(),
address_city: INPUTS.city.value.trim(),
address_state: INPUTS.state.value.trim().toUpperCase(),
address_zip: INPUTS.zip.value.trim(),
},
});
});
</script>

View file

@ -0,0 +1,87 @@
---
// ForeignCarrierStep — 47 CFR § 63.11 affiliation notification intake.
---
<div class="pw-step">
<h2>Foreign carrier affiliation</h2>
<p class="pw-help">
Per 47 CFR § 63.11, U.S. carriers affiliated with a foreign carrier
serving the same international route must notify the FCC. Tell us
about the affiliation below; we handle the filing.
</p>
<label class="pw-field">Foreign carrier legal name</label>
<input id="pw-fc-name" class="pw-input" />
<div class="pw-row">
<div><label class="pw-field">Country (ISO-2)</label>
<input id="pw-fc-country" class="pw-input" maxlength="2" placeholder="e.g. GB, MX, CA" /></div>
<div><label class="pw-field">Ownership %</label>
<input id="pw-fc-pct" class="pw-input" type="number" step="0.1" min="0" max="100" /></div>
<div><label class="pw-field">Affiliation date</label>
<input id="pw-fc-date" class="pw-input" type="date" /></div>
</div>
<label class="pw-field">Affected routes (comma-separated ISO-2 country codes)</label>
<input id="pw-fc-routes" class="pw-input" placeholder="e.g. GB, FR, DE" />
<label class="pw-field">Notification type</label>
<select id="pw-fc-type" class="pw-input">
<option value="post-closing">Post-closing (affiliation already in effect)</option>
<option value="pre-consummation">Pre-consummation (notifying before closing)</option>
</select>
<div id="pw-fc-err" class="pw-err" hidden></div>
</div>
<style>
.pw-step h2 { margin: 0 0 0.5rem; color: #1a2744; }
.pw-help { color: #64748b; font-size: 0.9rem; margin-bottom: 1rem; }
.pw-field { display: block; font-weight: 600; margin: 0.7rem 0 0.2rem; font-size: 0.88rem; }
.pw-input { width: 100%; padding: 0.5rem 0.7rem; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 0.93rem; }
.pw-row { display: flex; gap: 1rem; flex-wrap: wrap; }
.pw-row > * { flex: 1 1 150px; }
.pw-err { color: #b91c1c; margin-top: 0.75rem; font-size: 0.9rem; }
</style>
<script>
const g = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
const err = g<HTMLDivElement>("pw-fc-err");
window.addEventListener("pw:step-shown", (evt: any) => {
if (evt.detail.step !== "foreign_carrier") return;
const s = (window as any).PWIntake.get();
const f = s.intake_data?.foreign_carrier || {};
g<HTMLInputElement>("pw-fc-name").value = f.foreign_carrier_legal_name || "";
g<HTMLInputElement>("pw-fc-country").value = f.country || "";
g<HTMLInputElement>("pw-fc-pct").value = f.ownership_pct ?? "";
g<HTMLInputElement>("pw-fc-date").value = f.affiliation_date || "";
g<HTMLInputElement>("pw-fc-routes").value = (f.affected_routes || []).join(", ");
g<HTMLSelectElement>("pw-fc-type").value = f.notification_type || "post-closing";
});
window.addEventListener("pw:step-next", (evt: any) => {
const PW = (window as any).PWIntake;
if (PW.steps[PW.get().step_index] !== "foreign_carrier") return;
const name = g<HTMLInputElement>("pw-fc-name").value.trim();
const country = g<HTMLInputElement>("pw-fc-country").value.trim().toUpperCase();
const pct = parseFloat(g<HTMLInputElement>("pw-fc-pct").value);
const date = g<HTMLInputElement>("pw-fc-date").value;
const routes = g<HTMLInputElement>("pw-fc-routes").value
.split(",").map((s) => s.trim().toUpperCase()).filter(Boolean);
if (!name || !country || !pct || !date || routes.length === 0) {
err.hidden = false; err.textContent = "All fields required for 63.11 notification."; evt.preventDefault(); return;
}
err.hidden = true;
PW.patchIntakeData({
foreign_carrier: {
foreign_carrier_legal_name: name,
country: country,
ownership_pct: pct,
affiliation_date: date,
affected_routes: routes,
notification_type: g<HTMLSelectElement>("pw-fc-type").value,
},
});
});
</script>

View file

@ -0,0 +1,241 @@
---
// ForeignQualStep — multi-state COA picker with per-state fee preview.
// Calls GET /api/v1/foreign-qualification/jurisdictions to load the
// state catalog, then POST /quote to update totals in real-time.
---
<div class="pw-step">
<h2>Select target states for foreign qualification</h2>
<p class="pw-help">
Choose every state where your entity needs authorization to do business.
Each state charges its own Certificate of Authority fee. We also provision
a registered agent in each target state ($125/yr each).
</p>
<div class="pw-fq-controls">
<label class="pw-fq-et">
Entity type:
<select id="pw-fq-entity-type">
<option value="llc">LLC</option>
<option value="corporation">Corporation</option>
<option value="s_corp">S-Corp</option>
</select>
</label>
<label class="pw-fq-home">
Home state (where formed):
<input type="text" id="pw-fq-home-state" maxlength="2" placeholder="WY" style="width:50px;text-transform:uppercase" />
</label>
<label>
<input type="checkbox" id="pw-fq-ra" checked /> Include registered agent ($125/yr each)
</label>
<label>
<input type="checkbox" id="pw-fq-expedited" /> Expedited processing
</label>
</div>
<div class="pw-fq-actions">
<button type="button" id="pw-fq-select-all" class="pw-btn-plain pw-btn">Select all</button>
<button type="button" id="pw-fq-clear" class="pw-btn-plain pw-btn">Clear</button>
<span id="pw-fq-count">0 states selected</span>
</div>
<div id="pw-fq-grid" class="pw-fq-grid"></div>
<div id="pw-fq-quote" class="pw-fq-quote" hidden>
<table id="pw-fq-quote-table">
<thead>
<tr>
<th>State</th>
<th>State fee</th>
<th>RA</th>
<th>Service</th>
<th>Total</th>
</tr>
</thead>
<tbody id="pw-fq-quote-body"></tbody>
<tfoot>
<tr>
<th colspan="4" style="text-align:right">Grand total:</th>
<th id="pw-fq-grand-total">$0</th>
</tr>
</tfoot>
</table>
</div>
<div id="pw-fq-err" class="pw-err" hidden></div>
</div>
<style>
.pw-step h2 { margin: 0 0 0.5rem; color: #1a2744; }
.pw-help { color: #64748b; font-size: 0.9rem; margin-bottom: 1rem; }
.pw-fq-controls { display: flex; flex-wrap: wrap; gap: 1rem; align-items: center; margin-bottom: 1rem; }
.pw-fq-controls label { font-size: 0.85rem; color: #475569; display: flex; align-items: center; gap: 0.35rem; }
.pw-fq-controls select, .pw-fq-controls input[type="text"] { padding: 0.3rem 0.5rem; border: 1px solid #cbd5e1; border-radius: 4px; }
.pw-fq-actions { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 0.75rem; }
.pw-btn { padding: 0.4rem 0.9rem; border: 0; border-radius: 6px; font-size: 0.85rem; cursor: pointer; }
.pw-btn-plain { background: #e2e8f0; color: #1f2937; }
#pw-fq-count { color: #475569; font-size: 0.85rem; margin-left: 0.5rem; }
.pw-fq-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 0.5rem; }
.pw-fq-grid label { display: flex; align-items: center; gap: 0.35rem; font-size: 0.85rem;
padding: 0.4rem 0.6rem; border: 1px solid #e2e8f0; border-radius: 6px; cursor: pointer; }
.pw-fq-grid label:has(input:checked) { background: #e0f2fe; border-color: #38bdf8; }
.pw-fq-grid .pw-fq-fee { color: #64748b; font-size: 0.75rem; margin-left: auto; }
.pw-fq-quote { margin-top: 1rem; }
.pw-fq-quote table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
.pw-fq-quote th, .pw-fq-quote td { padding: 0.35rem 0.5rem; border-bottom: 1px solid #e2e8f0; text-align: right; }
.pw-fq-quote th:first-child, .pw-fq-quote td:first-child { text-align: left; }
.pw-fq-quote thead th { background: #f8fafc; font-size: 0.75rem; text-transform: uppercase; color: #475569; }
.pw-fq-quote tfoot th { background: #f1f5f9; font-size: 0.95rem; }
.pw-err { color: #b91c1c; margin-top: 0.75rem; font-size: 0.9rem; }
</style>
<script>
const grid = document.getElementById("pw-fq-grid")!;
const countEl = document.getElementById("pw-fq-count")!;
const quoteDiv = document.getElementById("pw-fq-quote") as HTMLElement;
const quoteBody = document.getElementById("pw-fq-quote-body")!;
const grandEl = document.getElementById("pw-fq-grand-total")!;
const err = document.getElementById("pw-fq-err") as HTMLDivElement;
const entityTypeEl = document.getElementById("pw-fq-entity-type") as HTMLSelectElement;
const homeStateEl = document.getElementById("pw-fq-home-state") as HTMLInputElement;
const raEl = document.getElementById("pw-fq-ra") as HTMLInputElement;
const expEl = document.getElementById("pw-fq-expedited") as HTMLInputElement;
interface JurisdictionRow {
code: string; name: string; foreign_llc_fee: number | null;
foreign_corp_fee: number | null; publication_required: boolean;
typical_processing_days: number | null;
}
let jurisdictions: JurisdictionRow[] = [];
async function loadJurisdictions() {
try {
const r = await fetch("/api/v1/foreign-qualification/jurisdictions");
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const d = await r.json();
jurisdictions = d.jurisdictions || [];
renderGrid();
} catch (e) {
err.hidden = false;
err.textContent = `Failed to load jurisdictions: ${(e as Error).message}`;
}
}
function renderGrid() {
grid.innerHTML = "";
for (const j of jurisdictions) {
const et = entityTypeEl.value;
const fee = et === "llc" || et === "pllc" ? j.foreign_llc_fee : j.foreign_corp_fee;
const lbl = document.createElement("label");
lbl.innerHTML =
`<input type="checkbox" data-code="${j.code}" />` +
`<span>${j.code} — ${j.name}</span>` +
`<span class="pw-fq-fee">${fee != null ? "$" + (fee / 100).toFixed(0) : "—"}</span>`;
grid.appendChild(lbl);
}
grid.querySelectorAll("input").forEach(cb => cb.addEventListener("change", updateCount));
}
function getSelected(): string[] {
return Array.from(grid.querySelectorAll<HTMLInputElement>("input:checked"))
.map(cb => cb.dataset.code!);
}
function updateCount() {
const sel = getSelected();
countEl.textContent = `${sel.length} state${sel.length === 1 ? "" : "s"} selected`;
if (sel.length > 0) fetchQuote(sel);
else { quoteDiv.hidden = true; }
}
async function fetchQuote(states: string[]) {
try {
const r = await fetch("/api/v1/foreign-qualification/quote", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
home_state_code: homeStateEl.value.trim().toUpperCase() || "WY",
entity_type: entityTypeEl.value,
target_states: states,
include_ra_each: raEl.checked,
expedited: expEl.checked,
}),
});
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const d = await r.json();
renderQuote(d);
} catch (e) {
err.hidden = false;
err.textContent = `Quote failed: ${(e as Error).message}`;
}
}
function renderQuote(d: any) {
quoteBody.innerHTML = "";
for (const it of (d.items || [])) {
if (it.error) continue;
const tr = document.createElement("tr");
tr.innerHTML =
`<td>${it.state_code} — ${it.state_name}</td>` +
`<td>$${(it.state_fee_cents / 100).toFixed(0)}</td>` +
`<td>$${(it.nwra_ra_fee_cents / 100).toFixed(0)}</td>` +
`<td>$${(it.service_fee_cents / 100).toFixed(0)}</td>` +
`<td><strong>$${(it.total_cents / 100).toFixed(2)}</strong></td>`;
quoteBody.appendChild(tr);
}
grandEl.textContent = `$${(d.grand_total_cents / 100).toLocaleString("en-US", { minimumFractionDigits: 2 })}`;
quoteDiv.hidden = false;
err.hidden = true;
}
document.getElementById("pw-fq-select-all")!.addEventListener("click", () => {
grid.querySelectorAll<HTMLInputElement>("input").forEach(cb => cb.checked = true);
updateCount();
});
document.getElementById("pw-fq-clear")!.addEventListener("click", () => {
grid.querySelectorAll<HTMLInputElement>("input").forEach(cb => cb.checked = false);
updateCount();
});
entityTypeEl.addEventListener("change", () => { renderGrid(); updateCount(); });
raEl.addEventListener("change", () => { const s = getSelected(); if (s.length) fetchQuote(s); });
expEl.addEventListener("change", () => { const s = getSelected(); if (s.length) fetchQuote(s); });
// Prefill from wizard state.
window.addEventListener("pw:step-shown", (evt: any) => {
if (evt.detail.step !== "foreign_qual") return;
const s = (window as any).PWIntake.get();
if (s.entity?.address_state) homeStateEl.value = s.entity.address_state;
if (s.entity?.entity_type) entityTypeEl.value = s.entity.entity_type;
if (s.intake_data?.target_states) {
const prev = s.intake_data.target_states as string[];
grid.querySelectorAll<HTMLInputElement>("input").forEach(cb => {
if (prev.includes(cb.dataset.code!)) cb.checked = true;
});
updateCount();
}
});
// Persist into wizard state on Next.
window.addEventListener("pw:step-next", (evt: any) => {
const PW = (window as any).PWIntake;
if (PW.steps[PW.get().step_index] !== "foreign_qual") return;
const sel = getSelected();
if (sel.length === 0) {
err.hidden = false;
err.textContent = "Select at least one target state.";
evt.preventDefault();
return;
}
err.hidden = true;
PW.patchIntakeData({
home_state_code: homeStateEl.value.trim().toUpperCase(),
entity_type: entityTypeEl.value,
target_states: sel,
include_ra_each: raEl.checked,
expedited: expEl.checked,
});
});
loadJurisdictions();
</script>

View file

@ -0,0 +1,67 @@
---
// HistoryStep — Line 228: first-service year + month (or pre-1999 checkbox).
// Split out from JurisdictionStep for the full 499-A flow.
---
<div class="pw-step">
<h2>First service date</h2>
<p class="pw-help">
What year and month did you first provide telecommunications service?
FCC Form 499-A Line 228. If you began before 1/1/1999, check the
pre-1999 box and leave year/month blank.
</p>
<div class="pw-row">
<div>
<label class="pw-field">Year</label>
<input type="number" id="pw-hist-year" min="1985" max="2030" class="pw-input" />
</div>
<div>
<label class="pw-field">Month</label>
<input type="number" id="pw-hist-month" min="1" max="12" class="pw-input" />
</div>
<div style="display:flex; align-items:end; gap: 0.5rem;">
<input type="checkbox" id="pw-hist-pre1999" />
<label for="pw-hist-pre1999" style="font-size:.9rem;">Before 1/1/1999</label>
</div>
</div>
<div id="pw-hist-err" class="pw-err" hidden></div>
</div>
<style>
.pw-step h2 { margin: 0 0 0.5rem; color: #1a2744; }
.pw-help { color: #64748b; font-size: 0.9rem; margin-bottom: 1rem; }
.pw-field { display: block; font-weight: 600; color: #1f2937; margin: 0.6rem 0 0.2rem; font-size: 0.88rem; }
.pw-input { width: 100%; padding: 0.5rem 0.7rem; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 0.93rem; }
.pw-row { display: flex; gap: 1rem; flex-wrap: wrap; }
.pw-row > * { flex: 1 1 140px; }
.pw-err { color: #b91c1c; margin-top: 0.75rem; font-size: 0.9rem; }
</style>
<script>
const yearI = document.getElementById("pw-hist-year") as HTMLInputElement;
const monthI = document.getElementById("pw-hist-month") as HTMLInputElement;
const preI = document.getElementById("pw-hist-pre1999") as HTMLInputElement;
const err = document.getElementById("pw-hist-err") as HTMLDivElement;
window.addEventListener("pw:step-shown", (evt: any) => {
if (evt.detail.step !== "history") return;
const s = (window as any).PWIntake.get();
yearI.value = s.intake_data?.first_telecom_service_year || "";
monthI.value = s.intake_data?.first_telecom_service_month || "";
preI.checked = Boolean(s.intake_data?.first_telecom_service_pre_1999);
});
window.addEventListener("pw:step-next", (evt: any) => {
const PW = (window as any).PWIntake;
if (PW.steps[PW.get().step_index] !== "history") return;
if (!preI.checked && (!yearI.value || !monthI.value)) {
err.hidden = false; err.textContent = "Enter year + month or check Pre-1999."; evt.preventDefault(); return;
}
err.hidden = true;
PW.patchIntakeData({
first_telecom_service_year: preI.checked ? null : Number(yearI.value),
first_telecom_service_month: preI.checked ? null : Number(monthI.value),
first_telecom_service_pre_1999: preI.checked,
});
});
</script>

View file

@ -0,0 +1,197 @@
---
// IccImportStep — upload carrier invoice files (CABS BOS, EDI 810,
// iconectiv 8YY, international settlement, wholesale SIP CSV). Rows are
// parsed by the icc_ingester worker; RevenueStep pre-fills Line 404 /
// 404.1 / 404.3 / 418 from the summary.
---
<div class="pw-step">
<h2>Inter-Carrier Compensation revenue (optional)</h2>
<p class="pw-help">
Drop your carrier invoices (CABS BOS, EDI 810, iconectiv 8YY query
reports, international settlement statements, Sangoma / Bandwidth /
Flowroute CSVs) and we'll populate your Form 499-A Line 404 / 404.1 /
404.3 / 418 revenues automatically. Skip if you don't have ICC
revenue — you can enter totals manually on the revenue step.
</p>
<div id="pw-icc-drop" class="pw-icc-drop">
<p>Drop files here or click to browse</p>
<input type="file" id="pw-icc-file" multiple accept=".bos,.810,.edi,.x12,.qry,.xml,.csv,.tsv,.pdf,.tas,.icss" style="display:none;" />
<button type="button" class="pw-btn-plain pw-btn" id="pw-icc-browse">Choose files</button>
</div>
<div id="pw-icc-uploads"></div>
<div id="pw-icc-paywall-notices"></div>
<h3 style="margin-top:1rem;">Summary</h3>
<table class="pw-icc-summary">
<thead><tr><th>499-A Line</th><th>ICC Category</th><th>Revenue</th></tr></thead>
<tbody id="pw-icc-summary-body"><tr><td colspan="3" style="text-align:center; color:#64748b;">No ICC revenue imported yet.</td></tr></tbody>
<tfoot><tr><th colspan="2" style="text-align:right;">Total:</th><th id="pw-icc-total">$0.00</th></tr></tfoot>
</table>
<label style="display:block; margin-top:1rem;">
<input type="checkbox" id="pw-icc-confirm" />
Use these imported totals to pre-fill my 499-A revenue lines
</label>
</div>
<style>
.pw-step h2 { margin: 0 0 0.5rem; color: #1a2744; }
.pw-step h3 { margin: 0.6rem 0 0.4rem; color: #1a2744; font-size: 1rem; }
.pw-help { color: #64748b; font-size: 0.9rem; margin-bottom: 1rem; }
.pw-icc-drop {
border: 2px dashed #cbd5e1; background: #f8fafc;
border-radius: 8px; padding: 1.5rem; text-align: center;
margin-bottom: 1rem; cursor: pointer;
}
.pw-icc-drop.dragover { background: #e0f2fe; border-color: #38bdf8; }
.pw-icc-drop p { margin: 0 0 0.5rem; color: #475569; }
.pw-btn { padding: 0.5rem 1.2rem; border: 0; border-radius: 6px; font-size: 0.9rem; cursor: pointer; }
.pw-btn-plain { background: #e2e8f0; color: #1f2937; }
.pw-icc-upload {
display: flex; align-items: center; gap: 0.5rem;
padding: 0.4rem 0.75rem; background: #fff; border: 1px solid #e2e8f0;
border-radius: 6px; margin-bottom: 0.35rem; font-size: 0.85rem;
}
.pw-icc-upload[data-status="pending"] { border-left: 4px solid #eab308; }
.pw-icc-upload[data-status="complete"] { border-left: 4px solid #059669; }
.pw-icc-upload[data-status="failed"] { border-left: 4px solid #dc2626; }
.pw-icc-summary { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
.pw-icc-summary th, .pw-icc-summary td { padding: 0.4rem 0.6rem; border-bottom: 1px solid #e2e8f0; text-align: left; }
.pw-icc-summary thead th { background: #f8fafc; font-size: 0.78rem; color: #475569; }
.pw-icc-summary tfoot th { background: #f1f5f9; }
.pw-paywall-notice {
background: #fef3c7; color: #92400e;
padding: 0.6rem 0.85rem; border-left: 3px solid #d97706;
border-radius: 0 6px 6px 0; margin: 0.5rem 0;
font-size: 0.88rem;
}
</style>
<script>
const drop = document.getElementById("pw-icc-drop") as HTMLElement;
const fileInput = document.getElementById("pw-icc-file") as HTMLInputElement;
const browse = document.getElementById("pw-icc-browse") as HTMLButtonElement;
const uploadsDiv = document.getElementById("pw-icc-uploads") as HTMLElement;
const summaryBody = document.getElementById("pw-icc-summary-body") as HTMLElement;
const totalCell = document.getElementById("pw-icc-total") as HTMLElement;
const confirmCb = document.getElementById("pw-icc-confirm") as HTMLInputElement;
browse.addEventListener("click", () => fileInput.click());
drop.addEventListener("click", (e) => { if (e.target === drop) fileInput.click(); });
drop.addEventListener("dragover", (e) => { e.preventDefault(); drop.classList.add("dragover"); });
drop.addEventListener("dragleave", () => drop.classList.remove("dragover"));
drop.addEventListener("drop", (e) => {
e.preventDefault(); drop.classList.remove("dragover");
if (e.dataTransfer?.files) handleFiles(e.dataTransfer.files);
});
fileInput.addEventListener("change", () => {
if (fileInput.files) handleFiles(fileInput.files);
});
async function getProfileId(): Promise<number | null> {
const s = (window as any).PWIntake.get();
if (!s.telecom_entity_id) return null;
try {
const r = await fetch(`/api/v1/cdr/profile/by-entity/${s.telecom_entity_id}`);
if (!r.ok) return null;
const j = await r.json();
return j.profile_id || null;
} catch { return null; }
}
async function handleFiles(files: FileList) {
const profile_id = await getProfileId();
if (!profile_id) {
alert("No CDR profile found for this entity. Complete the CDR Analysis service first, or skip this step."); return;
}
for (const f of Array.from(files)) {
const div = document.createElement("div");
div.className = "pw-icc-upload";
div.dataset.status = "pending";
div.innerHTML = `<span>${f.name} (${(f.size/1024).toFixed(0)} KB)</span> <span data-status>Uploading…</span>`;
uploadsDiv.appendChild(div);
try {
const tokR = await fetch("/api/v1/icc/upload-token", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ profile_id, file_name: f.name }),
});
if (!tokR.ok) throw new Error(`upload token: ${tokR.status}`);
const tok = await tokR.json();
// In prod the tok.minio_key would be a presigned PUT URL;
// placeholder just records the upload row and the ingester
// worker picks it up. Real MinIO upload belongs in a helper.
div.querySelector<HTMLElement>("[data-status]")!.textContent = "Queued for parsing";
div.dataset.status = "pending";
} catch (e) {
div.dataset.status = "failed";
div.querySelector<HTMLElement>("[data-status]")!.textContent = `Failed: ${(e as Error).message}`;
}
}
setTimeout(refreshSummary, 500);
}
async function refreshSummary() {
const profile_id = await getProfileId();
if (!profile_id) return;
const s = (window as any).PWIntake.get();
const year = s.intake_data?.form_year || new Date().getUTCFullYear() - 1;
try {
const r = await fetch(`/api/v1/icc/profile/${profile_id}/summary?year=${year}`);
if (!r.ok) return;
const data = await r.json();
summaryBody.innerHTML = "";
if (data.categories.length === 0) {
summaryBody.innerHTML = `<tr><td colspan="3" style="text-align:center; color:#64748b;">No ICC revenue imported yet.</td></tr>`;
} else {
for (const c of data.categories) {
const tr = document.createElement("tr");
tr.innerHTML = `<td><strong>Line ${c.form_499a_line}</strong></td>
<td>${c.icc_category}</td>
<td>$${(c.revenue_cents/100).toLocaleString("en-US", {minimumFractionDigits:2})}</td>`;
summaryBody.appendChild(tr);
}
}
totalCell.textContent = `$${(data.grand_total_cents/100).toLocaleString("en-US", {minimumFractionDigits:2})}`;
} catch {}
// Pull per-upload summary_json to surface paywall drops
try {
const rr = await fetch(`/api/v1/icc/profile/${profile_id}/uploads`);
if (!rr.ok) return;
const up = await rr.json();
const notices: string[] = [];
for (const u of (up.uploads || [])) {
const notice = u.summary_json?.customer_notice;
if (notice && u.summary_json?.rows_dropped_unpaid_years > 0) {
notices.push(notice);
}
}
const host = document.getElementById("pw-icc-paywall-notices") as HTMLElement;
if (notices.length === 0) {
host.innerHTML = "";
} else {
const unique = Array.from(new Set(notices));
host.innerHTML = unique.map((n) => `<div class="pw-paywall-notice">⚠ ${n}</div>`).join("");
}
} catch {}
}
window.addEventListener("pw:step-shown", (evt: any) => {
if (evt.detail.step !== "icc_import") return;
const s = (window as any).PWIntake.get();
confirmCb.checked = !!s.intake_data?.icc_revenue_source;
refreshSummary();
});
window.addEventListener("pw:step-next", (evt: any) => {
const PW = (window as any).PWIntake;
if (PW.steps[PW.get().step_index] !== "icc_import") return;
PW.patchIntakeData({
icc_revenue_source: confirmCb.checked ? "imported" : null,
});
});
</script>

View file

@ -0,0 +1,268 @@
---
// JurisdictionStep — Line 227 multi-select of states/territories served.
// First-service date (Line 228) moved to its own HistoryStep.
//
// After state selection, runs a foreign qualification check against
// entity_cache. Shows states where no foreign corp registration was
// found with opt-in checkboxes to add foreign qual filing to the order.
---
<div class="pw-step">
<h2>Jurisdictions served (Line 227)</h2>
<p class="pw-help">
Check every state and US territory where you have provided
telecommunications service in the past 15 months — plus any where
you expect to provide service in the next 12 months. For switched
services, include origination states; for called-party-pays
services, also include termination states.
</p>
<div id="pw-states" class="pw-states-grid"></div>
<!-- Foreign Qualification Check Results -->
<div id="pw-fq-section" class="fq-section" hidden>
<div id="pw-fq-loading" class="fq-loading" hidden>
<span class="fq-spinner"></span> Checking corporate registrations in selected states...
</div>
<div id="pw-fq-results" hidden>
<div class="fq-header">
<h3>Corporate Registration Check</h3>
<p class="fq-desc">
We checked each state you serve for a foreign corporation registration.
States marked below may require a Certificate of Authority filing.
</p>
</div>
<div id="pw-fq-missing" class="fq-missing-list"></div>
<div id="pw-fq-ok" class="fq-ok-summary"></div>
</div>
</div>
<div id="pw-jx-err" class="pw-err" hidden></div>
</div>
<style>
.pw-step h2 { margin: 0 0 0.5rem; color: #1a2744; }
.pw-help { color: #64748b; font-size: 0.9rem; margin-bottom: 1rem; }
.pw-states-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
gap: 0.35rem; padding: 0.75rem; background: #f8fafc;
border: 1px solid #e2e8f0; border-radius: 6px;
}
.pw-states-grid label { font-size: 0.85rem; color: #334155; }
.pw-err { color: #b91c1c; margin-top: 0.75rem; font-size: 0.9rem; }
/* Foreign qualification check styles */
.fq-section { margin-top: 1.25rem; }
.fq-loading {
padding: 0.75rem; background: #f8fafc; border: 1px solid #e2e8f0;
border-radius: 8px; font-size: 0.88rem; color: #6b7280;
display: flex; align-items: center; gap: 0.5rem;
}
.fq-spinner {
width: 16px; height: 16px; border: 2px solid #d1d5db;
border-top-color: #1e3a5f; border-radius: 50%;
animation: spin 0.6s linear infinite; display: inline-block;
}
@keyframes spin { to { transform: rotate(360deg); } }
.fq-header h3 { font-size: 0.95rem; color: #1a2744; margin-bottom: 0.25rem; }
.fq-desc { font-size: 0.82rem; color: #6b7280; margin-bottom: 0.75rem; }
.fq-missing-list { display: flex; flex-direction: column; gap: 0.4rem; }
.fq-row {
display: flex; align-items: center; gap: 0.6rem;
padding: 0.55rem 0.75rem; border: 1px solid #fbbf24;
background: #fffbeb; border-radius: 8px; font-size: 0.85rem;
}
.fq-row input { accent-color: #1e3a5f; width: 16px; height: 16px; flex-shrink: 0; }
.fq-row .fq-state { font-weight: 700; color: #92400e; min-width: 28px; }
.fq-row .fq-reason { flex: 1; color: #78350f; font-size: 0.82rem; }
.fq-row .fq-price { font-weight: 600; color: #1e3a5f; white-space: nowrap; }
.fq-ok-summary {
margin-top: 0.5rem; padding: 0.5rem 0.75rem;
background: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 8px;
font-size: 0.82rem; color: #166534;
}
.fq-total {
margin-top: 0.6rem; padding: 0.5rem 0.75rem;
background: #f0f4f8; border-radius: 8px;
font-size: 0.88rem; font-weight: 600; color: #1e3a5f;
display: flex; justify-content: space-between;
}
</style>
<script>
const STATES = [
"AL","AK","AS","AZ","AR","CA","CO","CT","DE","DC","FL","GA","GU","HI",
"ID","IL","IN","IA","KS","KY","LA","ME","MD","MA","MI","MN","MS","MO",
"MT","NE","NV","NH","NJ","NM","NY","NC","ND","MP","OH","OK","OR","PA",
"PR","RI","SC","SD","TN","TX","UT","VT","VA","VI","WA","WV","WI","WY",
];
const grid = document.getElementById("pw-states")!;
STATES.forEach((s) => {
const lbl = document.createElement("label");
lbl.innerHTML = `<input type="checkbox" value="${s}" data-state="1"/> ${s}`;
grid.appendChild(lbl);
});
const err = document.getElementById("pw-jx-err") as HTMLDivElement;
const fqSection = document.getElementById("pw-fq-section") as HTMLDivElement;
const fqLoading = document.getElementById("pw-fq-loading") as HTMLDivElement;
const fqResults = document.getElementById("pw-fq-results") as HTMLDivElement;
const fqMissing = document.getElementById("pw-fq-missing") as HTMLDivElement;
const fqOk = document.getElementById("pw-fq-ok") as HTMLDivElement;
// Determine API base URL
const API = (() => {
const h = window.location.hostname;
if (h === "localhost" || h === "127.0.0.1") return "http://" + h + ":3001";
if (h === "dev.performancewest.net") return "https://api.dev.performancewest.net";
return "https://api.performancewest.net";
})();
let fqCheckResults: any[] = [];
let fqServiceFee = 9900; // $99/state
async function runForeignQualCheck(entityName: string, homeState: string, states: string[]) {
// Only check US states (2-letter, no territories like AS, GU, MP, PR, VI)
const usStates = states.filter((s) => s.length === 2 && !["AS","GU","MP","PR","VI"].includes(s));
if (!entityName || usStates.length === 0) {
fqSection.hidden = true;
return;
}
fqSection.hidden = false;
fqLoading.hidden = false;
fqResults.hidden = true;
try {
const resp = await fetch(`${API}/api/v1/corp/foreign-qual-check`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ entity_name: entityName, home_state: homeState, states: usStates }),
});
const data = await resp.json();
fqCheckResults = data.results || [];
fqServiceFee = data.foreign_qual_service_fee_cents || 9900;
renderFQResults();
} catch {
fqSection.hidden = true;
} finally {
fqLoading.hidden = true;
}
}
function renderFQResults() {
const missing = fqCheckResults.filter((r: any) => r.needs_foreign_qual);
const ok = fqCheckResults.filter((r: any) => !r.needs_foreign_qual);
if (missing.length === 0) {
fqResults.hidden = false;
fqMissing.innerHTML = "";
fqOk.innerHTML = `All ${fqCheckResults.length} state(s) checked — corporate registrations found and active in every state.`;
return;
}
fqResults.hidden = false;
// Render missing states with opt-in checkboxes
fqMissing.innerHTML = missing.map((r: any) => `
<label class="fq-row">
<input type="checkbox" data-fq-state="${r.state_code}" checked>
<span class="fq-state">${r.state_code}</span>
<span class="fq-reason">${r.reason}</span>
<span class="fq-price">$${(fqServiceFee / 100).toFixed(0)}/state</span>
</label>
`).join("");
// Add total line
const totalEl = document.createElement("div");
totalEl.className = "fq-total";
totalEl.id = "fq-total-line";
fqMissing.appendChild(totalEl);
updateFQTotal();
// Wire up checkbox changes
fqMissing.querySelectorAll<HTMLInputElement>("input[data-fq-state]").forEach((cb) => {
cb.addEventListener("change", updateFQTotal);
});
if (ok.length > 0) {
fqOk.innerHTML = `${ok.length} state(s) have active registrations.`;
} else {
fqOk.innerHTML = "";
}
}
function updateFQTotal() {
const checked = fqMissing.querySelectorAll<HTMLInputElement>("input[data-fq-state]:checked");
const total = checked.length * fqServiceFee;
const totalEl = document.getElementById("fq-total-line");
if (totalEl) {
totalEl.innerHTML = `<span>Foreign qualification: ${checked.length} state(s)</span><span>+$${(total / 100).toLocaleString()}</span>`;
}
}
function getSelectedFQStates(): string[] {
return Array.from(fqMissing.querySelectorAll<HTMLInputElement>("input[data-fq-state]:checked"))
.map((cb) => cb.dataset.fqState!);
}
// Restore state
window.addEventListener("pw:step-shown", (evt: any) => {
if (evt.detail.step !== "jurisdiction") return;
const s = (window as any).PWIntake.get();
const set = new Set<string>(s.intake_data?.jurisdictions_served || s.entity?.jurisdictions_served || []);
grid.querySelectorAll<HTMLInputElement>("input[data-state='1']").forEach((cb) => {
cb.checked = set.has(cb.value);
});
// If states already selected, run the check
if (set.size > 0) {
const entityName = s.entity?.legal_name || s.intake_data?.entity_legal_name || "";
const homeState = s.entity?.state_of_formation || s.entity?.address_state || s.intake_data?.state_of_formation || "";
runForeignQualCheck(entityName, homeState, Array.from(set));
}
});
// On "Check Registrations" or when user changes state selection, debounce the check
let checkTimer: ReturnType<typeof setTimeout> | null = null;
grid.addEventListener("change", () => {
if (checkTimer) clearTimeout(checkTimer);
checkTimer = setTimeout(() => {
const PW = (window as any).PWIntake;
if (!PW) return;
const s = PW.get();
const selected = Array.from(grid.querySelectorAll<HTMLInputElement>("input[data-state='1']:checked")).map((cb) => cb.value);
if (selected.length > 0) {
const entityName = s.entity?.legal_name || s.intake_data?.entity_legal_name || "";
const homeState = s.entity?.state_of_formation || s.entity?.address_state || s.intake_data?.state_of_formation || "";
runForeignQualCheck(entityName, homeState, selected);
} else {
fqSection.hidden = true;
}
}, 800); // Debounce 800ms
});
// Save on step-next
window.addEventListener("pw:step-next", (evt: any) => {
const PW = (window as any).PWIntake;
if (PW.steps[PW.get().step_index] !== "jurisdiction") return;
const selected = Array.from(
grid.querySelectorAll<HTMLInputElement>("input[data-state='1']:checked"),
).map((cb) => cb.value);
if (selected.length === 0) {
err.hidden = false; err.textContent = "Pick at least one state/territory."; evt.preventDefault(); return;
}
err.hidden = true;
// Save jurisdictions + foreign qual selections
const fqStates = getSelectedFQStates();
PW.patchIntakeData({
jurisdictions_served: selected,
foreign_qual_add_on_states: fqStates.length > 0 ? fqStates : undefined,
});
const st = PW.get();
PW.set({ entity: { ...st.entity, jurisdictions_served: selected } });
});
</script>

View file

@ -0,0 +1,176 @@
---
// LNPARegionStep — Block 5 Lines 503-510 LNPA region percentages.
// 10 regions × 2 columns (Block 3 resale / Block 4 end-user).
// Each column must sum to 100.00% or 0%.
import { LNPA_REGIONS } from "../../../lib/lnpa_regions_catalog";
---
<div class="pw-step">
<h2>Revenue by LNPA region (Block 5)</h2>
<p class="pw-help">
Distribute your telecommunications revenue across the 10 Local Number
Portability Administration regions. Each column (Block 3 resale /
Block 4 end-user) must sum to exactly 100.00% — or 0 if you had no
revenue in that block. Feeds FCC Form 499-A Lines 503-510.
</p>
<div class="pw-lnpa-helper">
<button type="button" class="pw-btn-plain pw-btn" id="pw-lnpa-even">Distribute evenly</button>
<button type="button" class="pw-btn-plain pw-btn" id="pw-lnpa-from-billing">Pull from billing address distribution (CDR)</button>
</div>
<table class="pw-lnpa-table">
<thead>
<tr>
<th>Region</th>
<th>Description</th>
<th>Block 3 %<br>(resale)</th>
<th>Block 4 %<br>(end-user)</th>
</tr>
</thead>
<tbody id="pw-lnpa-body"></tbody>
<tfoot>
<tr>
<th colspan="2" style="text-align:right;">Sum:</th>
<th id="pw-lnpa-b3-sum">0.00%</th>
<th id="pw-lnpa-b4-sum">0.00%</th>
</tr>
</tfoot>
</table>
<div id="pw-lnpa-err" class="pw-err" hidden></div>
</div>
<style>
.pw-step h2 { margin: 0 0 0.5rem; color: #1a2744; }
.pw-help { color: #64748b; font-size: 0.9rem; margin-bottom: 1rem; }
.pw-lnpa-helper { display: flex; gap: 0.5rem; margin-bottom: 0.75rem; }
.pw-btn { padding: 0.4rem 0.9rem; border: 0; border-radius: 6px; font-size: 0.85rem; cursor: pointer; }
.pw-btn-plain { background: #e2e8f0; color: #1f2937; }
.pw-lnpa-table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
.pw-lnpa-table th, .pw-lnpa-table td {
padding: 0.4rem 0.6rem; border-bottom: 1px solid #e2e8f0;
text-align: left; vertical-align: middle;
}
.pw-lnpa-table thead th { background: #f8fafc; font-size: 0.78rem; color: #475569; text-transform: uppercase; }
.pw-lnpa-table input { width: 80px; padding: 0.3rem 0.5rem; border: 1px solid #cbd5e1; border-radius: 4px; text-align: right; }
.pw-lnpa-table tfoot th { background: #f1f5f9; }
.pw-lnpa-table tfoot th[id$="-sum"][data-invalid="1"] { color: #b91c1c; }
.pw-err { color: #b91c1c; margin-top: 0.75rem; font-size: 0.9rem; }
</style>
<script>
import { LNPA_REGIONS } from "../../../lib/lnpa_regions_catalog";
const body = document.getElementById("pw-lnpa-body")!;
const b3Sum = document.getElementById("pw-lnpa-b3-sum")!;
const b4Sum = document.getElementById("pw-lnpa-b4-sum")!;
const err = document.getElementById("pw-lnpa-err") as HTMLDivElement;
for (const r of LNPA_REGIONS) {
const row = document.createElement("tr");
row.innerHTML = `
<td><strong>${r.code}</strong> — ${r.label}</td>
<td style="font-size:.78rem; color:#64748b;">${r.description}</td>
<td><input type="number" step="0.01" min="0" max="100" data-b3="${r.code}" /></td>
<td><input type="number" step="0.01" min="0" max="100" data-b4="${r.code}" /></td>
`;
body.appendChild(row);
}
function recalc() {
let s3 = 0, s4 = 0;
body.querySelectorAll<HTMLInputElement>("input[data-b3]").forEach((i) => s3 += Number(i.value) || 0);
body.querySelectorAll<HTMLInputElement>("input[data-b4]").forEach((i) => s4 += Number(i.value) || 0);
b3Sum.textContent = s3.toFixed(2) + "%";
b4Sum.textContent = s4.toFixed(2) + "%";
b3Sum.setAttribute("data-invalid", (Math.abs(s3 - 100) > 0.01 && s3 !== 0) ? "1" : "0");
b4Sum.setAttribute("data-invalid", (Math.abs(s4 - 100) > 0.01 && s4 !== 0) ? "1" : "0");
return { s3, s4 };
}
body.addEventListener("input", recalc);
document.getElementById("pw-lnpa-even")!.addEventListener("click", () => {
const pct = 100 / LNPA_REGIONS.length;
body.querySelectorAll<HTMLInputElement>("input[data-b3]").forEach((i) => i.value = pct.toFixed(2));
body.querySelectorAll<HTMLInputElement>("input[data-b4]").forEach((i) => i.value = pct.toFixed(2));
recalc();
});
document.getElementById("pw-lnpa-from-billing")!.addEventListener("click", async () => {
const s = (window as any).PWIntake.get();
if (!s.telecom_entity_id) {
err.hidden = false; err.textContent = "No telecom entity selected — can't pull billing distribution."; return;
}
try {
// Call the CDR study endpoint to get billing_state_regions_json, then convert
const profResp = await fetch(`/api/v1/cdr/profile/by-entity/${s.telecom_entity_id}`);
if (!profResp.ok) throw new Error("no CDR profile");
const { profile_id } = await profResp.json();
const year = s.intake_data?.form_year || new Date().getUTCFullYear() - 1;
const sResp = await fetch(`/api/v1/cdr/profile/${profile_id}/study?year=${year}`);
if (!sResp.ok) throw new Error("no study");
const study = await sResp.json();
const regions = study.classified_report?.billing_state_regions || {};
// regions is { "NE": 0.12, "MA": 0.08, ... } — apply to both columns
for (const r of LNPA_REGIONS) {
const pct = (regions[r.code] || 0) * 100;
const b3 = body.querySelector<HTMLInputElement>(`input[data-b3="${r.code}"]`)!;
const b4 = body.querySelector<HTMLInputElement>(`input[data-b4="${r.code}"]`)!;
b3.value = pct.toFixed(2);
b4.value = pct.toFixed(2);
}
recalc();
err.hidden = true;
} catch (e) {
err.hidden = false; err.textContent = `Could not pull billing distribution: ${(e as Error).message}`;
}
});
window.addEventListener("pw:step-shown", (evt: any) => {
if (evt.detail.step !== "lnpa_region") return;
const s = (window as any).PWIntake.get();
const existing = s.intake_data?.lnpa_region_allocations || [];
for (const a of existing) {
const b3 = body.querySelector<HTMLInputElement>(`input[data-b3="${a.region_code}"]`);
const b4 = body.querySelector<HTMLInputElement>(`input[data-b4="${a.region_code}"]`);
if (b3) b3.value = String(a.block_3_pct);
if (b4) b4.value = String(a.block_4_pct);
}
recalc();
});
window.addEventListener("pw:step-next", (evt: any) => {
const PW = (window as any).PWIntake;
if (PW.steps[PW.get().step_index] !== "lnpa_region") return;
const { s3, s4 } = recalc();
if (Math.abs(s3 - 100) > 0.01 && s3 !== 0) {
err.hidden = false; err.textContent = `Block 3 column sums to ${s3.toFixed(2)}% — must be 100.00% or 0.`; evt.preventDefault(); return;
}
if (Math.abs(s4 - 100) > 0.01 && s4 !== 0) {
err.hidden = false; err.textContent = `Block 4 column sums to ${s4.toFixed(2)}% — must be 100.00% or 0.`; evt.preventDefault(); return;
}
err.hidden = true;
const allocations: Array<{region_code: string; block_3_pct: number; block_4_pct: number}> = [];
for (const r of LNPA_REGIONS) {
const b3 = Number(body.querySelector<HTMLInputElement>(`input[data-b3="${r.code}"]`)?.value) || 0;
const b4 = Number(body.querySelector<HTMLInputElement>(`input[data-b4="${r.code}"]`)?.value) || 0;
allocations.push({ region_code: r.code, block_3_pct: b3, block_4_pct: b4 });
}
PW.patchIntakeData({ lnpa_region_allocations: allocations });
// Also PUT to the API so the /validate endpoint finds the rows
const st = PW.get();
if (st.telecom_entity_id) {
fetch(`/api/v1/lnpa-regions/entity/${st.telecom_entity_id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
reporting_year: st.intake_data?.form_year || new Date().getUTCFullYear() - 1,
reporting_period: "annual",
allocations,
}),
}).catch(() => {});
}
});
</script>

View file

@ -0,0 +1,94 @@
---
// OCNStep — NECA Company Code request parameters.
---
<div class="pw-step">
<h2>NECA OCN details</h2>
<p class="pw-help">
We submit the NECA Company Code Request Form on your behalf. Pick the
category that best matches your service type — IPES covers most VoIP.
</p>
<label class="pw-field">Service category</label>
<select id="pw-ocn-category" class="pw-input">
<option value="IPES">IPES — Internet Protocol Enabled Services (VoIP)</option>
<option value="CLEC">CLEC — Competitive Local Exchange Carrier</option>
<option value="ULEC">ULEC — Unbundled Local Exchange Carrier</option>
<option value="CAP">CAP — Competitive Access Provider</option>
<option value="IC">IC — Interexchange Carrier</option>
<option value="LRSL">LRSL — Local Exchange Reseller</option>
<option value="PCS">PCS — Personal Communications Service</option>
<option value="PCSR">PCSR — PCS Reseller</option>
<option value="WIRE">WIRE — Wireless Carrier</option>
<option value="WRSL">WRSL — Wireless Reseller</option>
<option value="ETHX">ETHX — Ethernet Exchange</option>
</select>
<label class="pw-field">Operating states (comma-separated, required only for CLEC/ULEC)</label>
<input id="pw-ocn-states" class="pw-input" placeholder="CA, NY, TX" />
<label><input type="checkbox" id="pw-ocn-expedited" /> Expedited processing (+$125, 5 business days instead of 10)</label>
<div style="margin-top:1.25rem;padding:1rem;background:#fefce8;border:1px solid #fde68a;border-radius:8px;">
<label class="pw-field" style="margin-top:0">Do you have an existing tandem services agreement with a provider?</label>
<p style="font-size:.8rem;color:#92400e;margin:.25rem 0 .5rem">A tandem services agreement (or interconnection agreement) with an existing CLEC or LEC is required for NECA to process your OCN application. If you don't have one, we can arrange a sponsoring CLEC agreement for an additional $2,000.</p>
<div style="display:flex;gap:.75rem">
<label style="display:flex;align-items:center;gap:.4rem;padding:.5rem .75rem;border:1px solid #d1d5db;border-radius:6px;cursor:pointer;font-size:.9rem">
<input type="radio" name="pw-tandem" id="pw-tandem-yes" value="yes"> Yes, I have one
</label>
<label style="display:flex;align-items:center;gap:.4rem;padding:.5rem .75rem;border:1px solid #d1d5db;border-radius:6px;cursor:pointer;font-size:.9rem">
<input type="radio" name="pw-tandem" id="pw-tandem-no" value="no"> No, I need a sponsoring CLEC (+$2,000)
</label>
</div>
</div>
<div style="margin-top:1rem;padding:.75rem 1rem;background:#f0fdf4;border:1px solid #86efac;border-radius:8px">
<p style="margin:0;font-size:.85rem;color:#166534;line-height:1.5">
<strong>Price: $650</strong> (includes $550 NECA filing fee + $100 service fee). One-time cost — no annual fees.
<span id="pw-tandem-addon" style="display:none"><br><strong>+ $2,000</strong> sponsoring CLEC agreement</span>
</p>
</div>
</div>
<style>
.pw-step h2 { margin: 0 0 0.5rem; color: #1a2744; }
.pw-help { color: #64748b; font-size: 0.9rem; margin-bottom: 1rem; }
.pw-field { display: block; font-weight: 600; margin: 0.7rem 0 0.2rem; font-size: 0.88rem; }
.pw-input { width: 100%; padding: 0.5rem 0.7rem; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 0.93rem; }
</style>
<script>
const g = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
// Show/hide tandem addon price
document.querySelectorAll("input[name='pw-tandem']").forEach((r) => {
r.addEventListener("change", () => {
const addon = document.getElementById("pw-tandem-addon");
if (addon) addon.style.display = (document.getElementById("pw-tandem-no") as HTMLInputElement)?.checked ? "inline" : "none";
});
});
window.addEventListener("pw:step-shown", (evt: any) => {
if (evt.detail.step !== "ocn") return;
const s = (window as any).PWIntake.get();
g<HTMLSelectElement>("pw-ocn-category").value = s.intake_data?.service_category || "IPES";
g<HTMLInputElement>("pw-ocn-states").value = (s.intake_data?.operating_states || []).join(", ");
g<HTMLInputElement>("pw-ocn-expedited").checked = Boolean(s.intake_data?.expedited);
if (s.intake_data?.needs_sponsoring_clec) {
g<HTMLInputElement>("pw-tandem-no").checked = true;
const addon = document.getElementById("pw-tandem-addon");
if (addon) addon.style.display = "inline";
} else if (s.intake_data?.has_tandem_agreement) {
g<HTMLInputElement>("pw-tandem-yes").checked = true;
}
});
window.addEventListener("pw:step-next", (evt: any) => {
const PW = (window as any).PWIntake;
if (PW.steps[PW.get().step_index] !== "ocn") return;
const needsClec = g<HTMLInputElement>("pw-tandem-no")?.checked;
PW.patchIntakeData({
service_category: g<HTMLSelectElement>("pw-ocn-category").value,
operating_states: g<HTMLInputElement>("pw-ocn-states").value
.split(",").map((s) => s.trim().toUpperCase()).filter(Boolean),
expedited: g<HTMLInputElement>("pw-ocn-expedited").checked,
has_tandem_agreement: g<HTMLInputElement>("pw-tandem-yes")?.checked || false,
needs_sponsoring_clec: needsClec || false,
});
});
</script>

View file

@ -0,0 +1,200 @@
---
// OfficerStep — CEO + 2nd/3rd officers with business addresses
// per 2026 FCC Form 499-A Block 2-C (Lines 219-226).
//
// Corporate/LLC: require 3 officers (or explain fewer). Sole proprietor:
// 1 is fine. Partnership: managing partner + 2 with greatest financial
// interest. Entity structure drives the required count.
---
<div class="pw-step">
<h2>Officers</h2>
<p class="pw-help">
Lines 219-226 of Form 499-A require name, title, and business address
for up to 3 officers. Entity structure drives how many we collect —
a sole proprietor gives 1; a corporation gives 3.
</p>
<label class="pw-field">How many officers will you list?</label>
<select id="pw-officer-count" class="pw-input">
<option value="1">1 — sole proprietor / single officer</option>
<option value="2">2 officers</option>
<option value="3" selected>3 officers (standard for corp / LLC)</option>
</select>
<fieldset class="pw-fieldset">
<legend>Officer 1 — CEO / highest-ranking</legend>
<div class="pw-row">
<div><label class="pw-field">Name</label><input type="text" id="pw-o1-n" class="pw-input" required /></div>
<div><label class="pw-field">Title</label><input type="text" id="pw-o1-t" class="pw-input" value="Chief Executive Officer" /></div>
</div>
<div class="pw-row">
<div><label class="pw-field">Email</label><input type="email" id="pw-o1-e" class="pw-input" required /></div>
<div><label class="pw-field">Phone</label><input type="tel" id="pw-o1-p" class="pw-input" /></div>
</div>
<label class="pw-field">Business street</label>
<input type="text" id="pw-o1-street" class="pw-input" />
<div class="pw-row">
<div><label class="pw-field">City</label><input type="text" id="pw-o1-city" class="pw-input" /></div>
<div><label class="pw-field">State</label><input type="text" id="pw-o1-state" class="pw-input" maxlength="2" /></div>
<div><label class="pw-field">ZIP</label><input type="text" id="pw-o1-zip" class="pw-input" maxlength="10" /></div>
</div>
</fieldset>
<fieldset class="pw-fieldset" id="pw-o2-wrap">
<legend>Officer 2</legend>
<div class="pw-row">
<div><label class="pw-field">Name</label><input type="text" id="pw-o2-n" class="pw-input" /></div>
<div><label class="pw-field">Title</label><input type="text" id="pw-o2-t" class="pw-input" /></div>
</div>
<label class="pw-field">Business street</label>
<input type="text" id="pw-o2-street" class="pw-input" />
<div class="pw-row">
<div><label class="pw-field">City</label><input type="text" id="pw-o2-city" class="pw-input" /></div>
<div><label class="pw-field">State</label><input type="text" id="pw-o2-state" class="pw-input" maxlength="2" /></div>
<div><label class="pw-field">ZIP</label><input type="text" id="pw-o2-zip" class="pw-input" maxlength="10" /></div>
</div>
</fieldset>
<fieldset class="pw-fieldset" id="pw-o3-wrap">
<legend>Officer 3</legend>
<div class="pw-row">
<div><label class="pw-field">Name</label><input type="text" id="pw-o3-n" class="pw-input" /></div>
<div><label class="pw-field">Title</label><input type="text" id="pw-o3-t" class="pw-input" /></div>
</div>
<label class="pw-field">Business street</label>
<input type="text" id="pw-o3-street" class="pw-input" />
<div class="pw-row">
<div><label class="pw-field">City</label><input type="text" id="pw-o3-city" class="pw-input" /></div>
<div><label class="pw-field">State</label><input type="text" id="pw-o3-state" class="pw-input" maxlength="2" /></div>
<div><label class="pw-field">ZIP</label><input type="text" id="pw-o3-zip" class="pw-input" maxlength="10" /></div>
</div>
</fieldset>
<div id="pw-officer-err" class="pw-err" hidden></div>
</div>
<style>
.pw-step h2 { margin: 0 0 0.5rem; color: #1a2744; }
.pw-help { color: #64748b; font-size: 0.9rem; margin-bottom: 1rem; }
.pw-field { display: block; font-weight: 600; color: #1f2937; margin: 0.4rem 0 0.15rem; font-size: 0.85rem; }
.pw-input { width: 100%; padding: 0.5rem 0.7rem; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 0.92rem; }
.pw-fieldset { border: 1px solid #e2e8f0; border-radius: 8px; padding: 0.75rem 1rem 1rem; margin: 1rem 0; }
.pw-fieldset legend { font-weight: 600; color: #1a2744; padding: 0 0.5rem; }
.pw-row { display: flex; gap: 0.75rem; flex-wrap: wrap; }
.pw-row > * { flex: 1 1 140px; }
.pw-err { color: #b91c1c; margin-top: 0.75rem; font-size: 0.9rem; }
</style>
<script>
const g = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
const count = g<HTMLSelectElement>("pw-officer-count");
const o2wrap = g<HTMLElement>("pw-o2-wrap");
const o3wrap = g<HTMLElement>("pw-o3-wrap");
const err = g<HTMLDivElement>("pw-officer-err");
function syncCount() {
const n = Number(count.value);
o2wrap.hidden = n < 2;
o3wrap.hidden = n < 3;
}
count.addEventListener("change", syncCount);
function officerFields(i: number) {
return {
n: g<HTMLInputElement>(`pw-o${i}-n`),
t: g<HTMLInputElement>(`pw-o${i}-t`),
e: i === 1 ? g<HTMLInputElement>("pw-o1-e") : null,
p: i === 1 ? g<HTMLInputElement>("pw-o1-p") : null,
street: g<HTMLInputElement>(`pw-o${i}-street`),
city: g<HTMLInputElement>(`pw-o${i}-city`),
state: g<HTMLInputElement>(`pw-o${i}-state`),
zip: g<HTMLInputElement>(`pw-o${i}-zip`),
};
}
window.addEventListener("pw:step-shown", (evt: any) => {
if (evt.detail.step !== "officer") return;
const s = (window as any).PWIntake.get();
const o = s.officers || [{}, {}, {}];
const structure = s.entity?.entity_structure;
const existingCount = s.entity?.officer_count_claimed;
count.value = String(existingCount || (structure === "sole_prop" ? 1 : structure === "partnership" ? 3 : 3));
syncCount();
for (let i = 1; i <= 3; i++) {
const f = officerFields(i);
const oc = o[i - 1] || {};
f.n.value = oc.name || (i === 1 ? (s.entity?.ceo_name || "") : "");
f.t.value = oc.title || (i === 1 ? (s.entity?.ceo_title || "Chief Executive Officer") : "");
if (f.e) f.e.value = oc.email || "";
if (f.p) f.p.value = oc.phone || "";
f.street.value = oc.street || (i === 1 ? (s.entity?.address_street || "") : "");
f.city.value = oc.city || (i === 1 ? (s.entity?.address_city || "") : "");
f.state.value = oc.state || (i === 1 ? (s.entity?.address_state || "") : "");
f.zip.value = oc.zip || (i === 1 ? (s.entity?.address_zip || "") : "");
}
});
window.addEventListener("pw:step-next", (evt: any) => {
const PW = (window as any).PWIntake;
if (PW.steps[PW.get().step_index] !== "officer") return;
const n = Number(count.value);
const officers: any[] = [];
const missing: string[] = [];
for (let i = 1; i <= n; i++) {
const f = officerFields(i);
if (!f.n.value.trim()) missing.push(`Officer ${i} name`);
if (!f.t.value.trim()) missing.push(`Officer ${i} title`);
// Address required for 499-A flows (not enforced for sole_prop)
if (!f.street.value.trim()) missing.push(`Officer ${i} street`);
if (!f.city.value.trim()) missing.push(`Officer ${i} city`);
officers.push({
name: f.n.value.trim(), title: f.t.value.trim(),
email: f.e?.value.trim() || "", phone: f.p?.value.trim() || "",
street: f.street.value.trim(), city: f.city.value.trim(),
state: f.state.value.trim().toUpperCase(), zip: f.zip.value.trim(),
});
}
if (missing.length) {
err.hidden = false; err.textContent = `Required: ${missing.join(", ")}`;
evt.preventDefault(); return;
}
err.hidden = true;
const s = PW.get();
PW.set({
officers,
entity: {
...s.entity,
ceo_name: officers[0].name,
ceo_title: officers[0].title,
contact_name: officers[0].name,
contact_email: officers[0].email,
contact_phone: officers[0].phone,
officer_1_street: officers[0].street,
officer_1_city: officers[0].city,
officer_1_state: officers[0].state,
officer_1_zip: officers[0].zip,
officer_2_name: officers[1]?.name || null,
officer_2_title: officers[1]?.title || null,
officer_2_street: officers[1]?.street || null,
officer_2_city: officers[1]?.city || null,
officer_2_state: officers[1]?.state || null,
officer_2_zip: officers[1]?.zip || null,
officer_3_name: officers[2]?.name || null,
officer_3_title: officers[2]?.title || null,
officer_3_street: officers[2]?.street || null,
officer_3_city: officers[2]?.city || null,
officer_3_state: officers[2]?.state || null,
officer_3_zip: officers[2]?.zip || null,
officer_count_claimed: n,
},
});
PW.patchIntakeData({
officer: officers[0],
officer_2: officers[1] || null,
officer_3: officers[2] || null,
officer_count_claimed: n,
});
});
</script>

View file

@ -0,0 +1,84 @@
---
// PaymentStep — hand off to the existing Stripe Checkout flow.
export interface Props { service_slug: string; }
const { service_slug } = Astro.props;
import { SERVICE_META, formatUSD } from "../../../lib/intake_manifest";
const meta = SERVICE_META[service_slug];
---
<div class="pw-step" data-slug={service_slug}>
<h2>Payment</h2>
<p class="pw-help">
You'll be redirected to our secure payment processor to complete the order.
After payment, the filing handler runs automatically. If admin review is
enabled on your account, you'll see the packet in your portal first.
</p>
<div class="pw-total-box">
<div class="pw-total-line">
<span>Service</span><strong>{meta?.name ?? service_slug}</strong>
</div>
<div class="pw-total-line">
<span>Total</span><strong>{meta ? formatUSD(meta.price_cents) : "—"}</strong>
</div>
</div>
<label class="pw-field">Payment method</label>
<select id="pw-pay-method" class="pw-input">
<option value="card">Credit / Debit card</option>
<option value="ach">ACH (US bank)</option>
<option value="paypal">PayPal</option>
<option value="klarna">Klarna (pay over time)</option>
<option value="crypto">Cryptocurrency</option>
</select>
<button type="button" id="pw-pay-go" class="pw-btn">Continue to payment →</button>
<div id="pw-pay-err" class="pw-err" hidden></div>
</div>
<style>
.pw-step h2 { margin: 0 0 0.5rem; color: #1a2744; }
.pw-help { color: #64748b; font-size: 0.9rem; margin-bottom: 1rem; }
.pw-total-box { background: #ecfdf5; border-left: 4px solid #059669; padding: 1rem 1.25rem; border-radius: 0 8px 8px 0; margin-bottom: 1.5rem; }
.pw-total-line { display: flex; justify-content: space-between; padding: 0.25rem 0; font-size: 1rem; }
.pw-field { display: block; font-weight: 600; margin: 0.6rem 0 0.2rem; font-size: 0.88rem; }
.pw-input { width: 100%; padding: 0.5rem 0.7rem; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 0.93rem; }
.pw-btn { padding: 0.75rem 2rem; border: 0; border-radius: 6px; background: #059669; color: #fff; font-weight: 600; cursor: pointer; font-size: 1rem; margin-top: 1.5rem; }
.pw-err { color: #b91c1c; margin-top: 0.75rem; font-size: 0.9rem; }
</style>
<script>
const slug = document.querySelector(".pw-step[data-slug]")!.getAttribute("data-slug")!;
const methodSel = document.getElementById("pw-pay-method") as HTMLSelectElement;
const goBtn = document.getElementById("pw-pay-go") as HTMLButtonElement;
const err = document.getElementById("pw-pay-err") as HTMLDivElement;
goBtn.addEventListener("click", async () => {
err.hidden = true;
goBtn.disabled = true; goBtn.textContent = "Creating checkout session…";
try {
const state = (window as any).PWIntake.get();
if (!state.order_number) throw new Error("Order was not created; go back to Review.");
const resp = await fetch("/api/v1/checkout/create-session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
order_id: state.order_number,
order_type: "compliance",
payment_method: methodSel.value,
}),
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const { checkout_url } = await resp.json();
if (!checkout_url) throw new Error("No checkout URL returned.");
// Clear the wizard state — success page handles the rest
sessionStorage.removeItem(`pw-intake-${slug}`);
window.location.href = checkout_url;
} catch (e: any) {
err.hidden = false;
err.textContent = "Could not start checkout: " + e.message;
goBtn.disabled = false;
goBtn.textContent = "Continue to payment →";
}
});
</script>

View file

@ -0,0 +1,193 @@
---
// ResellerCertStep — collect annually-signed reseller certifications
// for customers whose revenue lands on Line 303 (carrier's carrier).
// Per 2026 FCC Form 499-A Section IV.C.4, these certifications are
// MANDATORY to claim Line 303 revenue.
//
// Also collects non-contributing reseller records (Line 511).
import { RESELLER_CERTIFICATION_SAMPLE_TEXT } from "../../../lib/fcc_constants";
---
<div class="pw-step">
<h2>Reseller certifications (Line 303)</h2>
<p class="pw-help">
FCC Section IV.C.4 requires an annually-signed certification from each
reseller customer whose revenue you report on Line 303. The
certification attests they're buying for resale AND that they (or a
downstream entity) contribute to USF. Upload signed PDFs or record
the attestation details below.
</p>
<section class="pw-block">
<h3>Contributing resellers (Line 303)</h3>
<div id="pw-resellers-list"></div>
<button type="button" class="pw-btn-plain pw-btn" id="pw-add-reseller">+ Add reseller</button>
</section>
<section class="pw-block">
<h3>Non-contributing resellers (Line 511)</h3>
<p class="pw-help">
Customers that are de minimis, international-only, or government
entities — report their revenue separately so TRS/NANPA/LNP/ITSP
bases exclude it.
</p>
<div id="pw-nc-resellers-list"></div>
<button type="button" class="pw-btn-plain pw-btn" id="pw-add-nc-reseller">+ Add non-contributing reseller</button>
</section>
<details>
<summary>Sample FCC certification language</summary>
<p class="pw-cert-text" id="pw-sample-text"></p>
</details>
<div id="pw-rc-err" class="pw-err" hidden></div>
</div>
<style>
.pw-step h2 { margin: 0 0 0.5rem; color: #1a2744; }
.pw-step h3 { margin: 0 0 0.4rem; color: #1a2744; font-size: 1rem; }
.pw-block { padding: 1rem; border: 1px solid #e2e8f0; border-radius: 8px; margin-bottom: 0.75rem; }
.pw-help { color: #64748b; font-size: 0.85rem; margin-bottom: 0.75rem; }
.pw-reseller {
padding: 0.75rem; background: #f8fafc; border: 1px solid #e2e8f0;
border-radius: 6px; margin-bottom: 0.5rem;
}
.pw-reseller-row { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; margin-bottom: 0.35rem; }
.pw-field { display: block; font-weight: 600; color: #1f2937; margin: 0.2rem 0 0.1rem; font-size: 0.82rem; }
.pw-input { width: 100%; padding: 0.4rem 0.6rem; border: 1px solid #cbd5e1; border-radius: 4px; font-size: 0.88rem; font-family: inherit; }
.pw-btn { padding: 0.4rem 0.9rem; border: 0; border-radius: 6px; font-size: 0.85rem; cursor: pointer; margin-top: 0.3rem; }
.pw-btn-plain { background: #e2e8f0; color: #1f2937; }
.pw-btn-danger { background: #fee2e2; color: #991b1b; padding: 0.25rem 0.7rem; font-size: 0.78rem; }
.pw-cert-text { font-size: 0.82rem; color: #475569; background: #f8fafc; padding: 0.5rem; border-radius: 6px; }
.pw-err { color: #b91c1c; margin-top: 0.75rem; font-size: 0.9rem; }
details { border: 1px solid #e2e8f0; border-radius: 8px; padding: 0.5rem 0.75rem; margin-bottom: 0.75rem; }
details > summary { cursor: pointer; font-weight: 600; font-size: 0.88rem; color: #1a2744; }
</style>
<script>
import { RESELLER_CERTIFICATION_SAMPLE_TEXT } from "../../../lib/fcc_constants";
(document.getElementById("pw-sample-text") as HTMLElement).textContent
= RESELLER_CERTIFICATION_SAMPLE_TEXT;
const list = document.getElementById("pw-resellers-list") as HTMLElement;
const ncList = document.getElementById("pw-nc-resellers-list") as HTMLElement;
const err = document.getElementById("pw-rc-err") as HTMLDivElement;
function newResellerRow(init: any = {}) {
const row = document.createElement("div");
row.className = "pw-reseller";
row.innerHTML = `
<div class="pw-reseller-row">
<div><label class="pw-field">Reseller legal name</label>
<input data-f="reseller_legal_name" class="pw-input" value="${init.reseller_legal_name || ""}" /></div>
<div><label class="pw-field">USAC Filer ID (6-8 digits)</label>
<input data-f="reseller_filer_id_499" class="pw-input" value="${init.reseller_filer_id_499 || ""}" /></div>
</div>
<div class="pw-reseller-row">
<div><label class="pw-field">Contact name</label>
<input data-f="reseller_contact_name" class="pw-input" value="${init.reseller_contact_name || ""}" /></div>
<div><label class="pw-field">Contact email</label>
<input data-f="reseller_contact_email" class="pw-input" value="${init.reseller_contact_email || ""}" /></div>
</div>
<div class="pw-reseller-row">
<div><label class="pw-field">Certification date</label>
<input type="date" data-f="certification_date" class="pw-input" value="${init.certification_date || ""}" /></div>
<div><label class="pw-field">Signer name + title</label>
<input data-f="signer_combo" class="pw-input" value="${init.signer_combo || ""}" /></div>
</div>
<button type="button" class="pw-btn pw-btn-danger" data-remove="1">Remove</button>
`;
row.querySelector<HTMLButtonElement>("[data-remove]")!.addEventListener("click", () => row.remove());
return row;
}
function newNcResellerRow(init: any = {}) {
const row = document.createElement("div");
row.className = "pw-reseller";
row.innerHTML = `
<div class="pw-reseller-row">
<div><label class="pw-field">Reseller legal name</label>
<input data-f="reseller_legal_name" class="pw-input" value="${init.reseller_legal_name || ""}" /></div>
<div><label class="pw-field">USAC Filer ID</label>
<input data-f="reseller_filer_id_499" class="pw-input" value="${init.reseller_filer_id_499 || ""}" /></div>
</div>
<div class="pw-reseller-row">
<div><label class="pw-field">Reason they don't contribute</label>
<select data-f="non_contributing_reason" class="pw-input">
<option value="de_minimis" ${init.non_contributing_reason === "de_minimis" ? "selected" : ""}>De minimis</option>
<option value="intl_only" ${init.non_contributing_reason === "intl_only" ? "selected" : ""}>International-only</option>
<option value="government" ${init.non_contributing_reason === "government" ? "selected" : ""}>Government entity</option>
<option value="other" ${init.non_contributing_reason === "other" ? "selected" : ""}>Other</option>
</select></div>
<div><label class="pw-field">Revenue this year (USD)</label>
<input type="number" step="0.01" data-f="revenue_usd" class="pw-input" value="${init.revenue_usd || ""}" /></div>
</div>
<button type="button" class="pw-btn pw-btn-danger" data-remove="1">Remove</button>
`;
row.querySelector<HTMLButtonElement>("[data-remove]")!.addEventListener("click", () => row.remove());
return row;
}
document.getElementById("pw-add-reseller")!.addEventListener("click", () => {
list.appendChild(newResellerRow());
});
document.getElementById("pw-add-nc-reseller")!.addEventListener("click", () => {
ncList.appendChild(newNcResellerRow());
});
window.addEventListener("pw:step-shown", (evt: any) => {
if (evt.detail.step !== "reseller_cert") return;
const s = (window as any).PWIntake.get();
list.innerHTML = "";
ncList.innerHTML = "";
for (const r of s.intake_data?.reseller_certifications || []) list.appendChild(newResellerRow(r));
for (const r of s.intake_data?.non_contributing_resellers || []) ncList.appendChild(newNcResellerRow(r));
});
window.addEventListener("pw:step-next", (evt: any) => {
const PW = (window as any).PWIntake;
if (PW.steps[PW.get().step_index] !== "reseller_cert") return;
const certs: any[] = [];
const ncs: any[] = [];
for (const row of Array.from(list.querySelectorAll<HTMLElement>(".pw-reseller"))) {
const item: Record<string, string> = {};
for (const inp of Array.from(row.querySelectorAll<HTMLInputElement>("input[data-f]"))) {
item[inp.getAttribute("data-f")!] = inp.value;
}
if (!item.reseller_filer_id_499 && !item.reseller_legal_name) continue;
if (!/^\d{6,8}$/.test((item.reseller_filer_id_499 || "").replace(/\D/g, ""))) {
err.hidden = false; err.textContent = `Reseller Filer ID must be 6-8 digits: got "${item.reseller_filer_id_499}".`;
evt.preventDefault(); return;
}
if (!item.certification_date) {
err.hidden = false; err.textContent = "Each reseller needs a certification date."; evt.preventDefault(); return;
}
certs.push({
reseller_filer_id_499: item.reseller_filer_id_499,
reseller_legal_name: item.reseller_legal_name,
reseller_contact_name: item.reseller_contact_name,
reseller_contact_email: item.reseller_contact_email,
certification_date: item.certification_date,
signer_combo: item.signer_combo,
});
}
for (const row of Array.from(ncList.querySelectorAll<HTMLElement>(".pw-reseller"))) {
const item: Record<string, string> = {};
for (const el of Array.from(row.querySelectorAll<HTMLInputElement | HTMLSelectElement>("[data-f]"))) {
item[el.getAttribute("data-f")!] = (el as HTMLInputElement).value;
}
if (!item.reseller_filer_id_499 && !item.reseller_legal_name) continue;
ncs.push({
reseller_filer_id_499: item.reseller_filer_id_499,
reseller_legal_name: item.reseller_legal_name,
non_contributing_reason: item.non_contributing_reason || "other",
revenue_usd: Number(item.revenue_usd) || 0,
});
}
err.hidden = true;
PW.patchIntakeData({
reseller_certifications: certs,
non_contributing_resellers: ncs,
});
});
</script>

View file

@ -0,0 +1,375 @@
---
// RevenueStep — full Form 499-A revenue lines + safe-harbor election.
//
// Collects every revenue line the 2026 Form 499-A asks for, gated by
// Line 105 primary category. Totals pre-fill from:
// 1. CDR traffic study (classified) — if unlocked for the year
// 2. ICC import summary — if customer confirmed on IccImportStep
// 3. Manual entry otherwise
//
// Safe harbor election drives the 3-column split (intrastate / interstate
// / international) that form_499a.py writes to the USAC form.
import { SAFE_HARBOR_PCT_BY_YEAR, SAFE_HARBOR_DISALLOWED_CATEGORIES } from "../../../lib/fcc_constants";
---
<div class="pw-step">
<h2>Revenue (Blocks 3 + 4)</h2>
<p class="pw-help">
Enter revenue by Form 499-A line. Where available we've pre-filled from
your classified CDR traffic study and ICC imports. Override any number
that doesn't match your books.
</p>
<div id="pw-rev-study" class="pw-callout" hidden>
<strong>Classified traffic study loaded.</strong>
<span id="pw-rev-study-meta"></span>
</div>
<section class="pw-block">
<h3>Safe-harbor election</h3>
<label class="pw-field">How do you want interstate revenue computed?</label>
<select id="pw-sh-method" class="pw-input">
<option value="safe_harbor">Safe harbor (category-default %)</option>
<option value="traffic_study">Traffic study (upload required)</option>
<option value="actual_data">Actual data from my billing system</option>
</select>
<div id="pw-sh-notice" class="pw-notice" hidden></div>
<div id="pw-sh-recommendation" class="pw-notice" hidden></div>
<div id="pw-sh-upload" hidden>
<label class="pw-field">Upload traffic study (PDF with methodology)</label>
<input type="file" id="pw-sh-file" accept=".pdf" class="pw-input" />
</div>
</section>
<section class="pw-block">
<h3>Totals (Line 419 + uncollectibles)</h3>
<div class="pw-row">
<div><label class="pw-field">Total billed revenue (USD)</label>
<input type="number" step="0.01" id="pw-tot-rev" class="pw-input" min="0" /></div>
<div><label class="pw-field">Interstate %</label>
<input type="number" step="0.1" id="pw-inter" class="pw-input" min="0" max="100" /></div>
<div><label class="pw-field">International %</label>
<input type="number" step="0.1" id="pw-intl" class="pw-input" min="0" max="100" /></div>
<div><label class="pw-field">Form year</label>
<input type="number" id="pw-year" class="pw-input" min="2020" max="2030" /></div>
</div>
<div class="pw-row">
<div><label class="pw-field">Line 421: Total uncollectible</label>
<input type="number" step="0.01" id="pw-line-421" class="pw-input" /></div>
<div><label class="pw-field">Line 422: USF-base uncollectible only</label>
<input type="number" step="0.01" id="pw-line-422" class="pw-input" /></div>
</div>
</section>
<!-- Sub-lines — each category-gated by primary Line 105 -->
<section class="pw-block" id="pw-rev-lines">
<h3>Revenue lines</h3>
<div id="pw-rev-line-list"></div>
</section>
<section class="pw-block">
<h3>USF surcharge pass-through (Line 403)</h3>
<p class="pw-help">
Federal USF surcharges collected from customers (reported 100% interstate).
State USF surcharges (must not exceed actual state contributions).
</p>
<div class="pw-row">
<div><label class="pw-field">Federal USF surcharges (USD)</label>
<input type="number" step="0.01" id="pw-403-fed" class="pw-input" /></div>
<div><label class="pw-field">State USF surcharges (USD)</label>
<input type="number" step="0.01" id="pw-403-state" class="pw-input" /></div>
</div>
</section>
<div id="pw-rev-err" class="pw-err" hidden></div>
</div>
<style>
.pw-step h2 { margin: 0 0 0.5rem; color: #1a2744; }
.pw-step h3 { margin: 0 0 0.4rem; color: #1a2744; font-size: 1rem; }
.pw-block { padding: 1rem; border: 1px solid #e2e8f0; border-radius: 8px; margin-bottom: 0.75rem; }
.pw-help { color: #64748b; font-size: 0.85rem; margin-bottom: 0.75rem; }
.pw-field { display: block; font-weight: 600; color: #1f2937; margin: 0.4rem 0 0.15rem; font-size: 0.82rem; }
.pw-input { width: 100%; padding: 0.5rem 0.7rem; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 0.9rem; font-family: inherit; }
.pw-row { display: flex; gap: 0.75rem; flex-wrap: wrap; }
.pw-row > * { flex: 1 1 150px; }
.pw-callout { border-left: 4px solid #2d4e78; background: #f0f9ff; padding: 0.75rem 1rem; border-radius: 0 6px 6px 0; margin-bottom: 1rem; font-size: 0.9rem; }
.pw-notice { padding: 0.5rem 0.75rem; background: #fef3c7; border-left: 4px solid #d97706; border-radius: 0 6px 6px 0; font-size: 0.85rem; margin-top: 0.5rem; }
.pw-notice.pw-rec-ok { background: #d1fae5; border-left-color: #059669; color: #065f46; }
.pw-notice.pw-rec-info { background: #e0f2fe; border-left-color: #0284c7; color: #075985; }
.pw-notice.pw-rec-warn { background: #fef3c7; border-left-color: #d97706; color: #92400e; }
.pw-notice.pw-rec-danger { background: #fee2e2; border-left-color: #dc2626; color: #991b1b; }
.pw-rec-action {
display: inline-block; margin-left: 0.5rem; padding: 0.15rem 0.5rem;
background: rgba(255,255,255,0.7); border-radius: 4px;
font-size: 0.78rem; font-weight: 600; cursor: pointer; border: 0;
}
.pw-line-row {
display: grid; grid-template-columns: 100px 1fr 140px;
gap: 0.5rem; padding: 0.4rem 0; border-bottom: 1px solid #f1f5f9;
align-items: center;
}
.pw-line-row strong { color: #1a2744; font-size: 0.85rem; }
.pw-line-desc { font-size: 0.82rem; color: #64748b; }
.pw-line-row input { padding: 0.35rem 0.5rem; text-align: right; }
.pw-prefill-tag {
display: inline-block; padding: 0.1rem 0.4rem; background: #dcfce7;
color: #065f46; border-radius: 4px; font-size: 0.7rem; margin-left: 0.4rem;
}
.pw-err { color: #b91c1c; margin-top: 0.75rem; font-size: 0.9rem; }
</style>
<script>
import { SAFE_HARBOR_PCT_BY_YEAR, SAFE_HARBOR_DISALLOWED_CATEGORIES } from "../../../lib/fcc_constants";
import { fetchSafeHarborRecommendation, recommendationBadge } from "../../../lib/safe_harbor_recommender";
// Line definitions — subset of LINE_FILL_MAP. key matches the
// source_key used by form_499a.py.
type LineDef = { key: string; label: string; line_no: string; desc: string; categories: string[] | null; };
const LINES: LineDef[] = [
{ key: "line_303", label: "Line 303", line_no: "303", desc: "Carrier's-carrier service (wholesale to reseller)", categories: null },
{ key: "line_303_2", label: "Line 303.2", line_no: "303.2", desc: "Interconnected VoIP wholesale", categories: ["voip_interconnected"] },
{ key: "line_304_1", label: "Line 304.1", line_no: "304.1", desc: "Tariffed per-minute access", categories: ["clec","ilec","ixc"] },
{ key: "line_304_2", label: "Line 304.2", line_no: "304.2", desc: "UNE / contract per-minute access", categories: ["clec","ilec","ixc"] },
{ key: "line_305_1", label: "Line 305.1", line_no: "305.1", desc: "Private line / BDS — resold as TDM", categories: ["clec","ilec","private_line"] },
{ key: "line_305_2", label: "Line 305.2", line_no: "305.2", desc: "Private line / BDS — resold as VoIP", categories: ["clec","ilec","private_line"] },
{ key: "line_309", label: "Line 309", line_no: "309", desc: "Mobile service to resellers + roaming from other US carriers", categories: ["wireless"] },
{ key: "line_404", label: "Line 404", line_no: "404", desc: "End-user local revenue", categories: null },
{ key: "line_404_1", label: "Line 404.1", line_no: "404.1", desc: "Special access / BDS", categories: null },
{ key: "line_404_3", label: "Line 404.3", line_no: "404.3", desc: "8YY originating access", categories: null },
{ key: "line_405", label: "Line 405", line_no: "405", desc: "Tariffed SLC / ARC / PICC (typically $0 for non-ILECs)", categories: ["clec","ilec"] },
{ key: "line_409", label: "Line 409", line_no: "409", desc: "Mobile monthly / activation / service restoration", categories: ["wireless"] },
{ key: "line_410", label: "Line 410", line_no: "410", desc: "Mobile roaming / directory / prepaid airtime", categories: ["wireless"] },
{ key: "line_411", label: "Line 411", line_no: "411", desc: "Prepaid calling card revenue", categories: ["prepaid_calling_card"] },
{ key: "line_414_1", label: "Line 414.1", line_no: "414.1", desc: "Ordinary long distance — non-VoIP", categories: null },
{ key: "line_414_2", label: "Line 414.2", line_no: "414.2", desc: "Ordinary long distance — interconnected VoIP", categories: ["voip_interconnected"] },
{ key: "line_418_1", label: "Line 418.1", line_no: "418.1", desc: "Non-telecom to end users (equipment, consulting, internet)", categories: null },
{ key: "line_418_2", label: "Line 418.2", line_no: "418.2", desc: "Non-telecom to other carriers", categories: null },
{ key: "line_418_3", label: "Line 418.3", line_no: "418.3", desc: "Other miscellaneous revenue", categories: null },
{ key: "line_418_4", label: "Line 418.4", line_no: "418.4", desc: "Non-interconnected VoIP (TRS-only)", categories: ["voip_non_interconnected"] },
];
const listEl = document.getElementById("pw-rev-line-list")!;
const totRev = document.getElementById("pw-tot-rev") as HTMLInputElement;
const inter = document.getElementById("pw-inter") as HTMLInputElement;
const intl = document.getElementById("pw-intl") as HTMLInputElement;
const yearI = document.getElementById("pw-year") as HTMLInputElement;
const l421 = document.getElementById("pw-line-421") as HTMLInputElement;
const l422 = document.getElementById("pw-line-422") as HTMLInputElement;
const shMethod = document.getElementById("pw-sh-method") as HTMLSelectElement;
const shNotice = document.getElementById("pw-sh-notice") as HTMLElement;
const shUpload = document.getElementById("pw-sh-upload") as HTMLElement;
const f403 = document.getElementById("pw-403-fed") as HTMLInputElement;
const s403 = document.getElementById("pw-403-state") as HTMLInputElement;
const studyBanner = document.getElementById("pw-rev-study") as HTMLElement;
const studyMeta = document.getElementById("pw-rev-study-meta") as HTMLElement;
const err = document.getElementById("pw-rev-err") as HTMLDivElement;
let currentCategory = "voip_interconnected";
function renderLines(primary: string) {
currentCategory = primary;
listEl.innerHTML = "";
for (const L of LINES) {
if (L.categories && !L.categories.includes(primary)) continue;
const row = document.createElement("div");
row.className = "pw-line-row";
row.innerHTML = `
<strong>${L.label}</strong>
<span class="pw-line-desc">${L.desc}</span>
<input type="number" step="0.01" data-line="${L.key}" class="pw-input" placeholder="0.00" />
`;
listEl.appendChild(row);
}
}
function updateSafeHarborNotice(primary: string) {
if (shMethod.value === "safe_harbor") {
if (SAFE_HARBOR_DISALLOWED_CATEGORIES.has(primary)) {
shNotice.hidden = false;
shNotice.textContent = "Non-interconnected VoIP has NO safe harbor. Please select traffic study or actual data.";
shUpload.hidden = true;
} else {
const year = Number(yearI.value) || 2026;
const pct = SAFE_HARBOR_PCT_BY_YEAR[year]?.[primary];
shNotice.hidden = !pct;
shNotice.textContent = pct ? `Safe harbor: ${pct}% interstate will be applied.` : "";
shUpload.hidden = true;
}
} else if (shMethod.value === "traffic_study") {
shNotice.hidden = true;
shUpload.hidden = false;
} else {
shNotice.hidden = true;
shUpload.hidden = true;
}
}
shMethod.addEventListener("change", () => updateSafeHarborNotice(currentCategory));
yearI.addEventListener("change", () => { updateSafeHarborNotice(currentCategory); refreshRecommendation(); });
totRev.addEventListener("change", refreshRecommendation);
async function refreshRecommendation() {
const recPanel = document.getElementById("pw-sh-recommendation") as HTMLElement;
const s = (window as any).PWIntake.get();
if (!s.telecom_entity_id) { recPanel.hidden = true; return; }
// Resolve profile_id from the entity via the by-entity endpoint
let profile_id: number | null = null;
try {
const r = await fetch(`/api/v1/cdr/profile/by-entity/${s.telecom_entity_id}`);
if (r.ok) profile_id = (await r.json()).profile_id || null;
} catch {}
const year = Number(yearI.value) || new Date().getUTCFullYear() - 1;
const totalCents = Math.round((Number(totRev.value) || 0) * 100);
const rec = await fetchSafeHarborRecommendation({
profile_id, year, category: currentCategory,
total_revenue_cents: totalCents || undefined,
});
if (!rec) { recPanel.hidden = true; return; }
const badge = recommendationBadge(rec.recommendation);
recPanel.hidden = false;
recPanel.className = "pw-notice pw-rec-" + badge.tone;
let html = `<strong>${badge.headline}</strong> — ${rec.message}`;
if (badge.suggest && badge.suggest !== shMethod.value) {
html += ` <button type="button" class="pw-rec-action" data-suggest="${badge.suggest}">Use ${badge.suggest.replace("_", " ")}</button>`;
}
recPanel.innerHTML = html;
const btn = recPanel.querySelector<HTMLButtonElement>(".pw-rec-action");
if (btn) {
btn.addEventListener("click", () => {
shMethod.value = btn.getAttribute("data-suggest")!;
updateSafeHarborNotice(currentCategory);
refreshRecommendation();
});
}
}
async function tryPrefillFromStudy() {
const s = (window as any).PWIntake.get();
if (!s.telecom_entity_id) return;
const y = Number(yearI.value) || new Date().getUTCFullYear() - 1;
try {
const profResp = await fetch(`/api/v1/cdr/profile/by-entity/${s.telecom_entity_id}`);
if (!profResp.ok) return;
const { profile_id } = await profResp.json();
if (!profile_id) return;
const studyResp = await fetch(`/api/v1/cdr/profile/${profile_id}/study?year=${y}`);
if (!studyResp.ok) return;
const data = await studyResp.json();
if (data.status !== "unlocked" || !data.classified_report) return;
const r = data.classified_report;
if (!inter.value) inter.value = String(r.interstate_pct ?? "");
if (!intl.value) intl.value = String(r.international_pct ?? "");
if (!totRev.value) totRev.value = String((r.total_revenue_cents ?? 0) / 100);
studyBanner.hidden = false;
studyMeta.textContent =
`Based on ${Number(r.total_calls).toLocaleString()} classified calls (${r.methodology}).`;
} catch {}
}
async function tryPrefillFromIcc() {
const s = (window as any).PWIntake.get();
if (!s.telecom_entity_id || s.intake_data?.icc_revenue_source !== "imported") return;
const y = Number(yearI.value) || new Date().getUTCFullYear() - 1;
try {
const profResp = await fetch(`/api/v1/cdr/profile/by-entity/${s.telecom_entity_id}`);
if (!profResp.ok) return;
const { profile_id } = await profResp.json();
if (!profile_id) return;
const sumResp = await fetch(`/api/v1/icc/profile/${profile_id}/summary?year=${y}`);
if (!sumResp.ok) return;
const data = await sumResp.json();
for (const [lineNo, info] of Object.entries(data.by_form_line || {})) {
const key = "line_" + (lineNo as string).replace(".", "_");
const input = listEl.querySelector<HTMLInputElement>(`input[data-line="${key}"]`);
if (input && !input.value) {
input.value = (((info as any).revenue_cents) / 100).toFixed(2);
input.style.backgroundColor = "#dcfce7"; // green = pre-filled from ICC
}
}
} catch {}
}
window.addEventListener("pw:step-shown", (evt: any) => {
if (evt.detail.step !== "revenue") return;
const s = (window as any).PWIntake.get();
const primary = s.intake_data?.line_105_primary
|| s.entity?.line_105_primary
|| s.entity?.carrier_category
|| "voip_interconnected";
renderLines(primary);
totRev.value = s.intake_data?.total_revenue_cents
? String(s.intake_data.total_revenue_cents / 100) : "";
inter.value = s.intake_data?.interstate_pct ?? "";
intl.value = s.intake_data?.international_pct ?? "";
yearI.value = String(s.intake_data?.form_year || (new Date().getUTCFullYear() - 1));
l421.value = s.intake_data?.revenue?.line_421 ? (s.intake_data.revenue.line_421 / 100).toFixed(2) : "";
l422.value = s.intake_data?.revenue?.line_422 ? (s.intake_data.revenue.line_422 / 100).toFixed(2) : "";
const elect = s.intake_data?.safe_harbor_election?.[primary] || {};
shMethod.value = elect.method || "safe_harbor";
updateSafeHarborNotice(primary);
// Pre-fill revenue lines from saved state
for (const L of LINES) {
const val = s.intake_data?.revenue?.[L.key];
const input = listEl.querySelector<HTMLInputElement>(`input[data-line="${L.key}"]`);
if (input && val) input.value = (val / 100).toFixed(2);
}
f403.value = s.intake_data?.revenue?.line_403_federal ? (s.intake_data.revenue.line_403_federal / 100).toFixed(2) : "";
s403.value = s.intake_data?.revenue?.line_403_state ? (s.intake_data.revenue.line_403_state / 100).toFixed(2) : "";
tryPrefillFromStudy();
tryPrefillFromIcc();
refreshRecommendation();
});
window.addEventListener("pw:step-next", (evt: any) => {
const PW = (window as any).PWIntake;
if (PW.steps[PW.get().step_index] !== "revenue") return;
const primary = currentCategory;
if (SAFE_HARBOR_DISALLOWED_CATEGORIES.has(primary) && shMethod.value === "safe_harbor") {
err.hidden = false; err.textContent = "Safe harbor is not available for non-interconnected VoIP.";
evt.preventDefault(); return;
}
if (!totRev.value || !inter.value) {
err.hidden = false; err.textContent = "Total revenue + interstate % are required.";
evt.preventDefault(); return;
}
err.hidden = true;
const revenue: Record<string, number> = {};
for (const inp of Array.from(listEl.querySelectorAll<HTMLInputElement>("input[data-line]"))) {
const key = inp.getAttribute("data-line")!;
const cents = Math.round((Number(inp.value) || 0) * 100);
if (cents) revenue[key] = cents;
}
if (f403.value) revenue.line_403_federal = Math.round(Number(f403.value) * 100);
if (s403.value) revenue.line_403_state = Math.round(Number(s403.value) * 100);
if (l421.value) revenue.line_421 = Math.round(Number(l421.value) * 100);
if (l422.value) revenue.line_422 = Math.round(Number(l422.value) * 100);
// If only 421 provided, estimate 422 proportionally to USF base
if (revenue.line_421 && !revenue.line_422) {
revenue.line_422 = revenue.line_421; // conservative — customer can override
}
const year = Number(yearI.value) || (new Date().getUTCFullYear() - 1);
const sh = Number((SAFE_HARBOR_PCT_BY_YEAR as any)[year]?.[primary]);
const election = { ...(PW.get().intake_data?.safe_harbor_election || {}) };
election[primary] = {
year, q: "annual",
method: shMethod.value,
pct: shMethod.value === "safe_harbor" && !Number.isNaN(sh) ? sh : undefined,
};
PW.patchIntakeData({
total_revenue_cents: Math.round(parseFloat(totRev.value) * 100),
interstate_pct: parseFloat(inter.value),
international_pct: parseFloat(intl.value || "0"),
form_year: year,
revenue,
safe_harbor_election: election,
});
const st = PW.get();
PW.set({ entity: { ...st.entity, safe_harbor_election: election } });
});
</script>

View file

@ -0,0 +1,191 @@
---
// ReviewStep — summary panel, server-side validation hook, price display.
export interface Props { service_slug: string; }
const { service_slug } = Astro.props;
import { SERVICE_META, formatUSD } from "../../../lib/intake_manifest";
const meta = SERVICE_META[service_slug];
---
<div class="pw-step" data-slug={service_slug}>
<h2>Review</h2>
<p class="pw-help">
Review what we'll send to the FCC/USAC on your behalf. Click Finish
to run validation + continue to payment.
</p>
<div class="pw-summary" id="pw-review-pane">
<h3>Service</h3>
<div class="pw-summary-row">
<span>{meta?.name ?? service_slug}</span>
<strong>{meta ? formatUSD(meta.price_cents) : "—"}</strong>
</div>
<h3>Carrier</h3>
<div id="pw-summary-entity" class="pw-summary-sec"></div>
<h3>Order details</h3>
<div id="pw-summary-intake" class="pw-summary-sec"></div>
</div>
<div id="pw-review-errors" class="pw-err" hidden></div>
<div id="pw-review-warnings" class="pw-warn" hidden></div>
</div>
<style>
.pw-step h2 { margin: 0 0 0.5rem; color: #1a2744; }
.pw-help { color: #64748b; font-size: 0.9rem; margin-bottom: 1rem; }
.pw-summary { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 1rem 1.25rem; }
.pw-summary h3 { color: #1a2744; margin: 1rem 0 0.4rem; font-size: 1rem; }
.pw-summary h3:first-child { margin-top: 0; }
.pw-summary-row { display: flex; justify-content: space-between; padding: 0.3rem 0; font-size: 0.95rem; border-bottom: 1px solid #e2e8f0; }
.pw-summary-row:last-child { border: 0; }
.pw-summary-sec { font-size: 0.9rem; color: #475569; padding: 0.3rem 0; }
.pw-summary-sec pre { font-family: ui-monospace, SFMono-Regular, monospace; font-size: 0.8rem; background: #fff; padding: 0.5rem; border-radius: 4px; overflow-x: auto; }
.pw-err { color: #b91c1c; margin-top: 0.75rem; font-size: 0.9rem; background: #fee2e2; padding: 0.5rem 0.75rem; border-radius: 6px; }
.pw-warn { color: #92400e; margin-top: 0.75rem; font-size: 0.9rem; background: #fef3c7; padding: 0.5rem 0.75rem; border-radius: 6px; }
</style>
<script>
const slug = document.querySelector(".pw-step[data-slug]")!.getAttribute("data-slug")!;
const entDiv = document.getElementById("pw-summary-entity")!;
const intakeDiv = document.getElementById("pw-summary-intake")!;
const errDiv = document.getElementById("pw-review-errors") as HTMLDivElement;
const warnDiv = document.getElementById("pw-review-warnings") as HTMLDivElement;
function renderReview() {
const s = (window as any).PWIntake.get();
const e = s.entity || {};
entDiv.innerHTML = `
<div><strong>${e.legal_name || "(name pending)"}</strong>
${e.dba_name ? ` d/b/a ${e.dba_name}` : ""}</div>
<div>${e.frn ? `FRN ${e.frn}` : "<em>FRN pending (we'll register it if needed)</em>"}
${e.filer_id_499 ? ` · Filer ID ${e.filer_id_499}` : ""}</div>
<div>${[e.address_street, e.address_city,
(e.address_state && e.address_zip) ? `${e.address_state} ${e.address_zip}` : ""].filter(Boolean).join(", ") || "(address pending)"}</div>
<div>${e.carrier_category || ""}</div>
`;
intakeDiv.innerHTML = `<pre>${JSON.stringify(s.intake_data || {}, null, 2)}</pre>`;
}
async function runValidate(): Promise<{ ok: boolean; missing: string[]; soft: string[] }> {
// /validate expects the order to exist — we create a draft here if
// we haven't yet. Draft orders are created with payment_status=pending_payment.
const state = (window as any).PWIntake.get();
// Step 1: ensure we have an order_number for this session
let orderNumber = state.order_number;
if (!orderNumber) {
const createResp = await fetch("/api/v1/compliance-orders", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
service_slug: slug,
customer_email: state.email,
customer_name: state.name,
telecom_entity_id: state.telecom_entity_id,
intake_data: state.intake_data,
// Filing mode + de minimis election (migrations 058/059)
filing_mode: (state as any).filing_mode || "current",
form_year_override: (state as any).form_year_override || null,
revises_order_number: (state as any).revises_order_number || null,
revised_reason: (state as any).revised_reason || null,
waive_deminimis_exemption: (state as any).waive_deminimis_exemption === true,
waive_deminimis_reason: (state as any).waive_deminimis_reason || null,
}),
});
if (!createResp.ok) throw new Error(`order create HTTP ${createResp.status}`);
const created = await createResp.json();
orderNumber = created.order_number;
(window as any).PWIntake.set({ ...state, order_number: orderNumber });
}
// Step 2: validate
const vResp = await fetch(
`/api/v1/compliance-orders/${orderNumber}/validate`,
{ method: "POST" },
);
const data = await vResp.json();
return {
ok: data.ok === true,
missing: data.missing || [],
rejections: data.rejections || [],
soft: data.soft_warnings || [],
calculations: data.calculations || {},
};
}
function renderDeMinimis(w: any): string {
if (!w) return "";
const dollars = (c: number) => "$" + (c / 100).toLocaleString("en-US", { minimumFractionDigits: 2 });
return `
<div class="pw-deminimis">
<strong>Appendix A de minimis calculation (${w.form_year}):</strong>
<div>Contribution base: ${dollars(w.line_9_contribution_base_cents)}</div>
<div>× factor ${w.line_10_factor} = estimated contribution ${dollars(w.line_11_estimated_contrib_cents)}</div>
<div style="font-weight:600; color:${w.is_de_minimis ? "#065f46" : "#991b1b"};">
${w.is_de_minimis ? "✓ DE MINIMIS (exempt from USF)" : "NOT de minimis — you must contribute to USF"}
</div>
</div>
`;
}
function renderCalcsInfo(calcs: any): string {
if (!calcs) return "";
const parts: string[] = [];
if (calcs.de_minimis) parts.push(renderDeMinimis(calcs.de_minimis));
if (calcs.lnpa_sums) {
parts.push(`<div>LNPA sums: Block 3 ${calcs.lnpa_sums.block_3_pct.toFixed(2)}% / Block 4 ${calcs.lnpa_sums.block_4_pct.toFixed(2)}%</div>`);
}
if (calcs.trs_base) {
parts.push(`<div>TRS base (Line 512): $${(calcs.trs_base.line_512/100).toLocaleString("en-US",{minimumFractionDigits:2})}</div>`);
}
return parts.length ? `<section class="pw-block"><h3>Computed totals</h3>${parts.join("")}</section>` : "";
}
window.addEventListener("pw:step-shown", (evt: any) => {
if (evt.detail.step !== "review") return;
renderReview();
errDiv.hidden = true; warnDiv.hidden = true;
});
window.addEventListener("pw:step-next", async (evt: any) => {
const PW = (window as any).PWIntake;
if (PW.steps[PW.get().step_index] !== "review") return;
evt.preventDefault(); // handle async flow
try {
const result = await runValidate();
// Append computed totals (de minimis, LNPA sums, TRS base) to the review pane
const calcsHtml = renderCalcsInfo(result.calculations);
if (calcsHtml) {
const host = document.getElementById("pw-review-pane");
if (host) host.insertAdjacentHTML("beforeend", calcsHtml);
}
if (result.soft.length > 0) {
warnDiv.hidden = false;
warnDiv.textContent = "Soft warnings: " + result.soft.join(", ") + ". You can still proceed.";
} else { warnDiv.hidden = true; }
if (!result.ok) {
errDiv.hidden = false;
const msgs: string[] = [];
if (result.missing.length) msgs.push("Missing: " + result.missing.join(", "));
if (result.rejections && result.rejections.length) msgs.push(result.rejections.join(" | "));
errDiv.textContent = msgs.join(" — ");
return;
}
errDiv.hidden = true;
// advance to the payment step
const state = PW.get();
PW.set({ ...state, step_index: state.step_index + 1 });
document.querySelectorAll<HTMLElement>(".pw-wizard-body > [data-step]").forEach((el) => {
el.hidden = el.getAttribute("data-step") !== PW.steps[state.step_index + 1];
});
document.querySelectorAll<HTMLElement>(".pw-step-chip").forEach((chip, i) => {
chip.setAttribute("data-active", String(i === state.step_index + 1));
chip.setAttribute("data-done", String(i < state.step_index + 1));
});
window.dispatchEvent(new CustomEvent("pw:step-shown",
{ detail: { step: PW.steps[state.step_index + 1], idx: state.step_index + 1 } }));
} catch (err: any) {
errDiv.hidden = false;
errDiv.textContent = "Could not validate order: " + err.message;
}
});
</script>

View file

@ -0,0 +1,54 @@
---
// STIRShakenStep — target posture + optional STI-CA vendor preference.
---
<div class="pw-step">
<h2>STIR/SHAKEN</h2>
<p class="pw-help">
Your current or target STIR/SHAKEN posture. If you're not sure, pick
the option that best matches your network today — we'll advise on
upgrades as part of the filing.
</p>
<label class="pw-field">STIR/SHAKEN implementation</label>
<select id="pw-ss-status" class="pw-input">
<option value="">Select…</option>
<option value="complete_implementation">Complete — I sign calls with my own cert</option>
<option value="partial_implementation">Partial — upstream carrier signs my traffic</option>
<option value="robocall_mitigation_only">Robocall Mitigation Only — no STIR/SHAKEN signing</option>
<option value="exempt_small_carrier">Exempt small carrier</option>
</select>
<label class="pw-field">STI-CA vendor preference (optional)</label>
<input type="text" id="pw-ss-vendor" class="pw-input" placeholder="e.g. iconectiv, Peeringhub, Neustar (leave blank if unsure)" />
<label class="pw-field">Upstream voice provider (if partial / robocall-mitigation-only)</label>
<input type="text" id="pw-ss-upstream" class="pw-input" placeholder="e.g. Bandwidth.com, VoIP Innovations" />
</div>
<style>
.pw-step h2 { margin: 0 0 0.5rem; color: #1a2744; }
.pw-help { color: #64748b; font-size: 0.9rem; margin-bottom: 1rem; }
.pw-field { display: block; font-weight: 600; margin: 0.8rem 0 0.2rem; font-size: 0.88rem; }
.pw-input { width: 100%; padding: 0.5rem 0.7rem; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 0.93rem; }
</style>
<script>
const g = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
window.addEventListener("pw:step-shown", (evt: any) => {
if (evt.detail.step !== "stir_shaken") return;
const s = (window as any).PWIntake.get();
g<HTMLSelectElement>("pw-ss-status").value = s.intake_data?.target_stir_shaken_status || s.entity?.stir_shaken_status || "";
g<HTMLInputElement>("pw-ss-vendor").value = s.intake_data?.sti_ca_vendor || "";
g<HTMLInputElement>("pw-ss-upstream").value = s.intake_data?.upstream_provider_name || "";
});
window.addEventListener("pw:step-next", (evt: any) => {
const PW = (window as any).PWIntake;
if (PW.steps[PW.get().step_index] !== "stir_shaken") return;
PW.patchIntakeData({
target_stir_shaken_status: g<HTMLSelectElement>("pw-ss-status").value,
sti_ca_vendor: g<HTMLInputElement>("pw-ss-vendor").value.trim(),
upstream_provider_name: g<HTMLInputElement>("pw-ss-upstream").value.trim(),
});
});
</script>

View file

@ -0,0 +1,112 @@
---
// WirelessStep — spectrum / MVNO host / subscriber counts / roaming.
// Inserted into the wizard when line_105_categories contains 'wireless'.
---
<div class="pw-step">
<h2>Wireless carrier details</h2>
<p class="pw-help">
Wireless-specific intake for Form 499-A Lines 309 / 409 / 410 (mobile
revenue to resellers, end-user mobile monthly, roaming + usage) and
for the wireless CPNI template.
</p>
<label class="pw-field">Infrastructure</label>
<select id="pw-wl-infra" class="pw-input">
<option value="facilities">Facilities-based (own spectrum / towers)</option>
<option value="mvno">MVNO (use another carrier's network)</option>
</select>
<div id="pw-wl-mvno-wrap" hidden>
<label class="pw-field">Host MNO</label>
<select id="pw-wl-host" class="pw-input">
<option value="">—</option>
<option value="T-Mobile">T-Mobile</option>
<option value="Verizon">Verizon</option>
<option value="AT&T">AT&T</option>
<option value="Dish">Dish / Boost</option>
<option value="US Cellular">US Cellular</option>
</select>
</div>
<div id="pw-wl-fac-wrap">
<label class="pw-field">Spectrum bands (one per line)</label>
<textarea id="pw-wl-bands" class="pw-input" rows="3"
placeholder="e.g., Lower 700 MHz&#10;PCS 1900&#10;CBRS 3.5 GHz"></textarea>
<label class="pw-field">Cell site count</label>
<input type="number" id="pw-wl-sites" class="pw-input" min="0" />
</div>
<div class="pw-row">
<div><label class="pw-field">Post-paid subscribers</label>
<input type="number" id="pw-wl-post" class="pw-input" min="0" /></div>
<div><label class="pw-field">Pre-paid subscribers</label>
<input type="number" id="pw-wl-pre" class="pw-input" min="0" /></div>
</div>
<div class="pw-row">
<div><label class="pw-field">Inbound roaming MOUs</label>
<input type="number" id="pw-wl-roam-mou" class="pw-input" min="0" /></div>
<div><label class="pw-field">Inbound roaming revenue (USD)</label>
<input type="number" step="0.01" id="pw-wl-roam-rev" class="pw-input" min="0" /></div>
</div>
</div>
<style>
.pw-step h2 { margin: 0 0 0.5rem; color: #1a2744; }
.pw-help { color: #64748b; font-size: 0.9rem; margin-bottom: 1rem; }
.pw-field { display: block; font-weight: 600; color: #1f2937; margin: 0.6rem 0 0.2rem; font-size: 0.88rem; }
.pw-input { width: 100%; padding: 0.5rem 0.7rem; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 0.93rem; font-family: inherit; }
.pw-row { display: flex; gap: 1rem; flex-wrap: wrap; }
.pw-row > * { flex: 1 1 140px; }
</style>
<script>
const infra = document.getElementById("pw-wl-infra") as HTMLSelectElement;
const mvnoWrap = document.getElementById("pw-wl-mvno-wrap") as HTMLElement;
const facWrap = document.getElementById("pw-wl-fac-wrap") as HTMLElement;
const hostSel = document.getElementById("pw-wl-host") as HTMLSelectElement;
const bands = document.getElementById("pw-wl-bands") as HTMLTextAreaElement;
const sites = document.getElementById("pw-wl-sites") as HTMLInputElement;
const post = document.getElementById("pw-wl-post") as HTMLInputElement;
const pre = document.getElementById("pw-wl-pre") as HTMLInputElement;
const roamMou = document.getElementById("pw-wl-roam-mou") as HTMLInputElement;
const roamRev = document.getElementById("pw-wl-roam-rev") as HTMLInputElement;
function syncInfra() {
mvnoWrap.hidden = infra.value !== "mvno";
facWrap.hidden = infra.value === "mvno";
}
infra.addEventListener("change", syncInfra);
window.addEventListener("pw:step-shown", (evt: any) => {
if (evt.detail.step !== "wireless") return;
const s = (window as any).PWIntake.get();
const m = s.intake_data?.wireless_meta || {};
infra.value = m.infra_type || "facilities";
hostSel.value = m.host_mno || "";
bands.value = (m.spectrum_bands || []).join("\n");
sites.value = m.cell_site_count ?? "";
post.value = m.post_paid_subs ?? "";
pre.value = m.pre_paid_subs ?? "";
roamMou.value = m.roaming_mou ?? "";
roamRev.value = m.roaming_revenue_usd ?? "";
syncInfra();
});
window.addEventListener("pw:step-next", (evt: any) => {
const PW = (window as any).PWIntake;
if (PW.steps[PW.get().step_index] !== "wireless") return;
const meta: Record<string, any> = {
infra_type: infra.value,
host_mno: infra.value === "mvno" ? hostSel.value || null : null,
spectrum_bands: bands.value.split("\n").map((b) => b.trim()).filter(Boolean),
cell_site_count: Number(sites.value) || null,
post_paid_subs: Number(post.value) || 0,
pre_paid_subs: Number(pre.value) || 0,
roaming_mou: Number(roamMou.value) || 0,
roaming_revenue_usd: Number(roamRev.value) || 0,
};
PW.patchIntakeData({ wireless_meta: meta });
});
</script>

1
site/src/env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference path="../.astro/types.d.ts" />

View file

@ -0,0 +1,64 @@
---
// Base layout with production site chrome (nav, footer, support widget).
// Reads nav.html and footer.html partials extracted from the production
// homepage and includes them around the page content.
import { readFileSync } from "fs";
import { resolve } from "path";
export interface Props {
title: string;
description?: string;
}
const { title, description = "" } = Astro.props;
// Read the nav + footer partials (extracted from prod homepage)
let navHtml = "";
let footerHtml = "";
try {
navHtml = readFileSync(resolve("src/partials/nav.html"), "utf-8");
footerHtml = readFileSync(resolve("src/partials/footer.html"), "utf-8");
} catch {
// Partials not found — render without chrome (dev fallback)
}
---
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title} | Performance West Inc.</title>
{description && <meta name="description" content={description} />}
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/_astro/about.DhmoKVOS.css" />
<script type="module" src="/_astro/hoisted.yFz1BYXO.js"></script>
<script>
window.__PW_API = (function() {
var h = window.location.hostname;
if (h === "localhost" || h === "127.0.0.1") return "http://" + h + ":3001";
if (h === "dev.performancewest.net") return "https://api.dev.performancewest.net";
return "https://api.performancewest.net";
})();
</script>
<style is:global>
:root {
--pw-navy: #1a2744;
--pw-blue: #2d4e78;
--pw-light: #f8fafc;
--pw-ink: #1f2937;
--pw-muted: #64748b;
--pw-green: #059669;
--pw-amber: #b45309;
}
</style>
</head>
<Fragment set:html={navHtml} />
<main class="flex-1">
<slot />
</main>
<Fragment set:html={footerHtml} />
</html>

View file

@ -0,0 +1,97 @@
// FCC Constants — safe harbor %s, de minimis factors, Line 105 ticking rules
//
// The authoritative source of these values is USAC's annual Form 499-A
// Instructions (released each November). Kept here for client-side use in
// the intake wizard (display defaults, validation). The server-side
// lookup goes against fcc_deminimis_factors + fcc_safe_harbor_percentages
// DB tables — so this file mirrors those and must stay in sync.
//
// Authority: 2026 Form 499-A Instructions
// docs/fcc-references/2026-FCC-Form-499A-Form-Instructions.pdf
export const US_STATES_AND_TERRITORIES: string[] = [
"AL","AK","AZ","AR","CA","CO","CT","DE","DC","FL","GA","HI","ID","IL","IN",
"IA","KS","KY","LA","ME","MD","MA","MI","MN","MS","MO","MT","NE","NV","NH",
"NJ","NM","NY","NC","ND","OH","OK","OR","PA","RI","SC","SD","TN","TX","UT",
"VT","VA","WA","WV","WI","WY",
// US territories (Line 227 requires these as well)
"PR","GU","VI","AS","MP","UM", // Puerto Rico, Guam, USVI, Am. Samoa, N. Mariana, US Minor Outlying
];
// ── De minimis factor by form year ──────────────────────────────────────
// Per 2026 Form 499-A Appendix A Line 10 = 0.256. The factor changes
// annually; keep in sync with fcc_deminimis_factors table.
export const DE_MINIMIS_FACTOR_BY_YEAR: Record<number, number> = {
2026: 0.2560,
};
// De minimis exemption threshold ($)
export const DE_MINIMIS_THRESHOLD_USD = 10000;
// ── Safe harbor percentages by form year + category ─────────────────────
// Per 2026 Form 499-A Section IV.C.5.g. Non-interconnected VoIP has NO
// safe harbor — must use traffic study or actual data.
export const SAFE_HARBOR_PCT_BY_YEAR: Record<
number,
Record<string, number>
> = {
2026: {
voip_interconnected: 64.9,
cellular_pcs: 37.1,
paging: 12.0,
smr_dispatch: 1.0,
},
};
// Categories for which safe-harbor election is DISALLOWED (must use traffic study or actual data)
export const SAFE_HARBOR_DISALLOWED_CATEGORIES: Set<string> = new Set([
"voip_non_interconnected",
]);
// ── Officer-definition help text (Lines 221/223/225) ────────────────────
// Verbatim from 2026 instructions, paraphrased slightly for clarity.
export const OFFICER_DEFINITION_HELP_TEXT =
"An officer is an occupant of a position listed in the articles of "
+ "incorporation, articles of formation, or other similar legal document. "
+ "For corporations and LLCs, list the CEO/President/Chairman (and if "
+ "the same person holds multiple, the next-most-senior officers). For "
+ "sole proprietorships, list the owner only. For partnerships, list "
+ "the managing partner plus the two partners with the greatest "
+ "financial interest.";
// ── Form 499-A filing types (Line 612) ──────────────────────────────────
export type FilingType =
| "original_april_1"
| "registration_new_filer"
| "revised_registration"
| "revised_revenue";
export const FILING_TYPE_LABELS: Record<FilingType, string> = {
original_april_1: "Original Filing (April 1)",
registration_new_filer: "Registration — New Filer",
revised_registration: "Revised Filing (Registration Info)",
revised_revenue: "Revised Filing (Revenue Info)",
};
// ── Reseller certification — sample language (FCC Section IV.C.4) ───────
// Near-verbatim from 2026 instructions. Used as default text in
// ResellerCertStep.astro for the signed certification body.
export const RESELLER_CERTIFICATION_SAMPLE_TEXT =
"I certify under penalty of perjury that the company is purchasing "
+ "service(s) for resale, at least in part, and that the company is "
+ "incorporating the purchased services into its own offerings which are, "
+ "at least in part, assessable U.S. telecommunications or interconnected "
+ "Voice over Internet Protocol services. I also certify under penalty "
+ "of perjury that the company either directly contributes or has a "
+ "reasonable expectation that another entity in the downstream chain "
+ "of resellers directly contributes to the federal universal service "
+ "support mechanisms on the assessable portion of revenues from "
+ "offerings that incorporate the purchased services.";
// ── Nondisclosure (Line 605) certification text ─────────────────────────
export const NONDISCLOSURE_CERT_TEXT =
"By checking this box, the officer of the company signing the Worksheet "
+ "certifies that the information contained on the Worksheet is "
+ "privileged or confidential commercial or financial information and "
+ "that disclosure of such information would likely cause substantial "
+ "harm to the competitive position of the company filing the Worksheet.";

View file

@ -0,0 +1,143 @@
/**
* Per-service intake step manifest.
*
* Each service slug maps to the ordered list of wizard steps. The Wizard
* component inserts category-gated dynamic steps (wireless, earth_station,
* audio_bridging, icc_import, reseller_cert) at runtime based on the
* user's Line 105 selection, between the declared step markers.
*
* Keep in sync with:
* - api/src/routes/compliance-orders.ts REQUIRED_FIELDS
* - scripts/workers/services/__init__.py SERVICE_HANDLERS registry
*/
export type IntakeStep =
| "entity"
| "category" // NEW: Line 105 ranked multi-select
| "officer"
| "jurisdiction"
| "history" // NEW: Line 228 first-service date
| "wireless" // NEW: category-gated — inserted when wireless selected
| "earth_station" // NEW: category-gated — satellite + private_line
| "audio_bridging" // NEW: category-gated — audio_bridging
| "revenue"
| "bundled_service" // NEW: local+toll bundle allocation
| "icc_import" // NEW: ICC revenue file uploads
| "reseller_cert" // NEW: Line 303 reseller certifications
| "lnpa_region" // NEW: Block 5 Lines 503-510 LNPA regions
| "block6_cert" // NEW: exemptions + nondisclosure + filing type
| "bdc_data"
| "stir_shaken"
| "calea"
| "foreign_carrier"
| "foreign_qual" // NEW: multi-state COA picker
| "dc_agent" // NEW: DC registered agent choice
| "cpni_questions" // NEW: CPNI certification questionnaire (sec 5-9)
| "cdr_period"
| "ocn"
| "review"
| "payment";
export const INTAKE_MANIFEST: Record<string, IntakeStep[]> = {
// Diagnostic — lightweight; just needs the entity
"fcc-compliance-checkup": ["entity", "review", "payment"],
// Entry-point filings
"cores-frn-registration": ["entity", "officer", "review", "payment"],
"fcc-499-initial": [
"entity", "classification", "category", "officer", "jurisdiction", "history",
"dc_agent", "block6_cert", "review", "payment",
],
// Annual + quarterly revenue filings — the full fidelity flow
"fcc-499a": [
"entity", "classification", "category", "officer", "jurisdiction", "history",
"dc_agent", "revenue", "bundled_service", "icc_import",
"reseller_cert", "lnpa_region",
"block6_cert", "review", "payment",
],
"fcc-499a-499q": [
"entity", "classification", "category", "officer", "jurisdiction",
"revenue", "icc_import",
"block6_cert", "review", "payment",
],
// RMD + CPNI + STIR/SHAKEN
"rmd-filing": ["entity", "officer", "stir_shaken", "review", "payment"],
"cpni-certification": ["entity", "officer", "cpni_questions", "review", "payment"],
"stir-shaken": ["entity", "stir_shaken", "review", "payment"],
// BDC tiers (formerly Form 477)
"bdc-broadband": ["entity", "bdc_data", "review", "payment"],
"bdc-voice": ["entity", "bdc_data", "review", "payment"],
"bdc-filing": ["entity", "bdc_data", "review", "payment"],
// CALEA + foreign-affiliation
"calea-ssi": ["entity", "category", "calea", "review", "payment"],
"fcc-63-11-notification": ["entity", "foreign_carrier", "review", "payment"],
// Supporting services
"ocn-registration": ["entity", "ocn", "review", "payment"],
"dc-agent": ["entity", "review", "payment"],
"cdr-analysis": ["entity", "cdr_period", "review", "payment"],
// Foreign qualification — Certificate of Authority across US states
"foreign-qualification-single": [
"entity", "foreign_qual", "review", "payment",
],
"foreign-qualification-multi": [
"entity", "foreign_qual", "review", "payment",
],
// Bundles
"fcc-full-compliance": [
"entity", "category", "officer", "jurisdiction", "history",
"revenue", "stir_shaken", "icc_import",
"reseller_cert", "lnpa_region", "block6_cert",
"review", "payment",
],
"new-carrier-bundle": [
"entity", "category", "officer", "jurisdiction", "history",
"dc_agent", "calea", "review", "payment",
],
};
// Category-gated dynamic steps. The Wizard inserts these after the `category`
// step when the corresponding Line 105 category is in line_105_categories.
export const CATEGORY_GATED_STEPS: Record<string, IntakeStep[]> = {
wireless: ["wireless"],
satellite: ["earth_station"],
mobile_satellite:["earth_station"],
private_line: ["earth_station"],
audio_bridging: ["audio_bridging"],
};
// ── Pricing + human name lookup (mirrors COMPLIANCE_SERVICES server-side)
export const SERVICE_META: Record<string, { name: string; price_cents: number }> = {
"fcc-compliance-checkup": { name: "FCC Carrier Compliance Checkup", price_cents: 79900 },
"fcc-499a": { name: "FCC Form 499-A Filing", price_cents: 49900 },
"fcc-499a-499q": { name: "FCC Form 499-A + 499-Q Bundle", price_cents: 59900 },
"fcc-full-compliance": { name: "FCC Full Compliance Bundle", price_cents: 149900 },
"cpni-certification": { name: "CPNI Annual Certification", price_cents: 14900 },
"rmd-filing": { name: "RMD Registration / Recertification", price_cents: 21900 }, // + $100 FCC filing fee passthrough
"stir-shaken": { name: "STIR/SHAKEN Implementation Assistance", price_cents: 49900 },
"dc-agent": { name: "D.C. Registered Agent (Annual)", price_cents: 9900 },
"bdc-broadband": { name: "BDC Broadband Deployment Filing", price_cents: 19900 },
"bdc-voice": { name: "BDC Voice Subscription (formerly Form 477)", price_cents: 14900 },
"bdc-filing": { name: "BDC Filing (Broadband + Voice)", price_cents: 29900 },
"ocn-registration": { name: "NECA OCN / Company Code Registration", price_cents: 65000 },
"cdr-analysis": { name: "CDR Traffic Study (Annual)", price_cents: 49900 },
"cores-frn-registration": { name: "CORES / FRN Registration", price_cents: 9900 },
"fcc-499-initial": { name: "Form 499 Initial Registration", price_cents: 29900 },
"calea-ssi": { name: "CALEA SSI Plan", price_cents: 29900 },
"fcc-63-11-notification": { name: "Foreign Carrier Affiliation Notification", price_cents: 34900 },
"new-carrier-bundle": { name: "New Carrier Onboarding Bundle", price_cents: 179900 },
"foreign-qualification-single": { name: "Foreign Qualification (One State)", price_cents: 14900 },
"foreign-qualification-multi": { name: "Foreign Qualification (Multi-State)", price_cents: 9900 },
};
export function formatUSD(cents: number): string {
return `$${(cents / 100).toLocaleString("en-US", {
minimumFractionDigits: 2, maximumFractionDigits: 2,
})}`;
}

View file

@ -0,0 +1,302 @@
// Line 105 Category Catalog — FCC Form 499-A Appendix B (2026 edition)
//
// The 2026 instructions enumerate 22 Line 105 categories, grouped in this
// file by how much intake flow each gets:
// * first_class — dedicated intake step, CPNI/CALEA variant, special
// revenue-line logic
// * subtype_of — not a standalone category; marks a CLEC/IXC/Wireless
// with infra_type=reseller/mvno, or Satellite with a
// subservice flag. form_499a.py still ticks the box on
// the form; handler behavior mirrors the parent.
// * flag_only — we tick the Line 105 box correctly, nothing else
// special (revenue flows through standard lines)
// * deprecated — admin ToDo only; we don't support new flows for
// these categories, too rare to prioritize
//
// The catalog drives CategoryStep.astro (ranked multi-select UI) and the
// handler's form-filling logic.
export type Line105Status =
| "first_class"
| "subtype_of"
| "flag_only"
| "deprecated";
export type Line105Grouping =
| "common" // shown expanded by default
| "carrier_grade" // collapsed; "expand to see more carrier types"
| "specialized" // collapsed; satellite / audio-bridging / MSS
| "rare"; // collapsed; "handled manually" warning
export interface Line105Entry {
id: string; // matches form_499a.py handler enum
label: string; // UI label
fcc_box_number: number; // the numbered box on the paper form
status: Line105Status;
grouping: Line105Grouping;
parent?: string; // when status=subtype_of, the parent category id
tooltip: string; // short plain-English help
reseller_offered?: boolean; // show "Do you own facilities or resell?" sub-question
tdm_offered?: boolean; // show "TDM or packet?" under reseller
mvno_offered?: boolean; // show "Do you own spectrum or are you an MVNO?"
}
export const LINE_105_CATALOG: Line105Entry[] = [
// ── COMMON (expanded by default) ───────────────────────────────────────
{
id: "voip_interconnected",
label: "Interconnected VoIP provider",
fcc_box_number: 1,
status: "first_class",
grouping: "common",
tooltip:
"VoIP service that interconnects with the PSTN and allows customers "
+ "to make and receive calls to/from ordinary phone numbers. Safe "
+ "harbor of 64.9% interstate available.",
},
{
id: "voip_non_interconnected",
label: "Non-interconnected (one-way) VoIP",
fcc_box_number: 2,
status: "first_class",
grouping: "common",
tooltip:
"VoIP that only makes OR only receives calls (not both). No safe "
+ "harbor — requires traffic study or actual billing data.",
},
{
id: "clec",
label: "Competitive Local Exchange Carrier (CLEC)",
fcc_box_number: 3,
status: "first_class",
grouping: "common",
tooltip:
"Competitive local carrier offering wireline telephone service in "
+ "direct competition with the incumbent (ILEC).",
reseller_offered: true,
tdm_offered: true,
},
{
id: "ixc",
label: "Interexchange Carrier (IXC)",
fcc_box_number: 7,
status: "first_class",
grouping: "common",
tooltip:
"Long-distance (toll) carrier. Access-charge collection matters here "
+ "— originating access and 8YY revenue belong on Line 404.x.",
reseller_offered: true,
tdm_offered: true,
},
{
id: "wireless",
label: "Wireless / Cellular / PCS (CMRS)",
fcc_box_number: 8,
status: "first_class",
grouping: "common",
tooltip:
"Commercial Mobile Radio Service — cellular, PCS. Safe harbor of "
+ "37.1% interstate. MVNOs are a subtype below.",
mvno_offered: true,
},
// ── CARRIER_GRADE (collapsed) ──────────────────────────────────────────
{
id: "ilec",
label: "Incumbent LEC (ILEC)",
fcc_box_number: 4,
status: "flag_only",
grouping: "carrier_grade",
tooltip:
"Historical local exchange carrier. We don't onboard ILECs — select "
+ "this and our team will reach out.",
},
{
id: "local_reseller",
label: "Local Reseller (TDM era)",
fcc_box_number: 5,
status: "subtype_of",
parent: "clec",
grouping: "carrier_grade",
tooltip:
"Historical category for carriers that buy wholesale from an ILEC "
+ "and rebrand. If this describes you, check CLEC above and set "
+ "infrastructure = reseller.",
},
{
id: "toll_reseller",
label: "Toll Reseller",
fcc_box_number: 6,
status: "subtype_of",
parent: "ixc",
grouping: "carrier_grade",
tooltip:
"Long-distance reseller buying wholesale minutes. Check IXC above "
+ "and set infrastructure = reseller.",
},
{
id: "private_line",
label: "Private Line / Business Data Service",
fcc_box_number: 11,
status: "first_class",
grouping: "carrier_grade",
tooltip:
"Dedicated point-to-point circuits (DS-1/DS-3/Ethernet). Includes "
+ "special-access and BDS services.",
},
{
id: "toll_free",
label: "Toll-Free (8YY) Service",
fcc_box_number: 17,
status: "flag_only",
grouping: "carrier_grade",
tooltip:
"Customer-paid-calling (8YY) service provider. 8YY originating "
+ "access revenue flows through ICC imports.",
},
// ── SPECIALIZED (collapsed) ─────────────────────────────────────────────
{
id: "mvno",
label: "Mobile Virtual Network Operator (MVNO)",
fcc_box_number: 9,
status: "subtype_of",
parent: "wireless",
grouping: "specialized",
tooltip:
"Wireless service that uses another carrier's network (e.g., buying "
+ "from T-Mobile / Verizon / AT&T). Check Wireless above and set "
+ "infrastructure = MVNO.",
},
{
id: "satellite",
label: "Satellite Service",
fcc_box_number: 12,
status: "first_class",
grouping: "specialized",
tooltip:
"Earth-station based satellite service (FSS / BSS). Requires FCC "
+ "Form 312 earth station license.",
},
{
id: "mobile_satellite",
label: "Mobile Satellite Service (MSS)",
fcc_box_number: 21,
status: "subtype_of",
parent: "satellite",
grouping: "specialized",
tooltip:
"Mobile satellite (Iridium, Globalstar, Starlink Commercial mobile). "
+ "Check Satellite above and set service_type=MSS.",
},
{
id: "audio_bridging",
label: "Audio Bridging / Conferencing Service",
fcc_box_number: 16,
status: "first_class",
grouping: "specialized",
tooltip:
"Multi-party audio conferencing provider. FCC clarified in 2021 "
+ "that bridging in interstate commerce is telecom service.",
},
{
id: "fixed_wireless",
label: "Fixed Wireless (non-mobile)",
fcc_box_number: 20,
status: "subtype_of",
parent: "clec",
grouping: "specialized",
tooltip:
"Fixed wireless last-mile local service. Check CLEC above and set "
+ "infrastructure = facilities + wireless_metadata.",
},
{
id: "prepaid_calling_card",
label: "Prepaid Calling Card",
fcc_box_number: 10,
status: "flag_only",
grouping: "specialized",
tooltip:
"Prepaid long-distance cards. Revenue goes on Line 411 with special "
+ "safe-harbor rules.",
},
// ── RARE / DEPRECATED (collapsed with warning) ─────────────────────────
{
id: "payphone",
label: "Payphone Service Provider (PSP)",
fcc_box_number: 13,
status: "deprecated",
grouping: "rare",
tooltip:
"Rare in 2026 — if this describes you, our team will handle manually.",
},
{
id: "osp",
label: "Operator Service Provider (OSP)",
fcc_box_number: 14,
status: "flag_only",
grouping: "rare",
tooltip: "Operator-assisted call service provider.",
},
{
id: "shared_tenant",
label: "Shared Tenant Services",
fcc_box_number: 15,
status: "flag_only",
grouping: "rare",
tooltip: "Shared tenant telecommunications (e.g., building-wide PBX resale).",
},
{
id: "paging",
label: "Paging / Messaging",
fcc_box_number: 18,
status: "deprecated",
grouping: "rare",
tooltip: "Standalone paging — rare in 2026. Our team will handle manually.",
},
{
id: "smr",
label: "Specialized Mobile Radio (SMR)",
fcc_box_number: 19,
status: "deprecated",
grouping: "rare",
tooltip: "Specialized mobile radio dispatch. Rare — our team will handle manually.",
},
{
id: "other",
label: "Other (describe separately)",
fcc_box_number: 22,
status: "flag_only",
grouping: "rare",
tooltip:
"Any category not listed above. Our team will follow up for a "
+ "free-text description required on the form.",
},
];
// Convenience lookups
export const LINE_105_BY_ID: Record<string, Line105Entry> = Object.fromEntries(
LINE_105_CATALOG.map((c) => [c.id, c]),
);
export const LINE_105_GROUPS: Record<Line105Grouping, Line105Entry[]> =
LINE_105_CATALOG.reduce((acc, entry) => {
(acc[entry.grouping] ||= []).push(entry);
return acc;
}, {} as Record<Line105Grouping, Line105Entry[]>);
// Map a category id → the extra Line 105 box that gets auto-ticked because
// of the infra_type/mvno flag. form_499a.py mirrors this logic.
export function derivedLine105Boxes(
categoryId: string,
infra_type?: string,
): number[] {
const boxes: number[] = [];
if (infra_type === "reseller") {
if (categoryId === "clec") boxes.push(5); // local_reseller
if (categoryId === "ixc") boxes.push(6); // toll_reseller
}
if (infra_type === "mvno" && categoryId === "wireless") boxes.push(9);
return boxes;
}

View file

@ -0,0 +1,113 @@
// LNPA Region Catalog — 10 regions for Form 499-A Block 5 Lines 503-510
//
// The NANPA/LNPA regions as defined by iconectiv NPAC. Every filer with
// telecom revenue must allocate Block 3 (carrier's carrier) and Block 4
// (end user) revenues across these regions as percentages summing to 100%
// per column.
//
// Used by LNPARegionStep.astro to render the 10-region grid.
export interface LnpaRegion {
code: string;
label: string;
states: string[]; // postal codes of states in the region
description: string;
}
export const LNPA_REGIONS: LnpaRegion[] = [
{
code: "NE",
label: "Northeast",
states: ["CT", "MA", "ME", "NH", "NY", "RI", "VT"],
description: "Connecticut, Massachusetts, Maine, New Hampshire, New York, Rhode Island, Vermont",
},
{
code: "MA",
label: "Mid-Atlantic",
states: ["DC", "DE", "MD", "NJ", "PA", "VA", "WV"],
description: "DC, Delaware, Maryland, New Jersey, Pennsylvania, Virginia, West Virginia",
},
{
code: "SE",
label: "Southeast",
states: ["AL", "FL", "GA", "KY", "MS", "NC", "SC", "TN"],
description: "Alabama, Florida, Georgia, Kentucky, Mississippi, North Carolina, South Carolina, Tennessee",
},
{
code: "SC",
label: "Southern / South Central",
states: ["AR", "LA", "NM", "OK"],
description: "Arkansas, Louisiana, New Mexico, Oklahoma",
},
{
code: "TX",
label: "Texas",
states: ["TX"],
description: "Texas",
},
{
code: "MW",
label: "Midwest",
states: ["IL", "IN", "MI", "OH", "WI"],
description: "Illinois, Indiana, Michigan, Ohio, Wisconsin",
},
{
code: "IA",
label: "Iowa / Plains",
states: ["IA", "KS", "MN", "MO", "ND", "NE", "SD"],
description: "Iowa, Kansas, Minnesota, Missouri, North Dakota, Nebraska, South Dakota",
},
{
code: "RM",
label: "Rocky Mountain",
states: ["CO", "ID", "MT", "UT", "WY"],
description: "Colorado, Idaho, Montana, Utah, Wyoming",
},
{
code: "NW",
label: "Northwest",
states: ["AK", "OR", "WA"],
description: "Alaska, Oregon, Washington",
},
{
code: "WC",
label: "West Coast",
states: ["AZ", "CA", "HI", "NV"],
description: "Arizona, California, Hawaii, Nevada",
},
];
export const LNPA_REGION_BY_CODE: Record<string, LnpaRegion> = Object.fromEntries(
LNPA_REGIONS.map((r) => [r.code, r]),
);
// Given a state postal code, return the LNPA region that contains it.
export function stateToRegion(state: string): string | null {
const upper = state.toUpperCase();
for (const r of LNPA_REGIONS) {
if (r.states.includes(upper)) return r.code;
}
return null;
}
// Convert a state → revenue map (e.g., from CDR billing state distribution)
// into a region → percentage map. Used by the "Paste from billing
// distribution" helper in LNPARegionStep.
export function stateDistributionToRegionPct(
stateRevenue: Record<string, number>,
): Record<string, number> {
const regionTotals: Record<string, number> = {};
let grandTotal = 0;
for (const [state, revenue] of Object.entries(stateRevenue)) {
const region = stateToRegion(state);
if (!region) continue;
regionTotals[region] = (regionTotals[region] || 0) + revenue;
grandTotal += revenue;
}
if (grandTotal === 0) return {};
const regionPct: Record<string, number> = {};
for (const r of LNPA_REGIONS) {
regionPct[r.code] = Math.round((regionTotals[r.code] || 0) / grandTotal * 10000) / 100;
}
return regionPct;
}

View file

@ -0,0 +1,79 @@
// Safe harbor recommender — client-side wrapper around
// GET /api/v1/fcc/safe-harbor-recommendation.
//
// Consumed by RevenueStep to display a contextual recommendation next
// to the method dropdown. The server does the actual comparison against
// the customer's CDR traffic study — this is just a thin fetch wrapper.
export type Recommendation =
| "safe_harbor_okay"
| "use_actual_saves"
| "use_actual_required"
| "no_actual_data"
| "not_allowed"
| "no_safe_harbor_for_category";
export interface SafeHarborRecommendation {
recommendation: Recommendation;
category: string;
year: number;
safe_harbor_pct: number | null;
actual_interstate_pct: number | null;
delta_percentage_points?: number;
estimated_usf_delta_cents?: number;
total_revenue_cents?: number;
study_methodology?: string | null;
total_classified_calls?: number;
message: string;
}
export async function fetchSafeHarborRecommendation(opts: {
profile_id?: number | null;
year: number;
category: string;
total_revenue_cents?: number;
}): Promise<SafeHarborRecommendation | null> {
const params = new URLSearchParams({
year: String(opts.year),
category: opts.category,
});
if (opts.profile_id) params.set("profile_id", String(opts.profile_id));
if (opts.total_revenue_cents) {
params.set("total_revenue_cents", String(opts.total_revenue_cents));
}
try {
const r = await fetch(`/api/v1/fcc/safe-harbor-recommendation?${params.toString()}`);
if (!r.ok) return null;
return await r.json();
} catch {
return null;
}
}
/** UI hint: banner color + preferred method for a recommendation. */
export function recommendationBadge(rec: Recommendation): {
tone: "ok" | "info" | "warn" | "danger";
suggest: "safe_harbor" | "actual_data" | "traffic_study" | null;
headline: string;
} {
switch (rec) {
case "safe_harbor_okay":
return { tone: "ok", suggest: "safe_harbor",
headline: "Safe harbor is a good choice here." };
case "use_actual_saves":
return { tone: "info", suggest: "actual_data",
headline: "Using actual data would save you money." };
case "use_actual_required":
return { tone: "warn", suggest: "traffic_study",
headline: "Safe harbor would under-report — switch to traffic study." };
case "no_actual_data":
return { tone: "info", suggest: "safe_harbor",
headline: "No traffic study — safe harbor is the sensible default." };
case "not_allowed":
return { tone: "danger", suggest: "traffic_study",
headline: "Safe harbor not allowed for this category." };
case "no_safe_harbor_for_category":
return { tone: "danger", suggest: "actual_data",
headline: "No safe harbor defined for this category / year." };
}
}

View file

@ -0,0 +1,41 @@
---
import Base from "../../layouts/Base.astro";
import Wizard from "../../components/intake/Wizard.astro";
import TaxDeductibilityNotice from "../../components/TaxDeductibilityNotice.astro";
import { INTAKE_MANIFEST, SERVICE_META, formatUSD } from "../../lib/intake_manifest";
const slug = "bdc-broadband";
const steps = INTAKE_MANIFEST[slug];
const meta = SERVICE_META[slug];
const title = meta ? `Order ${meta.name}` : "Order";
const description = "Availability data only (no voice subscription). For broadband-focused ISPs and fixed wireless providers.";
---
<Base title={title} description={description}>
<main>
<section class="pw-order-intro">
<h1>{meta?.name}</h1>
<p class="pw-price" id="pw-price">{formatUSD(meta?.price_cents ?? 0)}</p>
<div id="pw-tax-notice"><TaxDeductibilityNotice /></div>
<p class="pw-desc">{description}</p>
</section>
<Wizard service_slug={slug} steps={steps ?? ["entity", "review", "payment"]} title={meta?.name ?? slug} />
</main>
<script>
if (new URLSearchParams(window.location.search).has("token")) {
const p = document.getElementById("pw-price");
const t = document.getElementById("pw-tax-notice");
if (p) p.style.display = "none";
if (t) t.style.display = "none";
}
</script>
</Base>
<style>
main { max-width: 900px; margin: 0 auto; padding: 2rem 1.25rem 4rem; }
.pw-order-intro { margin-bottom: 1.5rem; }
.pw-order-intro h1 { margin: 0 0 0.25rem; color: var(--pw-navy, #1a2744); }
.pw-price { font-size: 1.5rem; font-weight: 700; color: var(--pw-green, #059669); margin: 0 0 0.75rem; }
.pw-desc { color: var(--pw-muted, #64748b); max-width: 48rem; }
</style>

View file

@ -0,0 +1,41 @@
---
import Base from "../../layouts/Base.astro";
import Wizard from "../../components/intake/Wizard.astro";
import TaxDeductibilityNotice from "../../components/TaxDeductibilityNotice.astro";
import { INTAKE_MANIFEST, SERVICE_META, formatUSD } from "../../lib/intake_manifest";
const slug = "bdc-filing";
const steps = INTAKE_MANIFEST[slug];
const meta = SERVICE_META[slug];
const title = meta ? `Order ${meta.name}` : "Order";
const description = "Both BDC blocks in one order — broadband deployment + voice subscription. For carriers with both.";
---
<Base title={title} description={description}>
<main>
<section class="pw-order-intro">
<h1>{meta?.name}</h1>
<p class="pw-price" id="pw-price">{formatUSD(meta?.price_cents ?? 0)}</p>
<div id="pw-tax-notice"><TaxDeductibilityNotice /></div>
<p class="pw-desc">{description}</p>
</section>
<Wizard service_slug={slug} steps={steps ?? ["entity", "review", "payment"]} title={meta?.name ?? slug} />
</main>
<script>
if (new URLSearchParams(window.location.search).has("token")) {
const p = document.getElementById("pw-price");
const t = document.getElementById("pw-tax-notice");
if (p) p.style.display = "none";
if (t) t.style.display = "none";
}
</script>
</Base>
<style>
main { max-width: 900px; margin: 0 auto; padding: 2rem 1.25rem 4rem; }
.pw-order-intro { margin-bottom: 1.5rem; }
.pw-order-intro h1 { margin: 0 0 0.25rem; color: var(--pw-navy, #1a2744); }
.pw-price { font-size: 1.5rem; font-weight: 700; color: var(--pw-green, #059669); margin: 0 0 0.75rem; }
.pw-desc { color: var(--pw-muted, #64748b); max-width: 48rem; }
</style>

View file

@ -0,0 +1,41 @@
---
import Base from "../../layouts/Base.astro";
import Wizard from "../../components/intake/Wizard.astro";
import TaxDeductibilityNotice from "../../components/TaxDeductibilityNotice.astro";
import { INTAKE_MANIFEST, SERVICE_META, formatUSD } from "../../lib/intake_manifest";
const slug = "bdc-voice";
const steps = INTAKE_MANIFEST[slug];
const meta = SERVICE_META[slug];
const title = meta ? `Order ${meta.name}` : "Order";
const description = "Voice subscriber counts only — the part of the legacy Form 477 that now files through BDC. For VoIP resellers + CLECs.";
---
<Base title={title} description={description}>
<main>
<section class="pw-order-intro">
<h1>{meta?.name}</h1>
<p class="pw-price" id="pw-price">{formatUSD(meta?.price_cents ?? 0)}</p>
<div id="pw-tax-notice"><TaxDeductibilityNotice /></div>
<p class="pw-desc">{description}</p>
</section>
<Wizard service_slug={slug} steps={steps ?? ["entity", "review", "payment"]} title={meta?.name ?? slug} />
</main>
<script>
if (new URLSearchParams(window.location.search).has("token")) {
const p = document.getElementById("pw-price");
const t = document.getElementById("pw-tax-notice");
if (p) p.style.display = "none";
if (t) t.style.display = "none";
}
</script>
</Base>
<style>
main { max-width: 900px; margin: 0 auto; padding: 2rem 1.25rem 4rem; }
.pw-order-intro { margin-bottom: 1.5rem; }
.pw-order-intro h1 { margin: 0 0 0.25rem; color: var(--pw-navy, #1a2744); }
.pw-price { font-size: 1.5rem; font-weight: 700; color: var(--pw-green, #059669); margin: 0 0 0.75rem; }
.pw-desc { color: var(--pw-muted, #64748b); max-width: 48rem; }
</style>

View file

@ -0,0 +1,41 @@
---
import Base from "../../layouts/Base.astro";
import Wizard from "../../components/intake/Wizard.astro";
import TaxDeductibilityNotice from "../../components/TaxDeductibilityNotice.astro";
import { INTAKE_MANIFEST, SERVICE_META, formatUSD } from "../../lib/intake_manifest";
const slug = "calea-ssi";
const steps = INTAKE_MANIFEST[slug];
const meta = SERVICE_META[slug];
const title = meta ? `Order ${meta.name}` : "Order";
const description = "System Security and Integrity plan required of every common carrier under 47 USC § 229 / 47 CFR § 1.20003. Kept internally, provided to DOJ on subpoena.";
---
<Base title={title} description={description}>
<main>
<section class="pw-order-intro">
<h1>{meta?.name}</h1>
<p class="pw-price" id="pw-price">{formatUSD(meta?.price_cents ?? 0)}</p>
<div id="pw-tax-notice"><TaxDeductibilityNotice /></div>
<p class="pw-desc">{description}</p>
</section>
<Wizard service_slug={slug} steps={steps ?? ["entity", "review", "payment"]} title={meta?.name ?? slug} />
</main>
<script>
if (new URLSearchParams(window.location.search).has("token")) {
const p = document.getElementById("pw-price");
const t = document.getElementById("pw-tax-notice");
if (p) p.style.display = "none";
if (t) t.style.display = "none";
}
</script>
</Base>
<style>
main { max-width: 900px; margin: 0 auto; padding: 2rem 1.25rem 4rem; }
.pw-order-intro { margin-bottom: 1.5rem; }
.pw-order-intro h1 { margin: 0 0 0.25rem; color: var(--pw-navy, #1a2744); }
.pw-price { font-size: 1.5rem; font-weight: 700; color: var(--pw-green, #059669); margin: 0 0 0.75rem; }
.pw-desc { color: var(--pw-muted, #64748b); max-width: 48rem; }
</style>

View file

@ -0,0 +1,41 @@
---
import Base from "../../layouts/Base.astro";
import Wizard from "../../components/intake/Wizard.astro";
import TaxDeductibilityNotice from "../../components/TaxDeductibilityNotice.astro";
import { INTAKE_MANIFEST, SERVICE_META, formatUSD } from "../../lib/intake_manifest";
const slug = "cdr-analysis";
const steps = INTAKE_MANIFEST[slug];
const meta = SERVICE_META[slug];
const title = meta ? `Order ${meta.name}` : "Order";
const description = "Classified traffic study from your CDRs — feeds the 499-A with actual interstate/international percentages instead of the 64.9% safe harbor.";
---
<Base title={title} description={description}>
<main>
<section class="pw-order-intro">
<h1>{meta?.name}</h1>
<p class="pw-price" id="pw-price">{formatUSD(meta?.price_cents ?? 0)}</p>
<div id="pw-tax-notice"><TaxDeductibilityNotice /></div>
<p class="pw-desc">{description}</p>
</section>
<Wizard service_slug={slug} steps={steps ?? ["entity", "review", "payment"]} title={meta?.name ?? slug} />
</main>
<script>
if (new URLSearchParams(window.location.search).has("token")) {
const p = document.getElementById("pw-price");
const t = document.getElementById("pw-tax-notice");
if (p) p.style.display = "none";
if (t) t.style.display = "none";
}
</script>
</Base>
<style>
main { max-width: 900px; margin: 0 auto; padding: 2rem 1.25rem 4rem; }
.pw-order-intro { margin-bottom: 1.5rem; }
.pw-order-intro h1 { margin: 0 0 0.25rem; color: var(--pw-navy, #1a2744); }
.pw-price { font-size: 1.5rem; font-weight: 700; color: var(--pw-green, #059669); margin: 0 0 0.75rem; }
.pw-desc { color: var(--pw-muted, #64748b); max-width: 48rem; }
</style>

View file

@ -0,0 +1,41 @@
---
import Base from "../../layouts/Base.astro";
import Wizard from "../../components/intake/Wizard.astro";
import TaxDeductibilityNotice from "../../components/TaxDeductibilityNotice.astro";
import { INTAKE_MANIFEST, SERVICE_META, formatUSD } from "../../lib/intake_manifest";
const slug = "cores-frn-registration";
const steps = INTAKE_MANIFEST[slug];
const meta = SERVICE_META[slug];
const title = meta ? `Order ${meta.name}` : "Order";
const description = "Register your carrier in FCC CORES and obtain your FRN. Required before any other FCC filing.";
---
<Base title={title} description={description}>
<main>
<section class="pw-order-intro">
<h1>{meta?.name}</h1>
<p class="pw-price" id="pw-price">{formatUSD(meta?.price_cents ?? 0)}</p>
<div id="pw-tax-notice"><TaxDeductibilityNotice /></div>
<p class="pw-desc">{description}</p>
</section>
<Wizard service_slug={slug} steps={steps ?? ["entity", "review", "payment"]} title={meta?.name ?? slug} />
</main>
<script>
if (new URLSearchParams(window.location.search).has("token")) {
const p = document.getElementById("pw-price");
const t = document.getElementById("pw-tax-notice");
if (p) p.style.display = "none";
if (t) t.style.display = "none";
}
</script>
</Base>
<style>
main { max-width: 900px; margin: 0 auto; padding: 2rem 1.25rem 4rem; }
.pw-order-intro { margin-bottom: 1.5rem; }
.pw-order-intro h1 { margin: 0 0 0.25rem; color: var(--pw-navy, #1a2744); }
.pw-price { font-size: 1.5rem; font-weight: 700; color: var(--pw-green, #059669); margin: 0 0 0.75rem; }
.pw-desc { color: var(--pw-muted, #64748b); max-width: 48rem; }
</style>

View file

@ -0,0 +1,41 @@
---
import Base from "../../layouts/Base.astro";
import Wizard from "../../components/intake/Wizard.astro";
import TaxDeductibilityNotice from "../../components/TaxDeductibilityNotice.astro";
import { INTAKE_MANIFEST, SERVICE_META, formatUSD } from "../../lib/intake_manifest";
const slug = "cpni-certification";
const steps = INTAKE_MANIFEST[slug];
const meta = SERVICE_META[slug];
const title = meta ? `Order ${meta.name}` : "Order";
const description = "47 CFR § 64.2009 annual CPNI certification filed at FCC ECFS docket 06-36. Due March 1.";
---
<Base title={title} description={description}>
<main>
<section class="pw-order-intro">
<h1>{meta?.name}</h1>
<p class="pw-price" id="pw-price">{formatUSD(meta?.price_cents ?? 0)}</p>
<div id="pw-tax-notice"><TaxDeductibilityNotice /></div>
<p class="pw-desc">{description}</p>
</section>
<Wizard service_slug={slug} steps={steps ?? ["entity", "review", "payment"]} title={meta?.name ?? slug} />
</main>
<script>
// Always hide price + tax on intake pages — if the user is here, they've
// already paid via the order page or batch checkout.
const price = document.getElementById("pw-price");
const tax = document.getElementById("pw-tax-notice");
if (price) price.style.display = "none";
if (tax) tax.style.display = "none";
</script>
</Base>
<style>
main { max-width: 900px; margin: 0 auto; padding: 2rem 1.25rem 4rem; }
.pw-order-intro { margin-bottom: 1.5rem; }
.pw-order-intro h1 { margin: 0 0 0.25rem; color: var(--pw-navy, #1a2744); }
.pw-price { font-size: 1.5rem; font-weight: 700; color: var(--pw-green, #059669); margin: 0 0 0.75rem; }
.pw-desc { color: var(--pw-muted, #64748b); max-width: 48rem; }
</style>

View file

@ -0,0 +1,41 @@
---
import Base from "../../layouts/Base.astro";
import Wizard from "../../components/intake/Wizard.astro";
import TaxDeductibilityNotice from "../../components/TaxDeductibilityNotice.astro";
import { INTAKE_MANIFEST, SERVICE_META, formatUSD } from "../../lib/intake_manifest";
const slug = "dc-agent";
const steps = INTAKE_MANIFEST[slug];
const meta = SERVICE_META[slug];
const title = meta ? `Order ${meta.name}` : "Order";
const description = "Your required D.C. registered agent for service of process (47 CFR § 413). Uses Northwest Registered Agent wholesale.";
---
<Base title={title} description={description}>
<main>
<section class="pw-order-intro">
<h1>{meta?.name}</h1>
<p class="pw-price" id="pw-price">{formatUSD(meta?.price_cents ?? 0)}</p>
<div id="pw-tax-notice"><TaxDeductibilityNotice /></div>
<p class="pw-desc">{description}</p>
</section>
<Wizard service_slug={slug} steps={steps ?? ["entity", "review", "payment"]} title={meta?.name ?? slug} />
</main>
<script>
if (new URLSearchParams(window.location.search).has("token")) {
const p = document.getElementById("pw-price");
const t = document.getElementById("pw-tax-notice");
if (p) p.style.display = "none";
if (t) t.style.display = "none";
}
</script>
</Base>
<style>
main { max-width: 900px; margin: 0 auto; padding: 2rem 1.25rem 4rem; }
.pw-order-intro { margin-bottom: 1.5rem; }
.pw-order-intro h1 { margin: 0 0 0.25rem; color: var(--pw-navy, #1a2744); }
.pw-price { font-size: 1.5rem; font-weight: 700; color: var(--pw-green, #059669); margin: 0 0 0.75rem; }
.pw-desc { color: var(--pw-muted, #64748b); max-width: 48rem; }
</style>

View file

@ -0,0 +1,41 @@
---
import Base from "../../layouts/Base.astro";
import Wizard from "../../components/intake/Wizard.astro";
import TaxDeductibilityNotice from "../../components/TaxDeductibilityNotice.astro";
import { INTAKE_MANIFEST, SERVICE_META, formatUSD } from "../../lib/intake_manifest";
const slug = "fcc-499-initial";
const steps = INTAKE_MANIFEST[slug];
const meta = SERVICE_META[slug];
const title = meta ? `Order ${meta.name}` : "Order";
const description = "New carrier registration with USAC — obtain your Filer ID (812xxx) and unlock all future 499-A filings.";
---
<Base title={title} description={description}>
<main>
<section class="pw-order-intro">
<h1>{meta?.name}</h1>
<p class="pw-price" id="pw-price">{formatUSD(meta?.price_cents ?? 0)}</p>
<div id="pw-tax-notice"><TaxDeductibilityNotice /></div>
<p class="pw-desc">{description}</p>
</section>
<Wizard service_slug={slug} steps={steps ?? ["entity", "review", "payment"]} title={meta?.name ?? slug} />
</main>
<script>
if (new URLSearchParams(window.location.search).has("token")) {
const p = document.getElementById("pw-price");
const t = document.getElementById("pw-tax-notice");
if (p) p.style.display = "none";
if (t) t.style.display = "none";
}
</script>
</Base>
<style>
main { max-width: 900px; margin: 0 auto; padding: 2rem 1.25rem 4rem; }
.pw-order-intro { margin-bottom: 1.5rem; }
.pw-order-intro h1 { margin: 0 0 0.25rem; color: var(--pw-navy, #1a2744); }
.pw-price { font-size: 1.5rem; font-weight: 700; color: var(--pw-green, #059669); margin: 0 0 0.75rem; }
.pw-desc { color: var(--pw-muted, #64748b); max-width: 48rem; }
</style>

View file

@ -0,0 +1,41 @@
---
import Base from "../../layouts/Base.astro";
import Wizard from "../../components/intake/Wizard.astro";
import TaxDeductibilityNotice from "../../components/TaxDeductibilityNotice.astro";
import { INTAKE_MANIFEST, SERVICE_META, formatUSD } from "../../lib/intake_manifest";
const slug = "fcc-499a-499q";
const steps = INTAKE_MANIFEST[slug];
const meta = SERVICE_META[slug];
const title = meta ? `Order ${meta.name}` : "Order";
const description = "Annual 499-A plus the four quarterly 499-Q filings — one bundle, full year covered.";
---
<Base title={title} description={description}>
<main>
<section class="pw-order-intro">
<h1>{meta?.name}</h1>
<p class="pw-price" id="pw-price">{formatUSD(meta?.price_cents ?? 0)}</p>
<div id="pw-tax-notice"><TaxDeductibilityNotice /></div>
<p class="pw-desc">{description}</p>
</section>
<Wizard service_slug={slug} steps={steps ?? ["entity", "review", "payment"]} title={meta?.name ?? slug} />
</main>
<script>
if (new URLSearchParams(window.location.search).has("token")) {
const p = document.getElementById("pw-price");
const t = document.getElementById("pw-tax-notice");
if (p) p.style.display = "none";
if (t) t.style.display = "none";
}
</script>
</Base>
<style>
main { max-width: 900px; margin: 0 auto; padding: 2rem 1.25rem 4rem; }
.pw-order-intro { margin-bottom: 1.5rem; }
.pw-order-intro h1 { margin: 0 0 0.25rem; color: var(--pw-navy, #1a2744); }
.pw-price { font-size: 1.5rem; font-weight: 700; color: var(--pw-green, #059669); margin: 0 0 0.75rem; }
.pw-desc { color: var(--pw-muted, #64748b); max-width: 48rem; }
</style>

View file

@ -0,0 +1,41 @@
---
import Base from "../../layouts/Base.astro";
import Wizard from "../../components/intake/Wizard.astro";
import TaxDeductibilityNotice from "../../components/TaxDeductibilityNotice.astro";
import { INTAKE_MANIFEST, SERVICE_META, formatUSD } from "../../lib/intake_manifest";
const slug = "fcc-499a";
const steps = INTAKE_MANIFEST[slug];
const meta = SERVICE_META[slug];
const title = meta ? `Order ${meta.name}` : "Order";
const description = "Annual Telecommunications Reporting Worksheet. Due April 1 for the prior year's revenue.";
---
<Base title={title} description={description}>
<main>
<section class="pw-order-intro">
<h1>{meta?.name}</h1>
<p class="pw-price" id="pw-price">{formatUSD(meta?.price_cents ?? 0)}</p>
<div id="pw-tax-notice"><TaxDeductibilityNotice /></div>
<p class="pw-desc">{description}</p>
</section>
<Wizard service_slug={slug} steps={steps ?? ["entity", "review", "payment"]} title={meta?.name ?? slug} />
</main>
<script>
if (new URLSearchParams(window.location.search).has("token")) {
const p = document.getElementById("pw-price");
const t = document.getElementById("pw-tax-notice");
if (p) p.style.display = "none";
if (t) t.style.display = "none";
}
</script>
</Base>
<style>
main { max-width: 900px; margin: 0 auto; padding: 2rem 1.25rem 4rem; }
.pw-order-intro { margin-bottom: 1.5rem; }
.pw-order-intro h1 { margin: 0 0 0.25rem; color: var(--pw-navy, #1a2744); }
.pw-price { font-size: 1.5rem; font-weight: 700; color: var(--pw-green, #059669); margin: 0 0 0.75rem; }
.pw-desc { color: var(--pw-muted, #64748b); max-width: 48rem; }
</style>

View file

@ -0,0 +1,41 @@
---
import Base from "../../layouts/Base.astro";
import Wizard from "../../components/intake/Wizard.astro";
import TaxDeductibilityNotice from "../../components/TaxDeductibilityNotice.astro";
import { INTAKE_MANIFEST, SERVICE_META, formatUSD } from "../../lib/intake_manifest";
const slug = "fcc-63-11-notification";
const steps = INTAKE_MANIFEST[slug];
const meta = SERVICE_META[slug];
const title = meta ? `Order ${meta.name}` : "Order";
const description = "47 CFR § 63.11 notification filed with the FCC International Bureau. Required when U.S. carriers are affiliated with a foreign carrier serving the same route.";
---
<Base title={title} description={description}>
<main>
<section class="pw-order-intro">
<h1>{meta?.name}</h1>
<p class="pw-price" id="pw-price">{formatUSD(meta?.price_cents ?? 0)}</p>
<div id="pw-tax-notice"><TaxDeductibilityNotice /></div>
<p class="pw-desc">{description}</p>
</section>
<Wizard service_slug={slug} steps={steps ?? ["entity", "review", "payment"]} title={meta?.name ?? slug} />
</main>
<script>
if (new URLSearchParams(window.location.search).has("token")) {
const p = document.getElementById("pw-price");
const t = document.getElementById("pw-tax-notice");
if (p) p.style.display = "none";
if (t) t.style.display = "none";
}
</script>
</Base>
<style>
main { max-width: 900px; margin: 0 auto; padding: 2rem 1.25rem 4rem; }
.pw-order-intro { margin-bottom: 1.5rem; }
.pw-order-intro h1 { margin: 0 0 0.25rem; color: var(--pw-navy, #1a2744); }
.pw-price { font-size: 1.5rem; font-weight: 700; color: var(--pw-green, #059669); margin: 0 0 0.75rem; }
.pw-desc { color: var(--pw-muted, #64748b); max-width: 48rem; }
</style>

View file

@ -0,0 +1,41 @@
---
import Base from "../../layouts/Base.astro";
import Wizard from "../../components/intake/Wizard.astro";
import TaxDeductibilityNotice from "../../components/TaxDeductibilityNotice.astro";
import { INTAKE_MANIFEST, SERVICE_META, formatUSD } from "../../lib/intake_manifest";
const slug = "fcc-compliance-checkup";
const steps = INTAKE_MANIFEST[slug];
const meta = SERVICE_META[slug];
const title = meta ? `Order ${meta.name}` : "Order";
const description = "Diagnostic check — CORES, RMD, STIR/SHAKEN, CPNI, 499-A status. No filing, just a report.";
---
<Base title={title} description={description}>
<main>
<section class="pw-order-intro">
<h1>{meta?.name}</h1>
<p class="pw-price" id="pw-price">{formatUSD(meta?.price_cents ?? 0)}</p>
<div id="pw-tax-notice"><TaxDeductibilityNotice /></div>
<p class="pw-desc">{description}</p>
</section>
<Wizard service_slug={slug} steps={steps ?? ["entity", "review", "payment"]} title={meta?.name ?? slug} />
</main>
<script>
if (new URLSearchParams(window.location.search).has("token")) {
const p = document.getElementById("pw-price");
const t = document.getElementById("pw-tax-notice");
if (p) p.style.display = "none";
if (t) t.style.display = "none";
}
</script>
</Base>
<style>
main { max-width: 900px; margin: 0 auto; padding: 2rem 1.25rem 4rem; }
.pw-order-intro { margin-bottom: 1.5rem; }
.pw-order-intro h1 { margin: 0 0 0.25rem; color: var(--pw-navy, #1a2744); }
.pw-price { font-size: 1.5rem; font-weight: 700; color: var(--pw-green, #059669); margin: 0 0 0.75rem; }
.pw-desc { color: var(--pw-muted, #64748b); max-width: 48rem; }
</style>

View file

@ -0,0 +1,41 @@
---
import Base from "../../layouts/Base.astro";
import Wizard from "../../components/intake/Wizard.astro";
import TaxDeductibilityNotice from "../../components/TaxDeductibilityNotice.astro";
import { INTAKE_MANIFEST, SERVICE_META, formatUSD } from "../../lib/intake_manifest";
const slug = "fcc-full-compliance";
const steps = INTAKE_MANIFEST[slug];
const meta = SERVICE_META[slug];
const title = meta ? `Order ${meta.name}` : "Order";
const description = "RMD + CPNI + STIR/SHAKEN + 499-A + 499-Q in one order. Every annual filing, 15% bundle discount.";
---
<Base title={title} description={description}>
<main>
<section class="pw-order-intro">
<h1>{meta?.name}</h1>
<p class="pw-price" id="pw-price">{formatUSD(meta?.price_cents ?? 0)}</p>
<div id="pw-tax-notice"><TaxDeductibilityNotice /></div>
<p class="pw-desc">{description}</p>
</section>
<Wizard service_slug={slug} steps={steps ?? ["entity", "review", "payment"]} title={meta?.name ?? slug} />
</main>
<script>
if (new URLSearchParams(window.location.search).has("token")) {
const p = document.getElementById("pw-price");
const t = document.getElementById("pw-tax-notice");
if (p) p.style.display = "none";
if (t) t.style.display = "none";
}
</script>
</Base>
<style>
main { max-width: 900px; margin: 0 auto; padding: 2rem 1.25rem 4rem; }
.pw-order-intro { margin-bottom: 1.5rem; }
.pw-order-intro h1 { margin: 0 0 0.25rem; color: var(--pw-navy, #1a2744); }
.pw-price { font-size: 1.5rem; font-weight: 700; color: var(--pw-green, #059669); margin: 0 0 0.75rem; }
.pw-desc { color: var(--pw-muted, #64748b); max-width: 48rem; }
</style>

View file

@ -0,0 +1,70 @@
---
import Base from "../../layouts/Base.astro";
import Wizard from "../../components/intake/Wizard.astro";
import TaxDeductibilityNotice from "../../components/TaxDeductibilityNotice.astro";
import { INTAKE_MANIFEST, SERVICE_META, formatUSD } from "../../lib/intake_manifest";
// URL param ?multi=1 selects the multi-state discounted SKU.
const isMulti = Astro.url.searchParams.get("multi") === "1";
const slug = isMulti ? "foreign-qualification-multi" : "foreign-qualification-single";
const steps = INTAKE_MANIFEST[slug];
const meta = SERVICE_META[slug];
const title = "Foreign Qualification (Certificate of Authority)";
const description =
"Register your existing LLC or corporation to do business in additional " +
"US states. Required when expanding operations, for FCC carriers serving " +
"multiple states, or for establishing nexus in a new jurisdiction.";
---
<Base title={title} description={description}>
<main>
<section class="pw-order-intro">
<h1>{title}</h1>
<p class="pw-price" id="pw-price">
Service fee: {formatUSD(meta?.price_cents ?? 0)}
{isMulti ? " per state" : ""}
<span class="pw-price-note"> + state filing fees + registered agent</span>
</p>
<div id="pw-tax-notice"><TaxDeductibilityNotice /></div>
<p class="pw-desc">{description}</p>
{!isMulti && (
<p class="pw-multi-cta">
Registering in multiple states?
<a href="/order/foreign-qualification?multi=1">
Switch to multi-state pricing ($99/state)
</a>
</p>
)}
{isMulti && (
<p class="pw-multi-cta">
Only need one state?
<a href="/order/foreign-qualification">
Switch to single-state pricing ($149)
</a>
</p>
)}
</section>
<Wizard service_slug={slug} steps={steps ?? ["entity", "foreign_qual", "review", "payment"]} title={title} />
</main>
<script>
if (new URLSearchParams(window.location.search).has("token")) {
const p = document.getElementById("pw-price");
const t = document.getElementById("pw-tax-notice");
if (p) p.style.display = "none";
if (t) t.style.display = "none";
}
</script>
</Base>
<style>
main { max-width: 900px; margin: 0 auto; padding: 2rem 1.25rem 4rem; }
.pw-order-intro { margin-bottom: 1.5rem; }
.pw-order-intro h1 { margin: 0 0 0.25rem; color: var(--pw-navy, #1a2744); }
.pw-price { font-size: 1.5rem; font-weight: 700; color: var(--pw-green, #059669); margin: 0 0 0.5rem; }
.pw-price-note { font-size: 0.9rem; font-weight: 400; color: #64748b; }
.pw-desc { color: var(--pw-muted, #64748b); max-width: 48rem; }
.pw-multi-cta { font-size: 0.9rem; color: #475569; margin-top: 0.5rem; }
.pw-multi-cta a { color: #2563eb; text-decoration: underline; }
</style>

View file

@ -0,0 +1,41 @@
---
import Base from "../../layouts/Base.astro";
import Wizard from "../../components/intake/Wizard.astro";
import TaxDeductibilityNotice from "../../components/TaxDeductibilityNotice.astro";
import { INTAKE_MANIFEST, SERVICE_META, formatUSD } from "../../lib/intake_manifest";
const slug = "new-carrier-bundle";
const steps = INTAKE_MANIFEST[slug];
const meta = SERVICE_META[slug];
const title = meta ? `Order ${meta.name}` : "Order";
const description = "Start-to-finish for a brand-new VoIP carrier: FRN + 499 Initial + RMD + CPNI + CALEA SSI. Everything you need to legally start billing customers.";
---
<Base title={title} description={description}>
<main>
<section class="pw-order-intro">
<h1>{meta?.name}</h1>
<p class="pw-price" id="pw-price">{formatUSD(meta?.price_cents ?? 0)}</p>
<div id="pw-tax-notice"><TaxDeductibilityNotice /></div>
<p class="pw-desc">{description}</p>
</section>
<Wizard service_slug={slug} steps={steps ?? ["entity", "review", "payment"]} title={meta?.name ?? slug} />
</main>
<script>
if (new URLSearchParams(window.location.search).has("token")) {
const p = document.getElementById("pw-price");
const t = document.getElementById("pw-tax-notice");
if (p) p.style.display = "none";
if (t) t.style.display = "none";
}
</script>
</Base>
<style>
main { max-width: 900px; margin: 0 auto; padding: 2rem 1.25rem 4rem; }
.pw-order-intro { margin-bottom: 1.5rem; }
.pw-order-intro h1 { margin: 0 0 0.25rem; color: var(--pw-navy, #1a2744); }
.pw-price { font-size: 1.5rem; font-weight: 700; color: var(--pw-green, #059669); margin: 0 0 0.75rem; }
.pw-desc { color: var(--pw-muted, #64748b); max-width: 48rem; }
</style>

View file

@ -0,0 +1,41 @@
---
import Base from "../../layouts/Base.astro";
import Wizard from "../../components/intake/Wizard.astro";
import TaxDeductibilityNotice from "../../components/TaxDeductibilityNotice.astro";
import { INTAKE_MANIFEST, SERVICE_META, formatUSD } from "../../lib/intake_manifest";
const slug = "ocn-registration";
const steps = INTAKE_MANIFEST[slug];
const meta = SERVICE_META[slug];
const title = meta ? `Order ${meta.name}` : "Order";
const description = "Obtain an Operating Company Number from NECA. Required for STIR/SHAKEN signing authority and direct numbering access.";
---
<Base title={title} description={description}>
<main>
<section class="pw-order-intro">
<h1>{meta?.name}</h1>
<p class="pw-price" id="pw-price">{formatUSD(meta?.price_cents ?? 0)}</p>
<div id="pw-tax-notice"><TaxDeductibilityNotice /></div>
<p class="pw-desc">{description}</p>
</section>
<Wizard service_slug={slug} steps={steps ?? ["entity", "review", "payment"]} title={meta?.name ?? slug} />
</main>
<script>
if (new URLSearchParams(window.location.search).has("token")) {
const p = document.getElementById("pw-price");
const t = document.getElementById("pw-tax-notice");
if (p) p.style.display = "none";
if (t) t.style.display = "none";
}
</script>
</Base>
<style>
main { max-width: 900px; margin: 0 auto; padding: 2rem 1.25rem 4rem; }
.pw-order-intro { margin-bottom: 1.5rem; }
.pw-order-intro h1 { margin: 0 0 0.25rem; color: var(--pw-navy, #1a2744); }
.pw-price { font-size: 1.5rem; font-weight: 700; color: var(--pw-green, #059669); margin: 0 0 0.75rem; }
.pw-desc { color: var(--pw-muted, #64748b); max-width: 48rem; }
</style>

View file

@ -0,0 +1,46 @@
---
import Base from "../../layouts/Base.astro";
import Wizard from "../../components/intake/Wizard.astro";
import TaxDeductibilityNotice from "../../components/TaxDeductibilityNotice.astro";
import { INTAKE_MANIFEST, SERVICE_META, formatUSD } from "../../lib/intake_manifest";
const slug = "rmd-filing";
const steps = INTAKE_MANIFEST[slug];
const meta = SERVICE_META[slug];
const title = meta ? `Order ${meta.name}` : "Order";
const description = "Robocall Mitigation Database filing. Annual recertification required March 1 (March 2 in 2026).";
---
<Base title={title} description={description}>
<main>
<section class="pw-order-intro">
<h1>{meta?.name}</h1>
<p class="pw-price" id="pw-price">{formatUSD(meta?.price_cents ?? 0)} <span style="font-size:0.85rem;font-weight:400;color:#64748b;">+ $100 FCC filing fee</span></p>
<div id="pw-tax-notice"><TaxDeductibilityNotice /></div>
<p class="pw-desc">{description}</p>
<p style="font-size:0.82rem;color:#92400e;background:#fefce8;border-left:3px solid #f59e0b;padding:0.5rem 0.75rem;border-radius:0 4px 4px 0;margin-top:0.5rem;max-width:48rem;">
The FCC charges a $100 filing fee for RMD registrations and recertifications (effective 2025). This fee is passed through at cost and paid directly to the FCC during submission.
</p>
</section>
<Wizard service_slug={slug} steps={steps ?? ["entity", "review", "payment"]} title={meta?.name ?? slug} />
</main>
<script>
// Always hide price + tax + FCC fee note on intake pages — if the user
// is here, they've already paid via the order page or batch checkout.
const p = document.getElementById("pw-price");
const t = document.getElementById("pw-tax-notice");
const f = document.querySelector(".pw-order-intro p[style*='fefce8']") as HTMLElement;
if (p) p.style.display = "none";
if (t) t.style.display = "none";
if (f) f.style.display = "none";
</script>
</Base>
<style>
main { max-width: 900px; margin: 0 auto; padding: 2rem 1.25rem 4rem; }
.pw-order-intro { margin-bottom: 1.5rem; }
.pw-order-intro h1 { margin: 0 0 0.25rem; color: var(--pw-navy, #1a2744); }
.pw-price { font-size: 1.5rem; font-weight: 700; color: var(--pw-green, #059669); margin: 0 0 0.75rem; }
.pw-desc { color: var(--pw-muted, #64748b); max-width: 48rem; }
</style>

View file

@ -0,0 +1,41 @@
---
import Base from "../../layouts/Base.astro";
import Wizard from "../../components/intake/Wizard.astro";
import TaxDeductibilityNotice from "../../components/TaxDeductibilityNotice.astro";
import { INTAKE_MANIFEST, SERVICE_META, formatUSD } from "../../lib/intake_manifest";
const slug = "stir-shaken";
const steps = INTAKE_MANIFEST[slug];
const meta = SERVICE_META[slug];
const title = meta ? `Order ${meta.name}` : "Order";
const description = "Posture update + RMD refresh + STI-CA vendor coordination. For carriers moving from robocall-mitigation to partial implementation.";
---
<Base title={title} description={description}>
<main>
<section class="pw-order-intro">
<h1>{meta?.name}</h1>
<p class="pw-price" id="pw-price">{formatUSD(meta?.price_cents ?? 0)}</p>
<div id="pw-tax-notice"><TaxDeductibilityNotice /></div>
<p class="pw-desc">{description}</p>
</section>
<Wizard service_slug={slug} steps={steps ?? ["entity", "review", "payment"]} title={meta?.name ?? slug} />
</main>
<script>
if (new URLSearchParams(window.location.search).has("token")) {
const p = document.getElementById("pw-price");
const t = document.getElementById("pw-tax-notice");
if (p) p.style.display = "none";
if (t) t.style.display = "none";
}
</script>
</Base>
<style>
main { max-width: 900px; margin: 0 auto; padding: 2rem 1.25rem 4rem; }
.pw-order-intro { margin-bottom: 1.5rem; }
.pw-order-intro h1 { margin: 0 0 0.25rem; color: var(--pw-navy, #1a2744); }
.pw-price { font-size: 1.5rem; font-weight: 700; color: var(--pw-green, #059669); margin: 0 0 0.75rem; }
.pw-desc { color: var(--pw-muted, #64748b); max-width: 48rem; }
</style>

View file

@ -0,0 +1,309 @@
---
import Base from "../../../layouts/Base.astro";
const title = "499-A Traffic Study — Carrier CDR Data Upload & Classification";
const description = "Upload your call detail records, get a signed traffic study for your FCC Form 499-A with every jurisdiction classified — interstate, intrastate, international, indeterminate, wholesale vs. retail, and the Block 5 regional breakouts.";
---
<Base title={title} description={description}>
<main>
<div class="pw-hero">
<h1>499-A Traffic Study</h1>
<p style="font-size: 1.1rem; max-width: 42rem;">
Send us your call records. We classify every call for your FCC Form 499-A
and hand you a signed study you can file against &mdash; with a 5-year
audit trail, automatic regional breakouts, and a pre-filled 499-A worksheet.
</p>
</div>
<section id="why">
<h2>Why the numbers on your 499-A matter</h2>
<p>
USAC assesses Universal Service Fund contributions on the interstate and
international portion of your telecom revenue. Get the percentage wrong
on your 499-A and you either overpay (and over-charge your customers),
underpay (and invite an audit), or get hit with a monetary forfeiture.
For 2026, inaccurate filings can carry up to <strong>$251,322 per
violation</strong>.
</p>
<p>
Most small carriers default to the 64.9% interconnected-VoIP safe harbor
because computing the actual split is a pain: pull CDRs from the switch,
look up hundreds of area codes, allocate wholesale vs. retail, run the
regional breakouts, and keep the spreadsheet for five years in case of
audit. We do all of that for you, on autopilot, every quarter.
</p>
</section>
<section id="how-data-gets-to-us">
<h2>How you send us your data</h2>
<p>Pick whichever fits your operation &mdash; you can use more than one at the same time:</p>
<div class="pw-card">
<strong>Secure file drop (SFTP / FTPS)</strong>
<p style="margin: 0.4rem 0 0;">
Enable it in your portal and we generate a username and one-time
random password pointed at your own encrypted storage bucket. Works
with any standard SFTP or FTPS client or automation script. TLS-only.
</p>
</div>
<div class="pw-card">
<strong>We pull directly from your switch</strong>
<p style="margin: 0.4rem 0 0;">
Pick your switch from the dropdown and hand us credentials once; we
grab new CDRs on the schedule you choose. Supported platforms:
</p>
<ul style="margin-top: 0.5rem;">
<li>NetSapiens</li>
<li>Metaswitch (iCM / Provisioning Server)</li>
<li>FreeSWITCH</li>
<li>Asterisk / FreePBX / AsteriskNOW</li>
<li>Ribbon / Sonus SBC (EMA / PSX)</li>
<li>Sansay SBC (SSM)</li>
<li>Cisco BroadWorks</li>
<li>2600Hz Kazoo</li>
<li>Grandstream UCM (62xx / 63xx)</li>
<li>46Labs (Peering / NOVA)</li>
<li>SIP Navigator (Cataleya Orchid One)</li>
</ul>
<p style="margin: 0.5rem 0 0; color: var(--pw-muted); font-size: 0.9rem;">
Don&apos;t see yours? Pick <em>Other</em> and configure SFTP / FTP / FTPS /
HTTPS / S3 transport manually with any CDR format &mdash; we adapt to
whatever column layout your switch emits.
</p>
</div>
<div class="pw-card">
<strong>Drag-and-drop upload in your portal</strong>
<p style="margin: 0.4rem 0 0;">
Quickest path for one-off batches or small carriers. Files go
straight to an encrypted bucket &mdash; no email, no disks, no
PII in flight.
</p>
</div>
<div class="pw-card">
<strong>Real-time webhook</strong>
<p style="margin: 0.4rem 0 0;">
For platforms that emit per-call records as they happen, point the
stream at the webhook URL from your portal. Calls classify within
seconds of landing.
</p>
</div>
<p style="color: var(--pw-muted); font-size: 0.9rem;">
All transport is encrypted. Files are stored in an encrypted bucket
scoped to your account. Default retention is 5 years &mdash; matches
USAC&apos;s audit window per 47 CFR &sect; 54.711(a).
</p>
</section>
<section id="what-we-classify">
<h2>What we classify</h2>
<ul>
<li>
<strong>Interstate / intrastate / international</strong> &mdash; every
call, based on calling and called numbers. We use NANPA area-code
records and the FCC&apos;s country-code assignments.
</li>
<li>
<strong>Indeterminate</strong> &mdash; calls we can&apos;t resolve to
one state/country. Kept visible so you can see what&apos;s unclear
rather than silently miscounting. Common causes: anonymized caller,
unrouteable DID, switch-side caller-ID strip.
</li>
<li>
<strong>Wholesale vs. retail</strong> &mdash; fed by a one-time
mapping of your trunk groups and customer account IDs in the portal,
or by a per-row tag column if your CDR carries one.
</li>
<li>
<strong>Block 5 regional breakouts</strong> (Lines 503&ndash;510) &mdash;
produced <em>two ways</em>, by the caller&apos;s originating state
<em>and</em> by your customer&apos;s billing-address state. You (or we)
pick whichever the FCC has asked for at filing time.
</li>
</ul>
</section>
<section id="what-you-get">
<h2>What you get back</h2>
<ul>
<li>A <strong>signed traffic study</strong> (PDF + XLSX) with jurisdictional percentages, both regional breakouts, a methodology statement, sample size, and revenue-vs-minutes cross-check</li>
<li>A <strong>pre-filled 499-A workbook</strong> &mdash; Block 3, Block 4-A, and Block 5 cells populated from your actual data</li>
<li>A <strong>data-quality report</strong> listing anything we couldn&apos;t classify so you can fix switch issues before next quarter</li>
<li>A <strong>5-year retention guarantee</strong> on the underlying CDRs &mdash; ready for a USAC audit on 24 hours&apos; notice</li>
</ul>
</section>
<section id="revenue-first">
<h2>We bill USF by actual revenue when we can</h2>
<p>
USAC assesses on <strong>revenue</strong>, not minutes. If your CDRs carry
a per-call charge (most commercial switches do &mdash; Asterisk nibblebill,
NetSapiens <code>cost</code>, Ribbon <code>totalCharge</code>, etc.), we
compute your percentages from actual dollars. Minutes-weighted numbers
appear beside the revenue view as a sanity check.
</p>
<p>
Flat-rate line service (monthly seat pricing with no per-call charge) is
the one case where minutes-only estimation is the correct choice. That&apos;s
an explicit opt-in on your profile &mdash; we label the study accordingly
so the methodology section is defensible under audit.
</p>
</section>
<section id="benefits-vs-manual">
<h2>vs. doing it yourself</h2>
<table class="pw-pricing">
<thead>
<tr><th>Doing it yourself</th><th>With us</th></tr>
</thead>
<tbody>
<tr><td>Pull raw CDRs from the switch, wrestle with format changes</td><td>Automated the moment your switch drops a new file</td></tr>
<tr><td>Look up hundreds of area codes by hand, maintain a spreadsheet</td><td>Full NANPA lookup, updated continuously</td></tr>
<tr><td>Eyeball &ldquo;is this interstate?&rdquo; from phone numbers</td><td>Deterministic rules, same answer every quarter</td></tr>
<tr><td>Guess at the wholesale/retail split</td><td>Per-call bucketed from trunk-group mapping you set once</td></tr>
<tr><td>Use the 64.9% safe harbor even when your actual % is lower</td><td>Use your actual study; lower USF pass-through when math justifies it</td></tr>
<tr><td>Weight by minutes because it&apos;s easier</td><td>Weight by actual per-call revenue &mdash; the denominator USAC uses</td></tr>
<tr><td>No audit trail when USAC asks</td><td>Signed study with methodology + sample size, delivered quarterly</td></tr>
<tr><td>One-off Excel with formulas you&apos;ll forget in 12 months</td><td>Archived, dated, downloadable from your portal</td></tr>
</tbody>
</table>
</section>
<section id="mistakes">
<h2>Common mistakes we prevent</h2>
<ul>
<li><strong>Counting failed/abandoned calls as billable minutes</strong> &mdash; inflates the denominator; USAC fines follow</li>
<li><strong>Attestation confusion</strong> &mdash; treating all outbound as interstate when the answering point was actually intrastate</li>
<li><strong>Toll-free inversion</strong> &mdash; 8XX calls are paid by the called party; Block 5 attribution flips. Customers routinely put these in the wrong region.</li>
<li><strong>Time-zone drift</strong> &mdash; naive UTC-vs-local splits move calls across midnight boundaries and miscount a day&apos;s minutes</li>
<li><strong>Indeterminate calls hidden in &ldquo;interstate&rdquo;</strong> &mdash; bumps your USF contribution for minutes you shouldn&apos;t be paying on</li>
<li><strong>Stale trunk-group tags</strong> &mdash; a customer moves from wholesale to retail and nobody updates the spreadsheet; we re-prompt quarterly</li>
<li><strong>Safe harbor when actual interstate % is lower</strong> &mdash; overpaying into USF and over-charging end users</li>
<li><strong>Minutes-weighting a per-call carrier</strong> &mdash; a few long-international calls can swing a minutes ratio sharply away from the real revenue mix</li>
</ul>
</section>
<section id="cdr-fields">
<h2>What CDR fields we need</h2>
<p>No custom development on your side. If your CDR carries these fields, you&apos;re ready:</p>
<h3>Required &mdash; the system won&apos;t classify without these</h3>
<table>
<thead>
<tr><th>Field</th><th>Typical CSV header</th><th>Why we need it</th></tr>
</thead>
<tbody>
<tr><td>Call start time</td><td><code>start_time</code>, <code>calldate</code>, <code>start_stamp</code>, <code>initiated</code></td><td>Assigns the call to a reporting period and quarter</td></tr>
<tr><td>Calling number (ANI)</td><td><code>src</code>, <code>caller_id_number</code>, <code>orig_number</code>, <code>ani</code></td><td>Derives originating state via NPA</td></tr>
<tr><td>Called number (DNIS)</td><td><code>dst</code>, <code>destination_number</code>, <code>term_number</code>, <code>dnis</code></td><td>Derives terminating state</td></tr>
<tr><td>Call duration</td><td><code>duration</code>, <code>billsec</code>, <code>duration_ms</code>, <code>talk_time</code></td><td>Minutes-based weighting</td></tr>
</tbody>
</table>
<h3>Strongly preferred &mdash; makes the study revenue-accurate instead of minutes-estimated</h3>
<table>
<thead>
<tr><th>Field</th><th>Typical CSV header</th><th>What it enables</th></tr>
</thead>
<tbody>
<tr>
<td>Per-call billed amount</td>
<td><code>charge</code>, <code>cost</code>, <code>amount_billed</code>, <code>rate_charge</code>, <code>nibble_total_billed</code></td>
<td>
<strong>The primary revenue signal for the traffic study.</strong>
When present, we attribute USF by actual per-call revenue &mdash;
the denominator USAC actually uses. Without it, we fall back to
minutes-only estimation, which must be opted in on your profile.
</td>
</tr>
</tbody>
</table>
<h3>Helpful &mdash; quality of attribution and audit strength improves when present</h3>
<table>
<thead>
<tr><th>Field</th><th>Typical CSV header</th><th>What it enables</th></tr>
</thead>
<tbody>
<tr><td>Call direction</td><td><code>direction</code>, <code>call_direction</code>, <code>leg_type</code></td><td>Distinguishes outbound from inbound when ANI alone can&apos;t (toll-free inversion, etc.)</td></tr>
<tr><td>Disposition / answer indicator</td><td><code>disposition</code>, <code>hangup_cause</code>, <code>answered</code></td><td>Drops failed/abandoned calls so they don&apos;t skew minutes</td></tr>
<tr><td>Trunk group ID</td><td><code>trunk_group</code>, <code>trunk</code>, <code>carrier_id</code>, <code>channel_name</code> prefix</td><td>Primary key for wholesale/retail bucketing via the portal mapping</td></tr>
<tr><td>Customer / account ID</td><td><code>account_code</code>, <code>accountcode</code>, <code>customer_id</code>, <code>sip_account</code></td><td>Secondary bucketing key when trunk group isn&apos;t granular enough</td></tr>
<tr><td>Answer time + release time</td><td><code>answer_time</code>, <code>end_stamp</code>, <code>release_time</code></td><td>Cross-checks duration, detects zero-duration anomalies</td></tr>
<tr><td>Orig / term country</td><td><code>orig_country</code>, <code>term_country</code>, <code>country_code</code></td><td>Classifies international calls with ambiguous NANP derivation (Caribbean jurisdictions etc.)</td></tr>
<tr><td>Customer-type tag (if your switch can emit it)</td><td><code>customer_type</code>, <code>market_segment</code></td><td>Optional per-row override &mdash; wins over portal mapping</td></tr>
<tr><td>Rate center / LATA</td><td><code>rate_center</code>, <code>orig_lata</code>, <code>term_lata</code></td><td>Enables local-vs-intrastate-toll split (not required for 499-A)</td></tr>
</tbody>
</table>
<div class="pw-callout">
<strong>Bottom line:</strong> if your CDRs carry start time, ANI, DNIS,
and duration, you&apos;re ready. A per-call charge or cost column turns
the study into a revenue-weighted report, which is what USAC uses. Flat-rate
line services opt into minutes-only estimation &mdash; that&apos;s standard
for retail VoIP, and we note the methodology on every study.
</div>
</section>
<section id="pricing">
<h2>Storage, processing, and retention</h2>
<p>
Your 499-A filing service covers <strong>10 GB of compressed CDR
storage</strong> and <strong>10 million classified calls per reporting
year</strong>, with <strong>5-year retention</strong> &mdash; plenty for
most small-to-midsize carriers. Larger carriers pick the smallest tier
that covers their footprint:
</p>
<table class="pw-pricing">
<thead>
<tr><th>Plan</th><th>Storage</th><th>Calls / year</th><th>Annual</th></tr>
</thead>
<tbody>
<tr><td>Included with filing</td><td>10 GB</td><td>10 M</td><td>&mdash;</td></tr>
<tr><td>Storage Tier 1</td><td>50 GB</td><td>50 M</td><td>$99</td></tr>
<tr><td>Storage Tier 2</td><td>250 GB</td><td>250 M</td><td>$299</td></tr>
<tr><td>Storage Tier 3</td><td>1 TB</td><td>1 B</td><td>$799</td></tr>
<tr><td>Enterprise</td><td>Custom</td><td>Custom</td><td>Let&apos;s talk</td></tr>
</tbody>
</table>
<p>
Your portal shows a live usage meter. At 80% we email a heads-up; at
100% you pick (at setup) whether to keep ingesting and get the next
tier billed automatically, pause new uploads until you upgrade, or
notify-only. Prior years stay retained while you&apos;re on an active
plan; if you lapse, we send a 30-day notice before any deletion.
</p>
</section>
<section id="how-it-works-across-the-year">
<h2>How it works across a filing year</h2>
<p>
Data collection runs continuously &mdash; you can send us CDRs the day you
sign up and every quarter rolls into the year&apos;s dataset
automatically. The <strong>classified study for the current reporting
year</strong> becomes available once you&apos;ve signed up for that
year&apos;s 499-A filing service (or the standalone traffic study).
Prior years you&apos;ve filed with us stay open in your portal forever.
</p>
<p>
Until the current year is paid, you&apos;ll still see ingestion health
in the portal &mdash; upload counts, data-quality warnings, quarantine
reviews &mdash; you just won&apos;t see the final percentages or download
the signed study until the filing service is paid for. This is how we
keep the service sustainable without hourly billing.
</p>
</section>
<section id="cta">
<div class="pw-callout" style="border-color: var(--pw-green); background: #ecfdf5; text-align: center; padding: 1.5rem;">
<h3 style="margin: 0 0 0.5rem; color: var(--pw-navy);">Ready to stop using the safe harbor?</h3>
<p style="margin: 0 0 1rem;">Set up a profile, send us a sample CDR, and see your actual interstate percentage &mdash; usually in under an hour.</p>
<a class="pw-cta" href="/order/fcc-499a">Start your 2026 499-A filing →</a>
</div>
</section>
</main>
</Base>

View file

@ -0,0 +1,128 @@
---
import Base from "../../../layouts/Base.astro";
const title = "FCC Form 477 Filing — What Replaced It and What You Need Now";
const description = "The FCC retired Form 477 in December 2022. Voice subscription data now files through BDC. Here's what you actually need for 2026 and how we file it for you.";
---
<Base title={title} description={description}>
<main>
<section class="pw-hero">
<h1>FCC Form 477 Filing — Where Things Stand in 2026</h1>
<p style="font-size: 1.1rem; max-width: 44rem;">
The Form 477 you remember doesn&apos;t exist anymore. But the
<em>filing obligation</em> still does. Here&apos;s what changed and
what you actually need to file.
</p>
</section>
<section>
<h2>What happened to Form 477</h2>
<p>
The FCC retired the standalone Form 477 filing system on
<strong>December 31, 2022</strong>. All voice subscription data and
all broadband deployment data moved into the <strong>Broadband Data
Collection (BDC) system</strong>. Same deadlines (March 1 for
December-31 snapshots, September 1 for June-30 snapshots), same
required data points, different portal.
</p>
<div class="pw-callout">
<strong>Key point:</strong> if someone tells you to file Form 477
in 2026, they mean <em>file your voice-subscription data through BDC</em>.
The old apps.fcc.gov/form477 login page will redirect you to
bdc.fcc.gov — that&apos;s on purpose.
</div>
</section>
<section>
<h2>Who still has to file</h2>
<ul>
<li><strong>Facilities-based providers of fixed or mobile voice service</strong> — interconnected VoIP providers, CLECs, and wireline ILECs. File voice subscription counts.</li>
<li><strong>Facilities-based broadband ISPs</strong> — file availability data (by serviceable location) and subscription counts.</li>
<li><strong>Interconnected VoIP-only resellers</strong> — still required to file voice subscriber counts even though you don&apos;t own the underlying infrastructure.</li>
</ul>
</section>
<section>
<h2>Two ways we can file for you</h2>
<div class="pw-card-grid">
<a class="pw-card" href="/order/bdc-voice">
<h3>BDC Voice Subscription only</h3>
<p class="pw-card-price">$149 per filing</p>
<p>
The voice part of what used to be Form 477. Tell us your voice
subscriber count at the snapshot date; we file it.
</p>
<p class="pw-card-hint">
<em>Best for</em>: VoIP resellers, CLECs, competitive carriers
that don&apos;t offer broadband.
</p>
</a>
<a class="pw-card" href="/order/bdc-broadband">
<h3>BDC Broadband Deployment only</h3>
<p class="pw-card-price">$199 per filing</p>
<p>
Availability data — which locations you serve and at what speeds.
Upload your availability CSV; we validate and submit.
</p>
<p class="pw-card-hint">
<em>Best for</em>: ISPs, fixed wireless providers, broadband-only
companies.
</p>
</a>
<a class="pw-card" href="/order/bdc-filing">
<h3>Both — BDC Voice + Broadband</h3>
<p class="pw-card-price">$299 per filing</p>
<p>
One filing covers both blocks. Saves $49 vs. buying them
separately.
</p>
<p class="pw-card-hint">
<em>Best for</em>: full-service carriers offering both voice
and broadband.
</p>
</a>
</div>
</section>
<section>
<h2>Why you don&apos;t want to file this yourself</h2>
<ul>
<li><strong>BDC portal authentication</strong> — requires an authorized CORES session tied to your FRN; miss a step and you get locked out of your own filing.</li>
<li><strong>Availability CSV format is strict</strong> — the BDC system validates every row; a misformatted header or bad location ID rejects the entire upload.</li>
<li><strong>Missed filings</strong> — the FCC has begun issuing forfeitures to providers who miss BDC deadlines. Don&apos;t be the cautionary tale.</li>
<li><strong>Voice-only confusion</strong> — many voice-only VoIP resellers skip BDC because &quot;it&apos;s broadband data.&quot; Wrong. The voice subscription block is mandatory and lives in BDC now.</li>
</ul>
</section>
<section>
<h2>Deadlines for 2026</h2>
<table>
<thead><tr><th>Snapshot date</th><th>Filing deadline</th><th>What to file</th></tr></thead>
<tbody>
<tr><td>December 31, 2025</td><td>March 1, 2026</td><td>Voice subscribers + broadband availability + subscriber counts as of Dec 31, 2025</td></tr>
<tr><td>June 30, 2026</td><td>September 1, 2026</td><td>Voice subscribers + broadband availability + subscriber counts as of Jun 30, 2026</td></tr>
</tbody>
</table>
</section>
<section class="pw-cta-section">
<a class="pw-cta" href="/order/bdc-voice">File your BDC voice data (formerly Form 477) →</a>
</section>
</main>
</Base>
<style>
main { max-width: 900px; margin: 0 auto; padding: 2rem 1.25rem 4rem; }
.pw-hero { padding: 2.5rem 1.5rem; background: linear-gradient(135deg, #1a2744 0%, #2d4e78 100%); color: #fff; border-radius: 14px; margin-bottom: 2rem; }
.pw-hero h1 { color: #fff; margin: 0 0 0.5rem; }
.pw-callout { border-left: 4px solid #2d4e78; background: #f0f9ff; padding: 0.9rem 1.1rem; border-radius: 0 6px 6px 0; margin: 1rem 0; }
.pw-card-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 1rem; margin: 1rem 0; }
.pw-card { display: block; padding: 1.25rem; border: 1px solid #e2e8f0; border-radius: 10px; text-decoration: none; color: inherit; transition: box-shadow 0.15s; }
.pw-card:hover { box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); border-color: #2d4e78; }
.pw-card h3 { margin: 0 0 0.3rem; color: #1a2744; font-size: 1.05rem; }
.pw-card-price { font-weight: 700; color: #059669; margin: 0 0 0.5rem; font-size: 1rem; }
.pw-card-hint { color: #64748b; font-size: 0.85rem; margin-top: 0.5rem; }
.pw-cta-section { margin: 3rem 0; text-align: center; }
.pw-cta { display: inline-block; padding: 1rem 2rem; background: #059669; color: #fff; text-decoration: none; font-weight: 600; border-radius: 8px; font-size: 1.05rem; }
.pw-cta:hover { background: #047857; }
</style>

View file

@ -0,0 +1,622 @@
---
import Base from "../../layouts/Base.astro";
---
<Base
title="FCC Compliance Check — Free Telecom Filing Status"
description="Enter your FCC Registration Number (FRN) to check your RMD, CPNI, 499-A, and STIR/SHAKEN compliance status instantly. Free tool from Performance West."
>
<main class="max-w-3xl mx-auto px-4 py-10">
<!-- Breadcrumb -->
<nav class="text-sm text-gray-500 mb-6" aria-label="Breadcrumb">
<a href="/" class="hover:text-pw-600">Home</a>
<span class="mx-1">/</span>
<a href="/tools" class="hover:text-pw-600">Free Tools</a>
<span class="mx-1">/</span>
<span class="text-gray-800 font-medium">FCC Compliance Check</span>
</nav>
<!-- Header -->
<h1 class="text-3xl font-bold text-gray-900 mb-2">FCC Compliance Check</h1>
<p class="text-gray-600 mb-6">
Enter a business name or FRN below to instantly check FCC filing and compliance status.
</p>
<!-- Disclaimer -->
<div class="bg-gray-100 border border-gray-200 rounded-lg p-4 text-sm text-gray-600 mb-8">
This tool queries publicly available FCC data and is for informational purposes only.
This is not legal or compliance advice.
</div>
<!-- Search Box -->
<div class="bg-white border border-gray-200 rounded-xl p-6 shadow-sm mb-8">
<!-- Business Name Search -->
<label for="name-input" class="block text-sm font-medium text-gray-700 mb-1">Business Name</label>
<div class="flex gap-2 mb-2">
<input
type="text"
id="name-input"
placeholder="e.g. Verizon"
class="flex-1 border border-gray-300 rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-pw-500"
/>
<button
id="name-search-btn"
class="bg-pw-600 hover:bg-pw-700 text-white font-semibold px-5 py-2 rounded-lg text-sm transition"
>
Search
</button>
</div>
<div id="name-results" class="hidden mt-3"></div>
<!-- Divider -->
<div class="flex items-center gap-3 my-5">
<div class="flex-1 border-t border-gray-200"></div>
<span class="text-sm text-gray-400">or enter FRN directly</span>
<div class="flex-1 border-t border-gray-200"></div>
</div>
<!-- FRN Search -->
<label for="frn-input" class="block text-sm font-medium text-gray-700 mb-1">FCC Registration Number (FRN)</label>
<div class="flex gap-2 mb-2">
<input
type="text"
id="frn-input"
placeholder="0012345678"
maxlength="10"
class="flex-1 border border-gray-300 rounded-lg px-4 py-2 font-mono text-lg focus:outline-none focus:ring-2 focus:ring-pw-500"
/>
<button
id="frn-check-btn"
class="bg-gray-700 hover:bg-gray-800 text-white font-semibold px-5 py-2 rounded-lg text-sm transition"
>
Check Compliance
</button>
</div>
<p class="text-xs text-gray-400">
Don't know your FRN?
<a href="https://apps.fcc.gov/cores/userLogin.do" target="_blank" rel="noopener" class="text-pw-600 hover:underline">Look it up on FCC CORES</a>
</p>
</div>
<!-- Loading -->
<div id="loading" class="hidden text-center py-10">
<svg class="animate-spin h-8 w-8 text-pw-600 mx-auto mb-3" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"></path>
</svg>
<p class="text-gray-500 text-sm">Checking FCC databases&hellip;</p>
</div>
<!-- Error -->
<div id="error-box" class="hidden bg-red-50 border border-red-200 rounded-lg p-4 text-red-700 text-sm mb-6">
<p id="error-message"></p>
</div>
<!-- Results -->
<div id="results" class="hidden space-y-6">
<!-- Entity Header -->
<div class="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
<h2 id="entity-name" class="text-xl font-bold text-gray-900"></h2>
<p id="entity-dba" class="text-sm text-gray-500 hidden"></p>
<p id="entity-address" class="text-sm text-gray-500 mt-1"></p>
<p class="mt-2 text-sm text-gray-600">
FRN: <span id="entity-frn" class="font-mono font-semibold text-gray-800"></span>
</p>
<div id="entity-details" class="hidden mt-3 space-y-1 text-sm text-gray-600">
<p id="detail-filer-id"></p>
<p id="detail-comm-type"></p>
<p id="detail-rmd-number"></p>
<div id="detail-provider-types" class="flex flex-wrap gap-1 mt-1"></div>
<p id="detail-usf-contributor"></p>
<p id="detail-other-frns"></p>
<p id="detail-previous-names"></p>
</div>
</div>
<!-- Checks -->
<div id="checks-container" class="space-y-4"></div>
<!-- Prior-year catch-up -->
<div id="prior-year" class="hidden bg-red-50 border border-red-200 rounded-xl p-5 text-sm text-red-800">
<p class="font-semibold mb-1">Prior-Year Catch-Up May Be Required</p>
<p>One or more filings appear overdue. In some cases prior-year filings must be completed before the current year can be filed. Contact us for a full assessment.</p>
</div>
<!-- CTA Section -->
<div id="cta-section" class="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
<div id="cta-content"></div>
<!-- Payment Icons -->
<div id="payment-icons" class="hidden mt-5 flex flex-wrap items-center gap-3 justify-center">
<img src="/images/cards/visa.png" alt="Visa" class="h-7 opacity-70" />
<img src="/images/cards/mastercard.svg" alt="Mastercard" class="h-7 opacity-70" />
<img src="/images/cards/amex.png" alt="Amex" class="h-7 opacity-70" />
<img src="/images/cards/discover.svg" alt="Discover" class="h-7 opacity-70" />
<img src="/images/cards/paypal.svg" alt="PayPal" class="h-6 opacity-70" />
<span class="text-gray-300 mx-1">|</span>
<img src="/images/cards/bitcoin.svg" alt="Bitcoin" class="h-6 opacity-60" />
<img src="/images/cards/ethereum.svg" alt="Ethereum" class="h-6 opacity-60" />
<img src="/images/cards/usdc.svg" alt="USDC" class="h-6 opacity-60" />
<img src="/images/cards/usdt.svg" alt="USDT" class="h-6 opacity-60" />
<img src="/images/cards/litecoin.svg" alt="Litecoin" class="h-6 opacity-60" />
</div>
<!-- Reminder CTA (shown when all green) -->
<div id="reminder-cta" class="hidden mt-5 text-center">
<p class="text-sm text-gray-600 mb-3">Want a reminder before your next filing deadline?</p>
<button
id="subscribe-btn-compliance"
class="bg-pw-600 hover:bg-pw-700 text-white font-semibold px-6 py-2 rounded-lg text-sm transition"
>
Join Reminder List
</button>
</div>
<!-- Bottom row -->
<div class="mt-6 flex flex-wrap gap-3 justify-center text-sm">
<a href="/order/new-carrier" class="inline-flex items-center gap-1.5 bg-pw-600 hover:bg-pw-700 text-white font-semibold px-4 py-2 rounded-lg transition">
<img src="/images/flags/usa.png" alt="" class="h-4" />
Start a New US Carrier
</a>
<a href="/order/new-carrier-canada" class="inline-flex items-center gap-1.5 bg-red-600 hover:bg-red-700 text-white font-semibold px-4 py-2 rounded-lg transition">
<img src="/images/flags/canada.png" alt="" class="h-4" />
Start a New Canadian Carrier
</a>
<a href="/services" class="inline-flex items-center gap-1.5 border border-gray-300 text-gray-700 hover:bg-gray-50 font-semibold px-4 py-2 rounded-lg transition">
View All Telecom Services
</a>
</div>
</div>
<!-- Timestamp -->
<p id="checked-at" class="text-xs text-gray-400 text-center"></p>
</div>
</main>
<script type="module" is:inline>
const API = window.__PW_API || "https://api.performancewest.net";
const nameInput = document.getElementById("name-input");
const nameSearchBtn = document.getElementById("name-search-btn");
const nameResults = document.getElementById("name-results");
const frnInput = document.getElementById("frn-input");
const frnCheckBtn = document.getElementById("frn-check-btn");
const loadingEl = document.getElementById("loading");
const errorBox = document.getElementById("error-box");
const errorMessage = document.getElementById("error-message");
const resultsEl = document.getElementById("results");
// --- Name search ---
nameSearchBtn.addEventListener("click", runNameSearch);
nameInput.addEventListener("keydown", (e) => { if (e.key === "Enter") runNameSearch(); });
async function runNameSearch() {
const name = nameInput.value.trim();
if (!name) return;
nameResults.classList.remove("hidden");
nameResults.innerHTML = '<p class="text-sm text-gray-500">Searching&hellip;</p>';
try {
const res = await fetch(`${API}/api/v1/fcc/cores-search?name=${encodeURIComponent(name)}`);
if (!res.ok) throw new Error("Search failed");
const data = await res.json();
const items = data.results || data;
if (!items || items.length === 0) {
nameResults.innerHTML = '<p class="text-sm text-gray-500">No results found.</p>';
return;
}
let html = `<p class="text-xs text-gray-400 mb-2">${items.length} result${items.length !== 1 ? "s" : ""}</p>`;
html += '<div class="space-y-1 max-h-60 overflow-y-auto">';
for (const item of items) {
const label = [
item.legal_name || "",
item.dba ? `DBA: ${item.dba}` : "",
item.frn ? `FRN: ${item.frn}` : "",
item.filer_id ? `Filer ID: ${item.filer_id}` : "",
].filter(Boolean).join(" — ");
html += `<button
data-frn="${item.frn || ""}"
class="name-result-btn block w-full text-left px-3 py-2 rounded-lg text-sm hover:bg-pw-50 border border-transparent hover:border-pw-200 transition"
>${label}</button>`;
}
html += "</div>";
nameResults.innerHTML = html;
nameResults.querySelectorAll(".name-result-btn").forEach((btn) => {
btn.addEventListener("click", () => {
const frn = btn.getAttribute("data-frn");
if (!frn) {
showError("No FRN available for this entity.");
return;
}
frnInput.value = frn;
runCheck();
});
});
} catch (err) {
nameResults.innerHTML = `<p class="text-sm text-red-600">Search error: ${err.message}</p>`;
}
}
// --- FRN compliance check ---
frnCheckBtn.addEventListener("click", runCheck);
frnInput.addEventListener("keydown", (e) => { if (e.key === "Enter") runCheck(); });
async function runCheck() {
const frn = frnInput.value.trim();
if (!frn) return;
// Update URL
const url = new URL(window.location);
url.searchParams.set("frn", frn);
history.replaceState(null, "", url);
// Reset UI
resultsEl.classList.add("hidden");
errorBox.classList.add("hidden");
loadingEl.classList.remove("hidden");
try {
const res = await fetch(`${API}/api/v1/fcc/lookup?frn=${frn}`);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.detail || body.error || `Lookup failed (${res.status})`);
}
const data = await res.json();
renderResults(data);
} catch (err) {
showError(err.message);
} finally {
loadingEl.classList.add("hidden");
}
}
function showError(msg) {
errorMessage.textContent = msg;
errorBox.classList.remove("hidden");
}
// --- Render results ---
let lastData = null;
function renderResults(data) {
lastData = data;
errorBox.classList.add("hidden");
// Entity name
document.getElementById("entity-name").textContent = data.entity_name || data.legal_name || "Unknown Entity";
document.getElementById("entity-frn").textContent = data.frn || frnInput.value.trim();
// DBA
const dbaEl = document.getElementById("entity-dba");
const dbaValues = [
data.filer_499?.trade_name,
...(data.rmd?.other_dba_names || []),
].filter((v) => v && v !== "None" && v.trim() !== "");
if (dbaValues.length > 0) {
dbaEl.textContent = "DBA: " + [...new Set(dbaValues)].join(", ");
dbaEl.classList.remove("hidden");
} else {
dbaEl.classList.add("hidden");
}
// Address
const addrEl = document.getElementById("entity-address");
const coresAddr = data.cores_address || data.address;
if (coresAddr) {
addrEl.textContent = coresAddr;
} else if (data.rmd?.business_address) {
addrEl.textContent = data.rmd.business_address;
} else if (data.filer_499?.state) {
addrEl.textContent = data.filer_499.state;
} else {
addrEl.textContent = "";
}
// Details
const detailsEl = document.getElementById("entity-details");
let hasDetails = false;
function setDetail(id, label, value) {
const el = document.getElementById(id);
if (value && value !== "None" && String(value).trim() !== "") {
el.innerHTML = `<span class="font-medium text-gray-700">${label}:</span> ${value}`;
el.classList.remove("hidden");
hasDetails = true;
} else {
el.classList.add("hidden");
}
}
setDetail("detail-filer-id", "Filer ID", data.filer_499?.filer_id || data.filer_id);
setDetail("detail-comm-type", "Comm Type", data.filer_499?.comm_type || data.comm_type);
setDetail("detail-rmd-number", "RMD Number", data.rmd?.rmd_number);
// USF Contributor
const usfRaw = data.filer_499?.usf_contributor ?? data.usf_contributor ?? null;
const usfDisplay = usfRaw === true ? "Yes" : usfRaw === false ? "No" : null;
setDetail("detail-usf-contributor", "USF Contributor", usfDisplay);
// Provider types
const ptContainer = document.getElementById("detail-provider-types");
const providerTypes = data.rmd?.provider_types || [];
if (providerTypes.length > 0) {
ptContainer.innerHTML = '<span class="font-medium text-gray-700 mr-1">Provider Types:</span>' +
providerTypes.map((t) => `<span class="inline-block bg-pw-100 text-pw-800 text-xs font-medium px-2 py-0.5 rounded-full">${t}</span>`).join("");
ptContainer.classList.remove("hidden");
hasDetails = true;
} else {
ptContainer.classList.add("hidden");
}
setDetail("detail-other-frns", "Other FRNs", data.other_frns?.join(", "));
setDetail("detail-previous-names", "Previous Names", data.previous_names?.join(", "));
if (hasDetails) {
detailsEl.classList.remove("hidden");
} else {
detailsEl.classList.add("hidden");
}
// Checks
const checksContainer = document.getElementById("checks-container");
checksContainer.innerHTML = "";
const checks = data.checks || [];
const colorMap = {
green: { bg: "bg-green-50", border: "border-green-200", iconColor: "text-green-600", textColor: "text-green-800" },
yellow: { bg: "bg-amber-50", border: "border-amber-200", iconColor: "text-amber-600", textColor: "text-amber-800" },
red: { bg: "bg-red-50", border: "border-red-200", iconColor: "text-red-600", textColor: "text-red-800" },
unknown: { bg: "bg-gray-50", border: "border-gray-200", iconColor: "text-gray-500", textColor: "text-gray-700" },
};
const icons = {
green: `<svg class="w-6 h-6" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>`,
yellow: `<svg class="w-6 h-6" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01M10.29 3.86l-8.8 15.32A1 1 0 002.36 21h19.28a1 1 0 00.87-1.5l-9.64-16.64a1 1 0 00-1.74 0z"/></svg>`,
red: `<svg class="w-6 h-6" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>`,
unknown: `<svg class="w-6 h-6" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01"/></svg>`,
};
let hasRed = false;
checks.forEach((check) => {
const status = check.status || "unknown";
if (status === "red") hasRed = true;
const c = colorMap[status] || colorMap.unknown;
const icon = icons[status] || icons.unknown;
const card = document.createElement("div");
card.className = `${c.bg} ${c.border} border rounded-xl p-4 flex items-start gap-3`;
card.setAttribute("data-check-id", check.id);
let inner = `<div class="${c.iconColor} mt-0.5 flex-shrink-0">${icon}</div>`;
inner += `<div class="flex-1">`;
inner += `<p class="font-semibold ${c.textColor}">${check.label || check.id}</p>`;
if (check.due_date) inner += `<p class="text-xs ${c.textColor} opacity-75 mt-0.5">Due: ${check.due_date}</p>`;
if (check.detail) inner += `<p class="text-sm ${c.textColor} mt-1">${check.detail}</p>`;
// BDC special
if (check.id === "bdc_filing" && status === "unknown") {
inner += `<div class="mt-3">
<p class="text-sm font-medium ${c.textColor} mb-2">Do you provide broadband internet access?</p>
<div class="flex gap-2">
<button class="bdc-yes bg-amber-500 hover:bg-amber-600 text-white text-sm font-semibold px-4 py-1.5 rounded-lg transition">Yes</button>
<button class="bdc-no bg-green-600 hover:bg-green-700 text-white text-sm font-semibold px-4 py-1.5 rounded-lg transition">No</button>
</div>
</div>`;
}
inner += `</div>`;
card.innerHTML = inner;
// BDC button handlers
if (check.id === "bdc_filing" && status === "unknown") {
card.querySelector(".bdc-yes")?.addEventListener("click", () => {
check.status = "yellow";
const cy = colorMap.yellow;
card.className = `${cy.bg} ${cy.border} border rounded-xl p-4 flex items-start gap-3`;
card.innerHTML = `<div class="${cy.iconColor} mt-0.5 flex-shrink-0">${icons.yellow}</div>
<div class="flex-1">
<p class="font-semibold ${cy.textColor}">${check.label || check.id}</p>
${check.due_date ? `<p class="text-xs ${cy.textColor} opacity-75 mt-0.5">Due: ${check.due_date}</p>` : ""}
${check.detail ? `<p class="text-sm ${cy.textColor} mt-1">${check.detail}</p>` : ""}
</div>`;
renderCta(lastData);
});
card.querySelector(".bdc-no")?.addEventListener("click", () => {
check.status = "green";
const cg = colorMap.green;
card.className = `${cg.bg} ${cg.border} border rounded-xl p-4 flex items-start gap-3`;
card.innerHTML = `<div class="${cg.iconColor} mt-0.5 flex-shrink-0">${icons.green}</div>
<div class="flex-1">
<p class="font-semibold ${cg.textColor}">${check.label || check.id}</p>
</div>`;
});
}
checksContainer.appendChild(card);
});
// Prior-year
document.getElementById("prior-year").classList.toggle("hidden", !hasRed);
// CTA
renderCta(data);
// Timestamp
document.getElementById("checked-at").textContent = "Checked at " + new Date().toLocaleString();
// Show results
resultsEl.classList.remove("hidden");
}
// --- Render CTA (separate function) ---
function renderCta(data) {
const checks = data.checks || [];
const frn = data.frn || frnInput.value.trim();
const services = [];
for (const check of checks) {
const s = check.status || "unknown";
if (s === "green") continue;
switch (check.id) {
case "rmd_filing":
services.push({ id: "rmd_filing", name: "RMD Registration", desc: "", price: 219 });
break;
case "stir_shaken":
services.push({ id: "stir_shaken", name: "STIR/SHAKEN Implementation", desc: "", price: 499 });
break;
case "form_499a":
services.push({ id: "form_499a", name: "Form 499-A Filing", desc: "", price: 499 });
if (s === "red") {
services.push({ id: "dc_agent", name: "D.C. Registered Agent", desc: "", price: 99, priceLabel: "$99/yr" });
}
break;
case "form_499q":
services.push({ id: "form_499q", name: "Form 499-Q Filing", desc: "included with 499-A", price: 0 });
break;
case "cpni_certification":
services.push({ id: "cpni_certification", name: "CPNI Certification", desc: "", price: 149 });
break;
case "cores_registration":
if (s === "red") {
services.push({ id: "cores_registration", name: "FCC CORES Registration", desc: "included", price: 0 });
}
break;
case "bdc_filing":
if (s === "yellow") {
services.push({ id: "bdc_filing", name: "BDC / Form 477 Filing", desc: "", price: 199 });
}
break;
}
}
const ctaContent = document.getElementById("cta-content");
const paymentIcons = document.getElementById("payment-icons");
const reminderCta = document.getElementById("reminder-cta");
if (services.length > 0) {
let html = `<h3 class="text-lg font-bold text-gray-900 mb-4">We can help fix these items</h3>`;
html += `<div class="space-y-2" id="service-list">`;
for (const svc of services) {
const checked = svc.price > 0 && svc.id !== "stir_shaken" ? "checked" : "";
const priceLabel = svc.priceLabel || (svc.price > 0 ? `$${svc.price}` : svc.desc || "included");
html += `<label class="flex items-start gap-3 p-3 rounded-lg hover:bg-gray-50 cursor-pointer transition">
<input type="checkbox" class="svc-checkbox mt-1 accent-pw-600" data-id="${svc.id}" data-price="${svc.price}" ${checked} />
<div class="flex-1">
<span class="font-medium text-gray-900">${svc.name}</span>
${svc.desc && svc.price > 0 ? `<span class="text-xs text-gray-500 ml-1">${svc.desc}</span>` : ""}
${svc.price === 0 && svc.desc ? `<span class="text-xs text-gray-500 ml-1">(${svc.desc})</span>` : ""}
</div>
<span class="font-semibold text-gray-700 text-sm whitespace-nowrap">${priceLabel}</span>
</label>`;
}
html += `</div>`;
html += `<div id="total-row" class="mt-4 pt-4 border-t border-gray-200"></div>`;
html += `<div class="mt-4 text-center">
<a id="get-started-btn" href="#" class="inline-block bg-orange-500 hover:bg-orange-600 text-white font-bold px-8 py-3 rounded-lg shadow-md transition text-sm">
Get Started &rarr;
</a>
</div>`;
ctaContent.innerHTML = html;
paymentIcons.classList.remove("hidden");
reminderCta.classList.add("hidden");
// Total calculation
function updateTotal() {
const boxes = ctaContent.querySelectorAll(".svc-checkbox");
let total = 0;
let pricedCount = 0;
const selectedIds = [];
boxes.forEach((cb) => {
if (cb.checked) {
const p = parseInt(cb.dataset.price, 10);
selectedIds.push(cb.dataset.id);
if (p > 0) {
// Exclude dc_agent from discount calc
if (cb.dataset.id !== "dc_agent") {
pricedCount++;
}
total += p;
}
}
});
const totalRow = document.getElementById("total-row");
let discountHtml = "";
if (pricedCount >= 2) {
const discount = Math.round(total * 0.15);
const final_ = total - discount;
discountHtml = `<div class="flex justify-between text-sm">
<span class="text-gray-600">Subtotal</span>
<span class="text-gray-600">$${total}</span>
</div>
<div class="flex justify-between text-sm text-green-600 font-medium">
<span>15% bundle discount</span>
<span>-$${discount}</span>
</div>
<div class="flex justify-between text-lg font-bold mt-1">
<span>Total</span>
<span>$${final_}</span>
</div>`;
} else if (pricedCount === 1) {
discountHtml = `<div class="flex justify-between text-sm">
<span class="text-gray-600">Total</span>
<span class="font-semibold">$${total}</span>
</div>
<p class="text-xs text-amber-600 mt-1">Add another service for 15% off</p>`;
} else {
discountHtml = `<div class="flex justify-between text-sm">
<span class="text-gray-600">Total</span>
<span class="font-semibold">$${total}</span>
</div>`;
}
totalRow.innerHTML = discountHtml;
// Update link
const btn = document.getElementById("get-started-btn");
if (btn) {
btn.href = `/order/fcc-compliance?services=${selectedIds.join(",")}&frn=${frn}`;
}
}
ctaContent.querySelectorAll(".svc-checkbox").forEach((cb) => {
cb.addEventListener("change", updateTotal);
});
updateTotal();
} else {
ctaContent.innerHTML = `<h3 class="text-lg font-bold text-green-800 mb-2">Looking good &mdash; all checks passed</h3>
<p class="text-sm text-gray-600">Your filings appear current. No action is needed at this time.</p>`;
paymentIcons.classList.add("hidden");
reminderCta.classList.remove("hidden");
}
}
// --- Auto-fill from URL ---
(function init() {
const params = new URLSearchParams(window.location.search);
const frn = params.get("frn");
const entity = params.get("entity");
if (frn) {
frnInput.value = frn;
runCheck();
} else if (entity) {
nameInput.value = entity;
runNameSearch();
}
})();
</script>
</Base>

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,7 @@
<body class="min-h-screen flex flex-col"> <!-- Navigation --> <nav class="border-b border-gray-200 bg-white sticky top-0 z-50"> <div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8"> <div class="flex justify-between h-24 items-center"> <a href="/" class="flex items-center"> <img src="/images/logo.png" alt="Performance West" class="h-20 w-auto" width="83" height="70"> </a> <div class="hidden md:flex items-center gap-8"> <!-- Services dropdown --> <div class="relative" id="services-dropdown"> <button type="button" class="text-sm text-gray-600 hover:text-gray-900 inline-flex items-center gap-1" id="services-btn">
Services
<svg class="w-3.5 h-3.5 transition-transform" id="services-chevron" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"></path></svg> </button> <div id="services-menu" class="absolute left-1/2 -translate-x-1/2 top-full mt-2 rounded-xl border border-gray-200 bg-white shadow-xl hidden z-50" style="width: 720px;"> <div class="grid grid-cols-3 gap-0 p-4"> <!-- Column 1 --> <div class="pr-4 border-r border-gray-100"> <p class="text-[11px] font-semibold uppercase tracking-wider text-blue-500 mb-2">Telecom</p> <a href="/services/telecom/fcc-499a" class="block py-1.5 text-sm text-gray-700 hover:text-pw-700">FCC 499A Filing</a> <a href="/services/telecom/stir-shaken" class="block py-1.5 text-sm text-gray-700 hover:text-pw-700">STIR/SHAKEN</a> <a href="/services/telecom/ipes-isp" class="block py-1.5 text-sm text-gray-700 hover:text-pw-700">IPES & ISP Registration</a> <a href="/services/telecom/database-management" class="block py-1.5 text-sm text-gray-700 hover:text-pw-700">Telecom Databases</a> <a href="/services/telecom/state-puc" class="block py-1.5 text-sm text-gray-700 hover:text-pw-700">State PUC/PSC Filings</a> <a href="/services/telecom/canada-crtc" class="block py-1.5 text-sm font-medium text-blue-600 hover:text-blue-800">Canada CRTC Package <span class="inline-flex items-center px-1.5 py-0.5 rounded-full text-[9px] font-bold bg-red-500 text-white ml-1 animate-pulse">HOT</span></a> <a href="/tools/fcc-compliance-check" class="block py-1.5 text-sm font-medium text-green-600 hover:text-green-800">FCC Compliance Check <span class="inline-flex items-center px-1.5 py-0.5 rounded-full text-[9px] font-bold bg-green-500 text-white ml-1">FREE</span></a> </div> <!-- Column 2 --> <div class="px-4 border-r border-gray-100"> <p class="text-[11px] font-semibold uppercase tracking-wider text-purple-500 mb-2">Data Privacy</p> <a href="/services/privacy/ccpa-audit" class="block py-1.5 text-sm text-gray-700 hover:text-pw-700">CCPA/CPRA Audit</a> <a href="/services/privacy/privacy-policy" class="block py-1.5 text-sm text-gray-700 hover:text-pw-700">Privacy Policy Review</a> <a href="/services/privacy/data-mapping" class="block py-1.5 text-sm text-gray-700 hover:text-pw-700">Data Mapping</a> <a href="/services/privacy/breach-response" class="block py-1.5 text-sm text-gray-700 hover:text-pw-700">Breach Response Plan</a> <p class="text-[11px] font-semibold uppercase tracking-wider text-green-500 mb-2 mt-4">TCPA</p> <a href="/services/tcpa/consent-audit" class="block py-1.5 text-sm text-gray-700 hover:text-pw-700">Consent Audit</a> <a href="/services/tcpa/dnc-compliance" class="block py-1.5 text-sm text-gray-700 hover:text-pw-700">DNC Compliance</a> <a href="/services/tcpa/campaign-review" class="block py-1.5 text-sm text-gray-700 hover:text-pw-700">Campaign Review</a> </div> <!-- Column 3 --> <div class="pl-4"> <p class="text-[11px] font-semibold uppercase tracking-wider text-slate-500 mb-2">Corporate</p> <a href="/services/corporate/formation" class="block py-1.5 text-sm text-gray-700 hover:text-pw-700">Business Formation</a> <a href="/services/corporate/state-registration" class="block py-1.5 text-sm text-gray-700 hover:text-pw-700">State Registration</a> <a href="/services/corporate/annual-reports" class="block py-1.5 text-sm text-gray-700 hover:text-pw-700">Annual Reports</a> <a href="/services/corporate/registered-agent" class="block py-1.5 text-sm text-gray-700 hover:text-pw-700">Registered Agent</a> <a href="/order/formation" class="mt-3 block py-2 px-3 text-sm font-medium text-white bg-pw-700 hover:bg-pw-800 rounded-lg text-center transition-colors">Form a Business</a> </div> </div> </div> </div> <a href="/services" class="text-sm text-gray-600 hover:text-gray-900">All Services</a> <a href="/pricing" class="text-sm text-gray-600 hover:text-gray-900">Pricing</a> <a href="/tools/contractor-quiz" class="text-sm text-gray-600 hover:text-gray-900">Free Tools</a> <a href="/contact" class="text-sm text-gray-600 hover:text-gray-900">Contact</a> <a href="/order/formation" class="ml-2 px-4 py-2 text-sm font-medium text-white bg-pw-700 hover:bg-pw-800 rounded-lg transition-colors">Form a Business</a> <!-- Account button — links to ERPNext portal --> <a href="https://portal.performancewest.net" id="nav-login-btn" class="ml-1 flex items-center gap-1.5 px-3 py-2 text-sm font-medium text-gray-600 hover:text-pw-700 hover:bg-pw-50 rounded-lg transition-colors border border-gray-200 hover:border-pw-300"> <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z"></path></svg>
Client Portal
</a> <!-- Account button — logged in (hidden until session confirmed) --> <div id="nav-account-btn" class="hidden relative ml-1" id="nav-account-dropdown-root"> <button type="button" id="nav-account-trigger" class="flex items-center gap-2 px-3 py-2 text-sm font-medium text-pw-700 hover:bg-pw-50 rounded-lg transition-colors border border-pw-200"> <div class="w-6 h-6 rounded-full bg-pw-600 flex items-center justify-center shrink-0"> <svg class="w-3.5 h-3.5 text-white" fill="currentColor" viewBox="0 0 24 24"><path d="M12 12c2.7 0 4.8-2.1 4.8-4.8S14.7 2.4 12 2.4 7.2 4.5 7.2 7.2 9.3 12 12 12zm0 2.4c-3.2 0-9.6 1.6-9.6 4.8v2.4h19.2v-2.4c0-3.2-6.4-4.8-9.6-4.8z"></path></svg> </div> <span id="nav-greeting" class="max-w-[120px] truncate">My Account</span> <svg class="w-3.5 h-3.5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"></path></svg> </button> <!-- Dropdown --> <div id="nav-account-menu" class="absolute right-0 top-full mt-1 w-48 bg-white rounded-xl border border-gray-200 shadow-lg hidden z-50 py-1"> <div class="px-4 py-2 border-b border-gray-100"> <p class="text-xs text-gray-500">Signed in as</p> <p id="nav-account-email" class="text-xs font-medium text-gray-800 truncate"></p> </div> <button type="button" id="nav-logout-btn" class="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors">
Sign out
</button> </div> </div> </div> <!-- Mobile menu button --> <button type="button" class="md:hidden text-gray-600 hover:text-gray-900" id="mobile-menu-btn"> <svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16"></path></svg> </button> </div> </div> <!-- Mobile menu --> <div id="mobile-menu" class="md:hidden hidden border-t border-gray-200 bg-white"> <div class="px-4 py-3 space-y-1"> <p class="text-xs font-semibold text-blue-500 uppercase tracking-wider px-2 pt-1">Telecom</p> <a href="/services/telecom/fcc-499a" class="block px-2 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded">FCC 499A Filing</a> <a href="/services/telecom/stir-shaken" class="block px-2 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded">STIR/SHAKEN</a> <a href="/services/telecom/ipes-isp" class="block px-2 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded">IPES & ISP Registration</a> <a href="/services/telecom/database-management" class="block px-2 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded">Telecom Databases</a> <a href="/services/telecom/state-puc" class="block px-2 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded">State PUC/PSC</a> <a href="/services/telecom/canada-crtc" class="block px-2 py-2 text-sm font-medium text-blue-600 hover:bg-blue-50 rounded">Canada CRTC Package <span class="inline-flex items-center px-1.5 py-0.5 rounded-full text-[9px] font-bold bg-red-500 text-white ml-1 animate-pulse">HOT</span></a> <p class="text-xs font-semibold text-purple-500 uppercase tracking-wider px-2 pt-3">Data Privacy</p> <a href="/services/privacy/ccpa-audit" class="block px-2 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded">CCPA/CPRA Audit</a> <a href="/services/privacy/privacy-policy" class="block px-2 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded">Privacy Policy Review</a> <a href="/services/privacy/data-mapping" class="block px-2 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded">Data Mapping</a> <a href="/services/privacy/breach-response" class="block px-2 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded">Breach Response Plan</a> <p class="text-xs font-semibold text-green-500 uppercase tracking-wider px-2 pt-3">TCPA</p> <a href="/services/tcpa/consent-audit" class="block px-2 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded">Consent Audit</a> <a href="/services/tcpa/dnc-compliance" class="block px-2 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded">DNC Compliance</a> <a href="/services/tcpa/campaign-review" class="block px-2 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded">Campaign Review</a> <p class="text-xs font-semibold text-slate-500 uppercase tracking-wider px-2 pt-3">Corporate</p> <a href="/services/corporate/formation" class="block px-2 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded">Business Formation</a> <a href="/services/corporate/state-registration" class="block px-2 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded">State Registration</a> <a href="/services/corporate/annual-reports" class="block px-2 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded">Annual Reports</a> <a href="/services/corporate/registered-agent" class="block px-2 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded">Registered Agent</a> <div class="border-t border-gray-100 my-2"></div> <a href="/services" class="block px-2 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded">All Services</a> <a href="/pricing" class="block px-2 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded">Pricing</a> <a href="/tools/contractor-quiz" class="block px-2 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded">Free Tools</a> <a href="/contact" class="block px-2 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded">Contact</a> <a href="/order/formation" class="block mx-2 mt-2 px-4 py-2.5 text-sm font-medium text-white bg-pw-700 hover:bg-pw-800 rounded-lg text-center transition-colors">Form a Business</a> </div> </div> </nav>