feat(intrastate): automate state PUC/PSC authority filing (email + invoice + auto-bill)
Intrastate operating authority is state-specific + application-based like IRP, so
it reuses the same email/POA + invoice-reconciliation flow:
- intrastate_filing.send_intrastate_submission: emails the state PSC/PUC the
authority application with the signed POA attached (subject tag [PW-ISA CO-..]),
reusing irp_filing's MinIO download + census enrich helpers.
- The shared poller (irp_invoice_poller) now matches BOTH [PW-IRP] and [PW-ISA]
tags, parses the fee, Telegram-alerts, and bills the customer the exact amount
with the correct service slug.
- state_trucking gov-fee gate routes intrastate-authority to the PSC/PUC email
path; if no submission email is configured for the base state it falls back
to a manual todo (safe default — no emailing guessed agency addresses).
Per-state ISA_<ST>_EMAIL env (blank until the exact agency address is verified).
SC/GA/TX scaffolded. Customer still only sees an exact-fee payment link; you only
approve the final filing.
This commit is contained in:
parent
42b433db5a
commit
b125d46663
4 changed files with 226 additions and 18 deletions
|
|
@ -71,7 +71,10 @@ IRP_STATE_CONTACTS = {
|
|||
}
|
||||
|
||||
SUBJECT_TAG = "PW-IRP" # [PW-IRP CO-XXXXXXXX] in subject for reply matching
|
||||
TAG_RE = re.compile(r"\[PW-IRP\s+(C[OG]-[A-Z0-9]+)\]", re.I)
|
||||
# Match either state-agency tag: IRP or intrastate authority (ISA). Capture the
|
||||
# tag kind + the order number so the poller knows which service to bill.
|
||||
TAG_RE = re.compile(r"\[PW-(IRP|ISA)\s+(C[OG]-[A-Z0-9]+)\]", re.I)
|
||||
TAG_SLUG = {"IRP": "irp-registration", "ISA": "intrastate-authority"}
|
||||
# Fee patterns commonly seen on IRP invoices / replies.
|
||||
FEE_RE = [
|
||||
re.compile(r"(?:total\s+(?:fees?\s+)?due|amount\s+due|total\s+amount|apportioned\s+fees?)[^$]{0,40}\$\s*([0-9][0-9,]*\.?\d{0,2})", re.I),
|
||||
|
|
@ -265,12 +268,19 @@ def poll_irp_invoices() -> int:
|
|||
m = imaplib.IMAP4_SSL(IMAP_HOST, IMAP_PORT, timeout=30)
|
||||
m.login(IMAP_USER, IMAP_PASS)
|
||||
m.select(IMAP_FOLDER)
|
||||
# Unseen mail only. On a shared mailbox, restrict to our subject tag.
|
||||
# Unseen mail only. On a shared mailbox, restrict to our subject tags.
|
||||
if DEDICATED_MAILBOX:
|
||||
typ, data = m.search(None, "UNSEEN")
|
||||
ids = data[0].split() if data and data[0] else []
|
||||
else:
|
||||
typ, data = m.search(None, "UNSEEN", "SUBJECT", SUBJECT_TAG)
|
||||
ids = data[0].split() if data and data[0] else []
|
||||
ids = []
|
||||
seen_ids = set()
|
||||
for tagword in ("PW-IRP", "PW-ISA"):
|
||||
typ, data = m.search(None, "UNSEEN", "SUBJECT", tagword)
|
||||
for i in (data[0].split() if data and data[0] else []):
|
||||
if i not in seen_ids:
|
||||
seen_ids.add(i)
|
||||
ids.append(i)
|
||||
for mid in ids:
|
||||
typ, md = m.fetch(mid, "(RFC822)")
|
||||
if typ != "OK" or not md or not md[0]:
|
||||
|
|
@ -279,11 +289,14 @@ def poll_irp_invoices() -> int:
|
|||
subject = str(email.header.make_header(email.header.decode_header(msg.get("Subject", ""))))
|
||||
tag = TAG_RE.search(subject)
|
||||
if not tag:
|
||||
# Not an IRP reply we can match; leave it unseen on shared mailbox.
|
||||
# Not a state-agency reply we can match; leave unseen on shared mailbox.
|
||||
if not DEDICATED_MAILBOX:
|
||||
m.store(mid, "-FLAGS", "(\\Seen)")
|
||||
continue
|
||||
parent_order = tag.group(1).upper()
|
||||
tag_kind = tag.group(1).upper()
|
||||
parent_order = tag.group(2).upper()
|
||||
slug = TAG_SLUG.get(tag_kind, "irp-registration")
|
||||
kind_label = "IRP" if tag_kind == "IRP" else "Intrastate authority"
|
||||
body = _body_text(msg)
|
||||
fee_cents = _parse_fee_cents(subject + "\n" + body)
|
||||
sender = msg.get("From", "")
|
||||
|
|
@ -291,7 +304,7 @@ def poll_irp_invoices() -> int:
|
|||
if not fee_cents:
|
||||
# Could not parse a fee — alert the operator to read it manually.
|
||||
send_telegram(
|
||||
f"📬 IRP reply received (no fee auto-parsed)\n"
|
||||
f"📬 {kind_label} reply received (no fee auto-parsed)\n"
|
||||
f"Order: {parent_order}\nFrom: {sender}\n"
|
||||
f"Subject: {subject}\nOpen the {IMAP_USER} mailbox to review + enter the fee."
|
||||
)
|
||||
|
|
@ -299,10 +312,10 @@ def poll_irp_invoices() -> int:
|
|||
processed += 1
|
||||
continue
|
||||
|
||||
ok = _bill_parent_irp_fee(parent_order, fee_cents, sender,
|
||||
create_gov_fee_order, send_gov_fee_payment_email, GovFeeEstimate)
|
||||
ok = _bill_parent_fee(parent_order, slug, kind_label, fee_cents, sender,
|
||||
create_gov_fee_order, send_gov_fee_payment_email, GovFeeEstimate)
|
||||
send_telegram(
|
||||
f"💵 IRP invoice received → customer billed\n"
|
||||
f"💵 {kind_label} invoice received → customer billed\n"
|
||||
f"Order: {parent_order}\nState fee: ${fee_cents/100:,.2f}\n"
|
||||
f"From: {sender}\n"
|
||||
+ ("Payment link emailed to the customer." if ok else "⚠️ Could not auto-bill — check logs.")
|
||||
|
|
@ -312,12 +325,12 @@ def poll_irp_invoices() -> int:
|
|||
m.logout()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
LOG.error("[irp-poll] IMAP error: %s", exc)
|
||||
LOG.info("[irp-poll] processed %s IRP invoice reply(ies)", processed)
|
||||
LOG.info("[irp-poll] processed %s state-agency invoice reply(ies)", processed)
|
||||
return processed
|
||||
|
||||
|
||||
def _bill_parent_irp_fee(parent_order, fee_cents, sender,
|
||||
create_gov_fee_order, send_gov_fee_payment_email, GovFeeEstimate) -> bool:
|
||||
def _bill_parent_fee(parent_order, slug, kind_label, fee_cents, sender,
|
||||
create_gov_fee_order, send_gov_fee_payment_email, GovFeeEstimate) -> bool:
|
||||
"""Create the exact-amount gov-fee child + email the customer the payment link."""
|
||||
try:
|
||||
import psycopg2
|
||||
|
|
@ -334,22 +347,22 @@ def _bill_parent_irp_fee(parent_order, fee_cents, sender,
|
|||
LOG.error("[irp-poll] DB lookup failed for %s: %s", parent_order, exc)
|
||||
return False
|
||||
if not row:
|
||||
LOG.warning("[irp-poll] No parent order %s for IRP invoice", parent_order)
|
||||
LOG.warning("[irp-poll] No parent order %s for %s invoice", parent_order, kind_label)
|
||||
return False
|
||||
customer_email, customer_name, customer_phone, service_name = row
|
||||
|
||||
est = GovFeeEstimate(
|
||||
cents=fee_cents,
|
||||
label=f"IRP apportioned registration fee (state invoice) — {sender}",
|
||||
label=f"{kind_label} fee (state invoice) — {sender}",
|
||||
exact=True,
|
||||
breakdown=[f"State IRP invoice: ${fee_cents/100:,.2f}"],
|
||||
breakdown=[f"State {kind_label} invoice: ${fee_cents/100:,.2f}"],
|
||||
)
|
||||
child = create_gov_fee_order(parent_order, "irp-registration", est,
|
||||
child = create_gov_fee_order(parent_order, slug, est,
|
||||
customer_email, customer_name or "", customer_phone or "")
|
||||
if not child:
|
||||
return False
|
||||
send_gov_fee_payment_email(customer_email, customer_name or "",
|
||||
service_name or "IRP Registration",
|
||||
service_name or kind_label,
|
||||
customer_name or "", est, child)
|
||||
return True
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue