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>