Commit graph

174 commits

Author SHA1 Message Date
justin
cf021e2f91 feat(healthcare): OIG/SAM exclusion screening as $79/mo Stripe Subscription
Convert OIG/SAM from one-time $299/yr to recurring $79/month (card+ACH only) -
the first real recurring-billing product in the system. Exclusion screening is
a *monthly* federal obligation, so recurring monitoring fits the requirement and
is the biggest valuation lever (vs a one-time annual run).

Catalog (single source of truth):
- service-catalog.ts: add billing_interval + allowed_methods to ComplianceService;
  oig-sam-screening -> 7900c, billing_interval:"month", allowed_methods:[card,ach],
  name "(Monthly Monitoring)".
- gen-service-catalog.py + check-service-catalog-drift.py: carry/guard the two new
  fields; regenerate site catalog.

Checkout (api/src/routes/checkout.ts):
- mode:"subscription" with recurring price_data when billing_interval is set;
  surcharge absorbed for recurring (clean $79/mo); server-side METHOD_NOT_ALLOWED
  re-validation against allowed_methods.
- ensureColumns + migration 100: compliance_orders.stripe_subscription_id,
  bundle_upsell_sent_at (+ subscription index).

Webhooks (api/src/routes/webhooks.ts):
- record stripe_subscription_id on checkout.session.completed (subscription mode).
- invoice.paid (subscription_cycle only) -> re-dispatch screening for the cycle;
  invoice.payment_failed -> admin alert + first-failure customer nudge;
  customer.subscription.deleted -> mark order cancelled. (API 2026-03-25 moved the
  subscription link to invoice.parent.subscription_details.subscription.)

Fulfillment:
- job_server.py: pass recurring_cycle/invoice_id into the order.
- npi_provider.py: OIG handler labels renewal cycles "[Monthly cycle]" + re-screen
  note; bundle action runs only the FIRST screening + flags the $79/mo upsell.

Bundle land-and-expand:
- Provider Compliance Bundle now includes only the first OIG/SAM screening (was
  giving away $948/yr of monitoring inside an $899 bundle).
- new worker scripts/workers/bundle_upsell.py (+ pw-bundle-upsell timer): ~3 weeks
  after a paid bundle, emails the customer to continue $79/mo monitoring; dedup via
  bundle_upsell_sent_at; skips customers who already have an OIG/SAM order.

Surfaces updated to $79/mo: PaymentStep (filters methods, "Billed every month,
cancel anytime"), order pages, healthcare index, npi-compliance-check tool (also
fixed stale $699 bundle drift -> $899), hc_oig_screening + hc_compliance_bundle
emails.

Docs: billing.md gains a "Stripe-native Subscriptions" section + a reality-check
banner (Adyen/ERPNext-gateway model documented there is NOT live; Stripe is the
real rail). Fixed run-migrations.yml container name bug
(performancewest-postgres-1 -> performancewest-api-postgres-1, overridable).

Tests: api/tests/recurring-subscription.test.ts (28 assertions) covers catalog
gating, method validation, surcharge suppression, recurring line-item build,
invoiceSubscriptionId extraction, renewal-cycle gating. tsc clean; site build
clean; catalog drift OK.

Manual deploy step: enable invoice.paid, invoice.payment_failed,
customer.subscription.deleted on the Stripe webhook endpoint.
2026-06-18 07:54:38 -05:00
justin
e379e2b10f CRTC: ERPNext as portal source of truth + harden discount expiry + carrier guide PDF
- checkout.ts: generalize ensureCompliancePortalUser -> ensurePortalUser and
  call it in the CRTC post-payment path so PayPal/crypto/webhook-confirmed CRTC
  orders always get an ERPNext Customer + Website User (the single source of
  truth for portal login/password), matching the compliance fix from the
  PayPal incident. Also flip portal_user_created for canada_crtc/formation.
- canada-crtc.ts: enforce discount active+start/expiry windows, global usage
  limit and applies_to scope server-side at checkout (was active-only), so a
  promo like CANADA200 actually stops working after its expiry.
- scripts/generate_canada_carrier_guide_pdf.py: render the public Canadian
  wholesale carrier/vendor guide PDF (reuses the canonical VENDORS list) to
  site/public/guides/canada-carrier-guide.pdf for the CRTC campaign lead magnet.
2026-06-17 23:34:13 -05:00
justin
a04ecf7df3 chore(email): decommission SMTP2GO references — local MTA only
SMTP2GO is no longer used: Listmonk relays through the local Postfix MTA
(172.18.0.1:25 from the Docker network), which DKIM-signs and delivers
direct-to-recipient-MX; transactional mail goes through Carbonio. Verified
zero smtp2go in any live container env + postfix has no external relayhost.

Removed the stale references so a rebuild/new dev can't re-introduce it:
- api/src/config.ts: SMTP_HOST default mail.smtp2go.com -> co.carrierone.com
- scripts/workers/crypto_payment_worker.py: same default fix
- infra/ansible all.yml: listmonk_smtp_* now 172.18.0.1:25, no auth (+comment)
- app.env.j2 / email.ts / crm.md / go-live-todo.md / architecture.svg: docs
2026-06-17 22:46:59 -05:00
justin
b375385efd fix(email): add text/plain part to every transactional + telecom email
All transactional/worker senders built multipart/alternative (or mixed)
messages with ONLY an HTML part. A single-part multipart/alternative is
malformed and HTML-only mail is a spam-score signal -- the same class of
deliverability bug that hurt the campaign pipeline, but on the telecom /
filing / customer-transactional path (499-Q reminders, RMD/FCC filing
review links, intake/completion/delivery emails, commissions, etc).

- worker_email.send_worker_email: auto-derive plaintext from HTML when
  caller omits text= (fixes the shared helper for all current+future use)
- 16 rolled-their-own senders in scripts/workers/** + scripts/formation/
  document_delivery.py: attach html_to_text(...) plaintext sibling before
  the HTML part (job_server + document_delivery wrap text+html in an
  alternative sub-part so PDFs still attach to the mixed root)
- api/src/email.ts: add dependency-free htmlToText() and default
  sendEmail text to it (fixes checkout/webhook HTML-only sends)

Verified: all py files compile + import at runtime, api tsc passes,
htmlToText handles hrefs/lists/entities, 11 plaintext unit tests pass.
Telecom campaign 407 (Jun 8) was HTML-only + sent in the DKIM-broken
window -> 384 sent / 0 clicks (same junked-mail signature).
2026-06-17 21:07:40 -05:00
justin
9c87759501 auth: make ERPNext the single source of truth for customer passwords
Customer portal login previously checked a bcrypt customers.password_hash
in Postgres, while portal.performancewest.net validated against ERPNext —
two stores that drifted (the Paul Wilson lockout). Consolidate on ERPNext:

- erpnext-client: add verifyWebsiteUserPassword() — delegates the credential
  check to Frappe /api/method/login (Host header = site name; 200=ok,401=bad).
- portal-auth /login: verify against ERPNext, then mint the pw_customer cookie.
- portal-auth /register: create+set the ERPNext password (authority) and upsert
  a password-less customers profile row; takeover guard still honors any legacy
  PG password until the column is dropped.
- portal-auth /reset-password + /forgot-password: write the new password to
  ERPNext; forgot-password now also works for ERPNext-only users (creates the
  PG profile row on demand).
- Legacy customers with only a PG bcrypt password reset via forgot-password.
- checkout: refresh the stale comment (customers row is now a profile, no pw).

Build + typecheck green.
2026-06-17 10:09:32 -05:00
justin
c46efe5730 feat(sc-coc): SC intrastate Certificate of Compliance flow (insurance gate -> $25 fee -> file)
Routes SC intrastate-authority orders to the real SCDMV COC product instead of a
PSC certificate (which doesn't apply to property carriers):

  - sc_coc_filing.py: emails the carrier a one-click yes/no — does your insurer
    have / can they file a Form E (SC intrastate liability, $750k or $300k by
    GVWR) with SCDMV? Records the answer; builds the filled COC package.
  - state_trucking._handle_sc_coc_gate: SC intrastate gate —
      no answer  -> email the question once, HOLD
      answered no -> broker referral opened, HOLD (ops todo)
      answered yes-> proceed to bill the exact $25 SCDMV COC fee (at cost) + file
  - API POST /compliance-orders/:id/sc-insurance: records yes/no in intake_data
    (no schema change); NO opens an insurance_lead broker-referral ticket +
    Telegram; YES re-dispatches the worker to bill the $25 + file.
  - site/order/sc-insurance: customer one-click yes/no page (auto-submits when
    the email links straight to ?have=yes|no).

Non-SC intrastate still uses the PSC/PUC email path or a manual todo.
2026-06-16 09:15:55 -05:00
justin
861f2fbfd4 feat(govfee): auto-quote + collect state fees for at-cost trucking services
At-cost services (IRP/IFTA/intrastate) only collected our service fee at
checkout; the variable state fee was never billed, so orders stalled at
authorization_signed and the filing card would have had to front large IRP fees.

New end-to-end, hands-off flow (you only approve the final filing):
  1. After authorization is signed, state_trucking auto-estimates the gov fee
     from intake (base/op states, power units, weight) via gov_fee.estimate_gov_fee.
  2. Creates a CHILD compliance order (CG-..., service_fee=0, gov_fee=estimate,
     parent_order_number set, migration 099) that flows through the EXISTING
     checkout/payment/webhook machinery.
  3. Emails the customer a payment link to /order/pay (new self-contained page)
     showing every method with correct surcharges — ACH 0% (Stripe 0.8%/ cap
     absorbed, no GoCardless needed), card/PayPal 3%, Klarna 6%, crypto 0%.
  4. Order holds at awaiting_government_fee_approval until paid.
  5. On payment, handlePaymentComplete detects the child (parent_order_number)
     and re-dispatches the PARENT with gov_fee_paid=true, which proceeds to
     prepare + queue the filing and stops at ready_to_file for your approval.

IRP fees are estimates billed at cost (refund overage / rebill shortfall); IFTA
decals + most intrastate fees are near-exact. Tunable via env.
2026-06-16 04:35:45 -05:00
justin
bf69960e8c admin: mark-filed action to advance manual/admin-assisted orders to completed
Admin-assisted services (UCR, MC authority, etc.) have no automated submission,
so approving them only flips to authorization_signed and then sits there -- there
was no way to advance to completed. Add POST /mark-filed (filed_waiting_state |
completed, optional confirmation #, transactional + audit-logged) and drawer
buttons 'Mark as filed (waiting on agency)' / 'Mark completed' shown for orders in
authorization_signed / ready_to_file / filed_waiting_state. Confirmation number
is recorded into intake_data.filing_status.manual_confirmation.
2026-06-16 03:12:57 -05:00
justin
326aee7714 admin: inline filing screenshots + atomic approve transaction
- Documents now flag is_image and the drawer renders screenshots / confirmation
  images as inline clickable thumbnails (click to open full size); PDFs keep the
  View link. Evidence keys are labeled (Filing confirmation screenshot, etc.),
  the worker-temp screenshot_path (not a MinIO key) is dropped in favor of the
  durable evidence copy, and non-file evidence (fax_log_id) is skipped.
- Wrap approve's status-update + audit-insert in a transaction so a failure can
  no longer leave an order out of ready_to_file without dispatching (the earlier
  audit CHECK violation did exactly that to Paul's UCR; it has been reset).
2026-06-16 02:57:24 -05:00
justin
73c27c75b1 migration 098: allow compliance order types in order_audit_log
The admin compliance-orders approve/re-arm actions write order_audit_log rows
with order_type='compliance', but the CHECK constraint (from migration 004)
only allowed formation/service/quote -- so every approve failed with a 500
('Approve failed.'). Expand the constraint to include compliance + compliance_batch.
2026-06-16 02:49:46 -05:00
justin
8e1e2f16bf admin docs: only list objects that actually exist (drop dead/phantom rows)
Filter the documents list to objects that exist in storage, so stray keys (a
template pdf_minio_path, or a phantom mcs150 esign_records row on a UCR order
from the shared remediation pipeline) no longer surface as dead rows. The UI
drops the now-unreachable 'not generated yet' branch.
2026-06-16 02:37:33 -05:00
justin
c8e0065729 admin docs: hide phantom prepared-filing PDF for non-form services
The dot-compliance-remediation pipeline seeds filing_status.pdf_minio_path on
every order in a batch, but only MCS-150-producing slugs (mcs150-update,
dot-registration, usdot-reactivation, dot-full-compliance) ever generate it.
For admin-assisted services like UCR it was a phantom 'Prepared filing PDF /
not generated yet' row. Gate the prepared-filing artifacts on FORM_PRODUCING_SLUGS
(mirrors the worker's MCS150_FORM_SLUGS) and give the empty state a clearer
explanation.
2026-06-16 02:35:29 -05:00
justin
d18de006d8 admin approve: block filing when intake incomplete (force override + warning)
Paul Wilson's UCR (CO-FE07212A) sat at fulfillment_status=ready_to_file with
intake_data_validated=false, so the Approve & File button would have dispatched
it for government submission with incomplete intake and no document to review.

Backend: /approve now refuses an order whose intake_data_validated is false
unless {force:true} is passed (409 code=intake_incomplete); the override is
recorded in order_audit_log. The fulfillment_status=ready_to_file requirement
is unchanged, so awaiting_intake orders (e.g. Mitchell's MCS-150s) still 409.

UI: the drawer shows an amber 'intake not complete' warning above the approve
button, and approving an intake-incomplete order triggers an explicit
override confirmation before sending force=true.
2026-06-16 00:33:22 -05:00
justin
aa498fdfdf admin docs: probe existence with ranged GET (HEAD fails presigned-URL sig) 2026-06-16 00:23:40 -05:00
justin
1f3b36b29e admin docs: verify object existence, mark dead links, cleaner 404
The DB can record a pdf_minio_path before the object is uploaded (e.g. a
prepared-filing path written for an order whose prep never completed -- Paul
Wilson / Mark Adams MCS-150s). The documents list now HEAD-checks each key and
returns an exists flag; the UI shows 'not generated yet' instead of a dead View
button, and the stream endpoint returns a clean 404 for a missing object.
2026-06-16 00:22:35 -05:00
justin
bce5db4a09 admin: view order PDFs from MinIO (signed forms, prepared filings, evidence)
Adds a Documents section to the compliance-order detail drawer so you can
review the actual filing PDFs before approving an order:
  GET /api/v1/admin/compliance-orders/:id/documents  list viewable objects
  GET /api/v1/admin/compliance-orders/:id/document?key=&token=  stream one

Key discovery pulls from esign_records (unsigned + signed docs per order),
intake_data.filing_status (pdf_minio_path, attested_pdf, evidence/*), and the
order's engagement_letter / rmd_packet columns.

Rather than hand out presigned URLs (MinIO's public host is IP-allowlisted to a
few office IPs, so links break elsewhere), the API streams the object through
itself from internal minio:9000, gated by the admin JWT. The stream endpoint
accepts the token via ?token= (new middleware requireAdminQueryOrHeader) so a
PDF opens in a new tab, and refuses any key that isn't one of the order's own
documents.
2026-06-16 00:20:15 -05:00
justin
2296566e85 admin: compliance-orders dashboard (view, approve-to-file, re-arm intake)
The admin SPA only managed formation_orders; compliance service orders
(telecom/DOT/healthcare) had no admin surface, so you couldn't see what was
paid, what was stuck on intake, or approve a prepared filing for submission.

API (api/src/routes/admin.ts), all requireAdmin:
  GET  /api/v1/admin/compliance-orders            list, grouped by batch, filters
  GET  /api/v1/admin/compliance-orders/stats      queue overview counts
  GET  /api/v1/admin/compliance-orders/:id        full detail + audit log
  POST /api/v1/admin/compliance-orders/:id/approve       approve ready_to_file + dispatch worker
  POST /api/v1/admin/compliance-orders/:id/rearm-intake  clear reminder stamp so daily nudge resumes

UI: new static page /admin/compliance-orders/ (self-contained, CSP-safe inline
CSS, no external JS framework) reusing the existing pw_admin_token session.
Cards group multi-service batches, flag paid+intake-incomplete in red, show
reminder counts, and expose Approve & Re-arm buttons. Linked from the main
/admin top bar. Every approve/re-arm writes an order_audit_log entry.
2026-06-15 23:57:05 -05:00
justin
9e40965092 trucking: per-MX-operator throttling + Google/MS-Workspace warmup exclusion
The Jun 13-14 Gmail+Outlook block storm came from the main/trucking pool having
NO per-MX throttling (only HC had it) -- it concentrated warmup volume on
Google/Microsoft-Workspace-hosted business domains. Port the HC fix:

- migration 097: fmcsa_carriers.mx_provider column.
- mx_tag_carriers.py: resolve MX once per distinct domain (reuses the verifier's
  classifier+cache), tag every carrier with that domain's operator. Bounded per
  run, prioritizes unsent verified carriers.
- build_trucking_campaigns: during warmup (day<=6) EXCLUDE tagged Google/MS/
  Proofpoint/etc. carriers in fetch_carriers; per-MX cap in select_sendable_
  carriers so known operators never dominate the quota. Untagged carriers pass
  (not collapsed onto one bucket) until tagging fills in. mx_daily_caps ramps
  with the main warmup day; MAIN_SKIP_BIG_MX=0 disables once warmed.
2026-06-14 21:11:23 -05:00
justin
a2665c22c2 ucr: annual-renewal reminder campaign + order-alert campaign source
UCR (Unified Carrier Registration) is annual: opens Oct 1, due Dec 31, mandatory
for interstate carriers (op A, same ~628k pool as IFTA) -> recurring revenue.

- build_ucr_annual_campaign.py: 3-touch business-day cadence (30/12/4 bd before
  Dec 31, wider than IFTA since it's once a year), escalating tone, same-day
  coupon, 'I already did it' suppression. Reuses build_trucking_campaigns +
  IFTA business-day/token helpers (DRY). Per-year cycle reset.
- ucr_annual_reminder.html: deadline + fines/OOS risk + 'we figure out your fee
  tier' + coupon + filed link + CAN-SPAM. Source campaign 473.
- migration 096: ucr_reminded_at / ucr_touch_no / ucr_self_filed_at.
- ifta.ts: add GET /api/v1/ucr/filed (shares the HMAC token scheme).
- checkout.ts: order-placement Telegram now shows 'Source: campaign (code X)'
  when a discount code is present, so IFTA/UCR/CLIA conversions are visible.
  (Confirmed order-alert Telegram already fires from handlePaymentComplete for
  all compliance orders via both webhook + session paths.)
2026-06-14 00:30:23 -05:00
justin
3d4226e95c ifta: 3-touch business-day cadence + 'I already filed it' suppression
- Multi-touch reminders at 10/7/4 BUSINESS days before each deadline (weekends
  skipped; biz-day math so a touch never lands purely on a weekend with no
  runway). Escalating tone soft -> urgent -> last-chance, with the 'almost too
  late to DIY, we can still file it' angle so it's a convenience sale, not a free
  reminder service. ifta_touch_no tracks the highest touch sent so each touch
  hits only carriers below that level; never repeats a touch.
- 'I already filed it' one-click link: HMAC-tokenized GET /api/v1/ifta/filed
  (token matches between Python builder and api/src/routes/ifta.ts -- verified
  identical output), records ifta_self_filed_at, friendly confirmation page,
  stops further touches this cycle + gives DIY-vs-prospect signal. Builder
  excludes self-filed carriers.
- migration 094 (ifta_touch_no) + 095 (ifta_self_filed_at); cycle reset clears
  both each new quarter. Verified: biz-day touch schedule, token cross-match.
2026-06-13 23:41:14 -05:00
justin
19bbef3231 ifta: recurring quarterly-return reminder campaign (calendar-triggered)
IFTA returns are due on fixed dates (Apr30/Jul31/Oct31/Jan31) and every
interstate carrier (op code A, ~628k sendable) files 4x/year forever -- pure
recurring revenue, no per-carrier deadline data needed.

- build_ifta_quarterly_campaign.py: self-gates to the reminder window (~21d
  before each deadline), selects interstate carriers, mints the same-day coupon,
  builds+schedules the campaign reusing build_trucking_campaigns plumbing (DRY:
  one source of truth for sending/suppression/coupon). Per-quarter cycle reset
  (ifta_reminder_cycle marker) so each quarter re-reminds the full pool; marks
  ifta_reminded_at to avoid double-sends within a cycle.
- ifta_quarterly_reminder.html: deadline + penalties + 'we do the math' + coupon
  + CAN-SPAM. Listmonk source campaign id 469.
- migration 094: fmcsa_carriers.ifta_reminded_at column + partial index.
Verified: deadline/window logic correct, imports reuse tc helpers, migration
applied on prod.
2026-06-13 23:24:47 -05:00
justin
9c7a08f5c9 clia: new CLIA certificate renewal service, order page, email template + harvest
Set up the CLIA recurring-renewal vein (every clinical lab renews its CLIA cert
on a 2-year cycle; CMS publishes the full lab file with expiration dates):
- service-catalog: clia-renewal ($449, discountable) + order page (npi-intake
  steps) + intake manifest entry.
- harvest_clia_renewals.py: parse the CMS Provider-of-Services CLIA file, filter
  to labs expiring within a window (default 120d), emit name/address/phone/expiry.
  676k labs -> ~70k expiring in the next ~4 months.
- match_clia_to_nppes.py: CLIA has no NPI/email, so bridge to emailable NPPES
  orgs by normalized name+zip to recover NPI+email (yield TBD; labs that do not
  match still have clean phone+postal for a phone/mail channel).
- hc_clia_renewal.html: warm turnover-safety-net email with the striped official-
  record card (CLIA #, expiry, status), verify-on-CMS-QCOR, founder guarantee
  card, full CAN-SPAM address.
2026-06-13 22:10:51 -05:00
justin
5e9aec40d1 trucking: same-day expiring coupon to drive immediate conversion
The sales we got came at $79 + a 24hr coupon; cutting MCS-150 to $39 flat
removed urgency and conversions did NOT improve (a permanent low price sets a
new anchor and lets people defer). Restore the higher anchor and let an
expiring discount create the now-or-lose-it decision.

- Restore MCS-150 anchor $39 -> $79 (catalog single source + regenerated).
- build_trucking_campaigns.py: mint ONE random 5-letter coupon per send-day
  (40% off, valid through 23:59:59 ET that day) into the existing discount_codes
  table; inject coupon_code/pct/expires + a ?code= LP link into every email.
  Idempotent per day; service-fee-only scope (gov/pass-through fees never cut).
- Listmonk MCS-150 (186) + Inactive USDOT (188) templates: lead with the
  struck-through anchor + sale price + code + 'expires tonight', and point the
  primary CTA at the order page (with code) instead of the 'free check' tool.
- OrderPriceBanner: validates ?code= via /api/v1/discount and shows
  was/now + expiry; Wizard forwards the code to order creation.
- Verified: code gen, expiry math, scope enforcement, discount API
  (40% off $79 = $47.40), site+api builds clean.
2026-06-13 20:43:47 -05:00
justin
1ff8b88ac8 fix: stop suppressing synthetic@pipeline.com (real customer address)
Paul Wilson (Compound Technologies) signed up with synthetic@pipeline.com,
which is a genuine, deliverable EarthLink address (pipeline.com MX ->
earthlink-vadesecure.net; he confirmed receipt by phone). Our code had
hardcoded pipeline.com + the synthetic@ prefix as a 'non-deliverable
FMCSA-census placeholder' and silently suppressed every automated email to
him (checkout provisioning, order-creation validation, intake reminders,
set-password invites). Nothing in the codebase actually generates that
address, so the placeholder rationale was wrong. Removed pipeline.com and the
synthetic@ rule from all four suppression sites; only RFC-reserved
example.com/test.com/invalid remain blocked.
2026-06-10 14:41:19 -05:00
justin
983c732baf migration 093: add awaiting_intake fulfillment status for MCS-150 intake gate 2026-06-10 14:14:45 -05:00
justin
a3aeedd716 mcs150: census-prefilled intake-completion flow + completeness gate
Closes the data gap for orders that bypass the full intake (e.g. the DOT
compliance-remediation pipeline) and for all MCS-150 variants:

- Worker intake-completeness gate (mcs150_update): before filling, check the
  customer-required operational fields the FMCSA census cannot supply
  (operation classification, cargo, CURRENT annual mileage, email; plus
  signer/address for new-registration/reactivation, and states-of-operation
  for 150B hazmat). If missing, email the customer a census-pre-filled intake
  link and hold the order at fulfillment_status='awaiting_intake' with an admin
  todo, instead of fabricating a blank filing. The existing intake PUT endpoint
  already re-dispatches the worker on submit, so filing auto-resumes.
- Intake wizard (Wizard.astro): when resuming ?order=CO-xxx for a DOT/MCS order,
  seed still-empty fields from the FMCSA census (name/address/fleet/interstate)
  so the customer only confirms the operational details.
- /api/v1/dot/census now also returns total_drivers + a normalized
  carrier_operation_code for the prefill.
- MCS150Step.astro extended to collect every field the filler needs across all
  variants: mailing address, cdl_drivers, primary_vehicle_type,
  reason_for_filing, usdot_revoked, cell/fax, hazmat-safety-permit block
  (needs_hmsp, operating states, security plan), and intermodal-equipment
  provider counts; all prefill from intake_data.

verify_mcs150_variants.py covers 150/150B/150C end-to-end (ALL PASS).
2026-06-10 14:03:28 -05:00
justin
d5e66786a2 mcs150: enrich intake from FMCSA carrier census before PDF fill
The MCS-150 biennial update re-confirms the carrier's existing FMCSA
record. Previously the PDF filler only had whatever the intake form
collected; rescued/sparse orders (or orders where the carrier's data
lives in FMCSA, not the intake) produced near-empty forms. Now we pull
the carrier census (legal name, address, EIN, fleet counts) from the
FMCSA carrier API and merge it under any customer-provided intake values
(customer edits win), so the form is pre-filled with the carrier's
current registered data. Refactored the FMCSA fetch into a shared
_fetch_fmcsa_carrier helper used by both enrichment and status check.
2026-06-10 12:35:52 -05:00
justin
1fe942c109 fix(checkout): don't skip ERPNext SO for synthetic@pipeline.com (real customers use it)
ensureComplianceSalesOrder skipped the FMCSA-census placeholder email, but a real
paying customer (Paul Wilson) genuinely uses synthetic@pipeline.com, so his SO
never got created/regenerated. A Sales Order is internal bookkeeping, not an
outbound email, so the placeholder skip is unnecessary here (the email/portal
guard in ensureCompliancePortalUser still protects actual sends).
2026-06-10 07:05:21 -05:00
justin
7708086130 Fix batch payment totals in Telegram and ERPNext invoice flow 2026-06-10 07:04:06 -05:00
justin
6827aafdbc fix(checkout): batch surcharge 5x over-count + ERPNext SO missing discount
Mitchell's batch CB-95BA6C90: Stripe correctly charged $450.88 ($437.75 net +
$13.13 surcharge), but the DB + Telegram showed $503.40 with a $65.65 surcharge.
Two bugs:

1) On Stripe session creation, the per-row surcharge UPDATE wrote the FULL batch
   surcharge ($13.13) to EVERY row via WHERE batch_id, so anything summing the
   per-row field (the Telegram order notification) over-counted Nx (5 x $13.13 =
   $65.65). Now the single surcharge is split across the rows so they sum to the
   true total. Stripe was always charged correctly (one surcharge line item).

2) ensureComplianceSalesOrder built the ERPNext SO from full line-item prices but
   applied NO discount, so the SO grand total over-stated what the customer paid.
   Now applies the promo/bundle discount via apply_discount_on=Grand Total +
   discount_amount on both the primary and fallback SO create.
2026-06-10 06:57:59 -05:00
justin
058d4d426a feat(compliance): admin verification gate + durable submission evidence
Per request: after the customer signs but BEFORE we submit to the government, hold
the order for a human to verify the prepared filing is correct.

- MCS-150 handler (mcs150-update + usdot-reactivation): new admin-verification gate
  after the signature gate -- if not admin_approved, set fulfillment_status=
  'ready_to_file', create a HIGH-priority 'VERIFY before filing' admin todo, and
  STOP (no FMCSA submission). job_server injects admin_approved from the dispatch
  payload (mirrors client_approved).
- New admin endpoint POST /api/v1/admin/compliance-orders/:id/approve-submit
  (requireAdmin): verifies status=ready_to_file, re-dispatches the worker with
  admin_approved=true to proceed to actual submission.
- Durable submission EVIDENCE: the web/fax submitters only wrote confirmation
  screenshots to an ephemeral temp dir. Now _upload_submission_evidence copies the
  FMCSA confirmation screenshot + attested PDF + fax_log_id to MinIO under
  filings/<slug>/<order>/evidence/ and records the keys on the order, so we keep
  proof of every government submission.

(state-trucking + the FCC handlers already gate via admin todos / auto_filing.py;
this brings MCS-150 to parity and adds evidence retention.)
2026-06-09 22:50:30 -05:00
justin
e87715aee7 fix(portal): onboarding/login links last 7 days, not 60 min
The rescue onboarding emails hardcoded a 60-minute expiry -- way too short for a
paid customer who hasn't engaged yet (they may not check email for hours/days),
so Paul's and Mitchell's links expired before they used them. Onboarding links
now last 7 days (ONBOARDING_TTL_MINUTES); the standard security password-RESET
window bumped 30min -> 2h. Re-issued fresh 7-day links to all 3 affected
customers (none had set a password yet) via reissue-onboarding-links.mjs, cc'd.
2026-06-09 22:50:09 -05:00
justin
8d301a1ab7 fix(checkout): SO creation falls back to generic item if a catalog Item is missing
Tracing Mitchell's batch, SO creation 404'd on a missing ERPNext Item
(INTRASTATE-AUTHORITY) -- 30 of 70 catalog erpnext_item codes were missing in
ERPNext. Created all 30. Added a safety net: if createResource('Sales Order')
404s on a missing Item, retry once with every line remapped to the generic
COMPLIANCE-SERVICE item so a missing catalog Item never strands a paid order's SO.
2026-06-09 14:50:06 -05:00
justin
e2467752dc chore: export ensureComplianceSalesOrder for rescue/backfill use 2026-06-09 14:44:28 -05:00
justin
68e6b60951 fix: worker emails (localhost:25 -> SMTP relay) + create ERPNext SO on webhook payment
Two bugs found tracing Mitchell Allen's batch CB-95BA6C90 (5 DOT services, card):

1) Worker authorization/signing-link/status emails were sent via
   smtplib.SMTP('localhost', 25), which has no MTA in the workers container ->
   every send failed '[Errno 111] Connection refused', so customers never got
   their e-sign links and orders sat 'awaiting client signature' forever. Routed
   all 9 hardcoded localhost:25 sites (state_trucking, mcs150_update, boc3_filing,
   hazmat_phmsa, mailbox_setup, dot_esign, completion_emails) through the
   authenticated SMTP relay (SMTP_HOST/PORT/STARTTLS/login) + added a shared
   worker_email.send_worker_email helper.

2) The ERPNext Sales Order for compliance/compliance_batch was only created in
   the /checkout/create-session endpoint, but CARD orders confirm via the Stripe
   WEBHOOK -> handlePaymentComplete, which never created the SO. Result: every
   webhook-confirmed order had erpnext_sales_order=NULL and workers logged
   'Sales Order not found 404' then built from PG. Added idempotent
   ensureComplianceSalesOrder() to handlePaymentComplete so ALL payment methods
   (card-webhook, PayPal, crypto) create + link the SO.
2026-06-09 14:40:46 -05:00
justin
3c65dd8748 fix(checkout): pull company from intake_data (compliance has no customer_company col)
compliance_orders stores company in intake_data JSON, not a column; read it from
there (company/legal_name/entity_name) with graceful fallback. Fix e2e test seed
accordingly.
2026-06-09 14:31:42 -05:00
justin
9987b1e30d fix(checkout): create Postgres customers row on order completion (PayPal login bug)
Portal login + forgot-password read the Postgres customers table (bcrypt), NOT
ERPNext. ensureCompliancePortalUser (the common path for Stripe/PayPal/crypto via
handlePaymentComplete) only provisioned the ERPNext customer/website-user and
never created the customers row -- so customers (notably PayPal, who reach this
path directly) had no account to log into or reset a password against. Now upserts
the customers row (no password; ON CONFLICT keeps any existing hash) with name +
company so they can register/reset and log in immediately.

Also: narrowed the placeholder-email skip from 'any synthetic@ or pipeline.com' to
exactly 'synthetic@pipeline.com' (the FMCSA-census placeholder) so real customers
on those real consumer domains aren't wrongly skipped -- which is what bit Paul
Wilson. Added cc support to sendEmail. e2e-paypal-portal-fix.mjs is the regression
test (seeds a compliance order, runs handlePaymentComplete, asserts the customers
row is created). Rescue scripts for the affected customer included.
2026-06-09 14:28:19 -05:00
justin
a308aeed6b fix(checkout): batch SO custom_order_type must be 'compliance' not 'compliance_batch'
ERPNext's PW Order Type select field only allows formation/canada_crtc/bundle/
compliance. A batch is a compliance order (multi-service), so use 'compliance';
the multi-service nature is already captured by the line items + external order
id.
2026-06-09 00:27:17 -05:00
justin
baa40443de fix(checkout): create ERPNext Sales Order for compliance_batch orders
Batch orders (CB-XXXX, used by the trucking new-carrier flow and any multi-
service cart) never created an ERPNext Sales Order -- the SO-creation branch was
gated to order_type 'compliance' only. So those paid orders never reached
ERPNext for fulfillment/accounting (0 of all paid batch orders had an
erpnext_sales_order). Added a compliance_batch branch that creates ONE Sales
Order with a line item per service in the batch (+ government-fee + processing-
fee lines), then stamps the SO name on every batch row. Non-blocking like the
others. Also created the missing ERPNext Items the new slugs reference
(DOT-NEW-CARRIER-BUNDLE, LLC-FORMATION, CORP-FORMATION, NEW-CARRIER-BUNDLE which
was missing too, GOVERNMENT-FILING-FEE).
2026-06-09 00:23:15 -05:00
justin
c6819371d8 fix(checkout): trucking new-carrier ordered the wrong (telecom) product + ACH broke
Two reported bugs, plus two related ones found while tracing:

1. WRONG PRODUCT (Stripe showed 'FCC setup package' for a trucking order): the
   trucking new-carrier form reused the slug 'new-carrier-bundle', which is the
   TELECOM VoIP onboarding bundle (FRN+499+RMD+CPNI+CALEA, $1799). So trucking
   customers were charged the telecom product/price and saw FCC on their receipt.
   Added a distinct 'dot-new-carrier-bundle' (USDOT+MC+BOC-3+MCS-150+Drug&Alcohol,
   $599 + FMCSA gov fees) and pointed the trucking page at it.

2. ACH 500 error: the Stripe session requested the Financial Connections
   'balances' permission, which isn't activated on the account -> Stripe rejected
   the whole session (invalid_request_error). Removed 'balances' (+prefetch); we
   only need 'payment_method' to collect+charge the bank account.

Also fixed (found while tracing):
3. The telecom new-carrier-bundle's BUNDLE_COMPONENTS listed TRUCKING slugs by
   mistake (copy/paste) -- corrected to its real FCC components.
4. The trucking page offered llc-formation / corp-formation / foreign-qual which
   did not exist in the catalog (batch would 400). Added llc-formation +
   corp-formation; remapped foreign-qual -> foreign-qualification-single.

Catalog regenerated (66 -> 69 services), drift-check + tsc clean.
2026-06-08 23:42:36 -05:00
justin
ada9e01321 checkout(healthcare): collapse + reposition surrogate how-to when not granted
The order confirmation asked providers to optionally grant CMS I&A Surrogate
access, then showed the full how-to to everyone high up. Now it respects their
intake answer:
- Granted (surrogate_access=yes): keep the how-to prominent up top so they can
  complete the step they chose.
- Not granted / undecided: top block just confirms 'we're handling it, nothing
  else to do' (no nagging); the speed-it-up how-to moves lower and is collapsed
  behind a 'Show me how' <details> expander (still available, just out of the way).
2026-06-08 00:36:41 -05:00
justin
25cf23dded feat(orders): reduce friction & chargebacks across order flow
1. Email: add a 'Problem with your order? We're here to help' support band to
   the shared htmlEmail() footer, so EVERY transactional email (confirmation,
   portal link, receipts) has a prominent 'Get help with your order' button
   linking to /contact. Less silent frustration -> fewer chargebacks.

2. NPI order form: entering a 10-digit NPI now auto-fills provider name, practice
   state, and specialty from the live NPPES lookup (same API as the free
   compliance-check tool), with a 'Found: <name>' confirmation. Only fills empty
   fields so it never clobbers edits.

3. NPI order form: read ?npi= from the URL so the email 'Start my revalidation'
   click lands with the NPI prefilled and the rest auto-filled (was being
   ignored entirely before).

4. Support FAB: add the floating help button + panel to 27 static public pages
   that were missing it (order, portal, trucking, survey, upload pages), so help
   is one click away everywhere.
2026-06-08 00:24:17 -05:00
justin
483f185861 feat(healthcare): prove revalidation is real via official CMS data + self-verify
Skepticism ("is this even real?") is the top objection. The data IS accurate
(verified our subscribers' NPIs match the official CMS Revalidation Due Date List
exactly), so this is a credibility-presentation fix:

1. Email: replace the plain detail row with an "Official record - CMS Medicare
   Revalidation Due Date List" card (NPI, legal name, due date, days overdue)
   plus a "Verify on CMS.gov" button. Clearly labeled as our presentation of
   public CMS data, not a CMS screenshot (no impersonation).
2. API: npi/lookup now pulls the revalidation due date LIVE from the public CMS
   dataset (data.cms.gov) instead of the empty local table, and returns a
   revalidation{ due_date, source, cms_legal_name, verify_url } proof object.
3. Tool: /tools/npi-compliance-check shows a live "official record" card with a
   self-verify link when CMS returns a due date.

Builder now stores reval_due_date/days_overdue as separate attribs for the card
(existing 194 subscribers backfilled from their detail string).
2026-06-07 23:54:01 -05:00
justin
09e21a6c97 refactor(pricing): single source of truth for the service catalog
Previously two hand-maintained price lists (API COMPLIANCE_SERVICES + site
SERVICE_META) drifted apart -- that is how the healthcare +$200 raise charged
$399 while displaying $599. Eliminate the drift class entirely:

- Move the catalog to api/src/service-catalog.ts (the authority; checkout
  charges from it). compliance-orders.ts imports it.
- scripts/gen-service-catalog.mjs generates site/src/lib/service-catalog.generated.ts
  from the API source. intake_manifest.ts re-exports SERVICE_META from it, so all
  ~60 site pages keep working unchanged.
- deploy.sh regenerates + drift-checks before building (site build context is
  ./site only and cannot read ../api, so generation happens host-side).
- scripts/check-service-catalog-drift.mjs fails the build if the generated file
  ever diverges from the API (verified: passes aligned, fails on mismatch).

To change a price now, edit ONE file: api/src/service-catalog.ts.
2026-06-07 19:11:34 -05:00
justin
e54459776c fix(healthcare): apply the +$200 increase to the API price catalog (was display-only)
The healthcare +$200 raise (commit 3859557) updated the displayed prices
(intake_manifest + service pages) but NOT the API COMPLIANCE_SERVICES catalog,
which is what actually charges. Customers saw $599 but were charged $399.

  npi-revalidation           $399 -> $599
  npi-reactivation           $249 -> $449
  nppes-update               $149 -> $349
  medicare-enrollment        $499 -> $699
  oig-sam-screening           $99 -> $299
  provider-compliance-bundle $699 -> $899

checkout.ts charges order.service_fee_cents (set from this catalog at order
creation), so this makes charged = displayed for all 6 provider services.
2026-06-07 18:57:26 -05:00
justin
e5db147319 esign: make signing copy fully generic - remove all ink references from website/API
Client-facing and website code now describes only a generic per-document signing
authorization; nothing visible to signers or recorded in the website/API code or
DB schema references ink, paper, reproduction, or any fulfillment mechanics.

- rename esign-ink-consent.ts -> esign-sign-consent.ts; INK_CONSENT_TEXT ->
  SIGN_CONSENT_TEXT (generic: 'use my signature to complete and submit this
  single filing', no ink/paper/reproduce language); helpers ink* -> sign*
- portal-esign-generic.ts: API field ink_reproduction -> require_sign_consent,
  ink_consent_text -> sign_consent_text, request field ink_consent -> sign_consent
- signing page (site/public/portal/esign): all ids/vars/comments ink* -> sign*;
  no 'ink' string remains
- npi_provider metadata flag ink_reproduction -> require_sign_consent
- migration 090/092 + live DB column comments rewritten to drop ink/plotter
  wording (DB column names kept as ink_consent* for compat, internal only)
- order-timeline.ts buffer comments neutralized
- tests: 37 checks, consent text asserted to omit ink/plotter/paper/reproduce/etc

DB columns ink_consent* retained (internal, never sent to clients) to avoid a
risky rename of already-applied prod columns.
2026-06-07 05:06:26 -05:00
justin
a4bad723bc esign: ink-reproduction consent gate + patent-risk research
Consent gate (the legal linchpin from the wet-signature memo):
- migration 092 adds ink_consent/ink_consent_at/ink_consent_text to esign_records
- extract pure, unit-tested gate logic into esign-ink-consent.ts (DRY single
  source for route + signing page): isInkReproduction / inkConsentRequired /
  inkConsentSatisfied + verbatim client-safe INK_CONSENT_TEXT
- portal-esign-generic.ts: GET surfaces ink_reproduction + consent text; POST
  gates DRAWN signatures on ink-path docs on explicit consent, stores it
- signing page locks the signature block until consent is checked (drawn only)
- npi_provider marks cms855/cms10114 esign metadata ink_reproduction=true
- 33 unit checks: gate truth table + consent text omits all internal mechanics
  (plotter/machine/CMS/MAC/etc) and keeps required legal reassurances

Patent-risk memo (docs/legal/patent-risk-mechanical-wet-signature.md):
- prior-art-dated risk analysis (autopen 1803/1942, plotters, CNC = public domain
  => low risk on core concept; e-sign workflow space litigious)
- firsthand recent-grant sweep (1.58M USPTO grants 2021-2025, queried via DuckDB):
  ZERO patents on machine-applies-signature-in-ink; e-sign players hold only
  electronic-workflow patents. Not an FTO; flags where attorney search is needed
2026-06-07 04:44:11 -05:00
justin
aafa76df83 Add missing admin_todos migration (091)
The admin_todos table is written by 8 worker handlers (and the new shared
create_admin_todo helper) but had NO CREATE migration anywhere, so every
fulfillment-task insert silently failed in environments without the table.
Define it with the exact column set the handlers use, plus status/priority/
order indexes and operator workflow columns (assigned_to, notes, completed_at).

Applied 076,085,086,088,089,090,091 to the dev DB (all idempotent); verified
admin_todos, esign_records, paper_filing_batches, compliance_orders.
fulfillment_status, and esign_records.signature_vector all exist and accept the
handler insert shape.
2026-06-07 03:22:28 -05:00
justin
28b1af341d Wire fulfillment alerts to Telegram + surface order progress in portal + even out ERPNext sync
Telegram notifications:
- Add shared scripts/workers/telegram_notify.py (send_telegram, notify_fulfillment_todo,
  create_admin_todo) so every worker alerts the operator the same way; fire-and-forget.
- Fire notify_fulfillment_todo after each admin_todos insert across all 8 service
  handlers (9 sites) so no fulfillment task waits unseen.
  (Orders + quotes + tickets already notified via checkout/quotes/tickets routes.)

Client portal order progress:
- order-timeline: derive real per-step status from live signals (payment paid,
  e-signature signed, fulfillment_status) instead of a static template; add
  current_step to the response.
- Extract pure applyLiveStatus into order-timeline-status.ts (DB-free) + unit test
  (api/test/test_timeline_status.ts, 8 cases).
- portal /me now returns compliance_orders.fulfillment_status.
- Dashboard renders a client-safe Progress badge (In progress / Action needed /
  Filed-awaiting-confirmation / Completed); batches show the most actionable status.
  No back-office mechanics exposed.

ERPNext sync parity:
- Create a Sales Order for formation and fcc_carrier_registration orders (previously
  only canada_crtc + compliance synced); write erpnext_sales_order back to each table.
  Non-blocking, matches existing pattern.

Verified: API tsc clean, timeline unit tests 8/8, Astro build 58 pages,
cms10114/ink/paper_batch Python tests still green, no mechanics leaks.
2026-06-07 03:17:46 -05:00
justin
41df4d9553 healthcare: go live with wet-signature filings + 2-day ETA buffer
Adds explicit order timelines for the 5 CMS provider filings (nppes-update,
npi-reactivation, npi-revalidation, medicare-enrollment, provider-compliance-
bundle) and a +2 business-day buffer applied from the signature step onward,
giving us time to produce + mail the original ink signature while the plotter
station is brought online.

- Buffer only affects post-signature steps; pre-signature steps and all
  non-wet-signature services are unchanged (verified with date math).
- Single source of truth: order-timeline route (consumed by the order success
  page), so the buffer flows through to the customer-facing ETA.
- Remove WET_SIGNATURE_BUFFER_DAYS once the ink-signature station is in
  steady-state (see docs/plans/plotter-plan.md).

API tsc clean; buffer compiled into dist.
2026-06-07 02:56:41 -05:00