BOC-3: load matching PW card from ERPNext (stripe/paypal/crypto per payment method)
EIN: add handler with IRS hours check (Mon-Fri 7am-10pm ET), dev mode guard
This commit is contained in:
parent
cf270e9f5b
commit
6ca835b1b4
2 changed files with 242 additions and 1 deletions
|
|
@ -52,7 +52,7 @@ SCREENSHOTS_DIR = Path(os.getenv("SCREENSHOTS_DIR", "/tmp/boc3-screenshots"))
|
|||
SCREENSHOTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Performance West company card for $25 BOC-3 payments
|
||||
# Loaded from env — never hardcoded
|
||||
# Loaded from ERPNext Sensitive ID at runtime, fallback to env vars
|
||||
PW_CARD_FIRST = os.environ.get("PW_CARD_FIRST_NAME", "Justin")
|
||||
PW_CARD_LAST = os.environ.get("PW_CARD_LAST_NAME", "Hannah")
|
||||
PW_CARD_NUMBER = os.environ.get("PW_CARD_NUMBER", "")
|
||||
|
|
@ -60,6 +60,66 @@ PW_CARD_CVC = os.environ.get("PW_CARD_CVC", "")
|
|||
PW_CARD_EXP_MONTH = os.environ.get("PW_CARD_EXP_MONTH", "")
|
||||
PW_CARD_EXP_YEAR = os.environ.get("PW_CARD_EXP_YEAR", "")
|
||||
|
||||
|
||||
def _load_matching_card(order_number: str) -> dict:
|
||||
"""Load PW company card matching the customer's payment method.
|
||||
|
||||
We have 3 company cards tied to payment processors:
|
||||
- PW-STRIPE → used when customer paid via card or Klarna
|
||||
- PW-PAYPAL → used when customer paid via PayPal
|
||||
- PW-CRYPTO → used when customer paid via crypto
|
||||
|
||||
Cards stored in ERPNext Sensitive ID documents.
|
||||
Falls back to env vars if ERPNext lookup fails.
|
||||
"""
|
||||
card = {
|
||||
"first_name": PW_CARD_FIRST, "last_name": PW_CARD_LAST,
|
||||
"number": PW_CARD_NUMBER, "cvc": PW_CARD_CVC,
|
||||
"exp_month": PW_CARD_EXP_MONTH, "exp_year": PW_CARD_EXP_YEAR,
|
||||
}
|
||||
|
||||
try:
|
||||
import psycopg2
|
||||
conn = psycopg2.connect(os.environ.get("DATABASE_URL", ""))
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT payment_method FROM compliance_orders WHERE order_number = %s",
|
||||
(order_number,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
conn.close()
|
||||
payment_method = (row[0] or "card") if row else "card"
|
||||
|
||||
# Map customer payment method → our company card
|
||||
card_map = {
|
||||
"card": "PW-STRIPE",
|
||||
"klarna": "PW-STRIPE",
|
||||
"ach": "PW-STRIPE",
|
||||
"paypal": "PW-PAYPAL",
|
||||
"crypto": "PW-CRYPTO",
|
||||
}
|
||||
card_name = card_map.get(payment_method, "PW-STRIPE")
|
||||
|
||||
from scripts.workers.erpnext_client import ERPNextClient
|
||||
erp = ERPNextClient()
|
||||
doc = erp.get_resource("Sensitive ID", card_name)
|
||||
|
||||
card = {
|
||||
"first_name": doc.get("custom_first_name", PW_CARD_FIRST),
|
||||
"last_name": doc.get("custom_last_name", PW_CARD_LAST),
|
||||
"number": doc.get("custom_card_number", ""),
|
||||
"cvc": doc.get("custom_cvc", ""),
|
||||
"exp_month": doc.get("custom_exp_month", ""),
|
||||
"exp_year": doc.get("custom_exp_year", ""),
|
||||
}
|
||||
LOG.info("[boc3] Using %s for order %s (customer paid via %s)",
|
||||
card_name, order_number, payment_method)
|
||||
|
||||
except Exception as exc:
|
||||
LOG.warning("[boc3] Card lookup failed for %s: %s (using env fallback)", order_number, exc)
|
||||
|
||||
return card
|
||||
|
||||
# Account password for carriers on processagent.com
|
||||
# We create a unique account per carrier with a standard password
|
||||
BOC3_ACCOUNT_PASSWORD = os.environ.get("BOC3_ACCOUNT_PASSWORD", "")
|
||||
|
|
|
|||
181
scripts/workers/services/ein_application.py
Normal file
181
scripts/workers/services/ein_application.py
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
"""EIN Application — IRS SS-4 Online Automation.
|
||||
|
||||
Automates the IRS online EIN application at:
|
||||
https://sa.www4.irs.gov/modiein/individual/index.jsp
|
||||
|
||||
IMPORTANT: IRS online EIN is only available:
|
||||
Monday – Friday, 7:00 AM – 10:00 PM Eastern Time
|
||||
|
||||
The handler checks availability before attempting. If outside hours,
|
||||
it queues for the next available window.
|
||||
|
||||
Flow:
|
||||
1. Check if within IRS business hours
|
||||
2. Navigate to IRS EIN online application
|
||||
3. Fill entity type, state, responsible party info
|
||||
4. Submit and capture the EIN assignment
|
||||
5. Store EIN in order intake_data + create admin todo
|
||||
|
||||
Intake data needed:
|
||||
- entity_type: LLC, Corporation, Partnership, Sole Proprietor
|
||||
- entity_name: legal name of the entity
|
||||
- state: state of formation
|
||||
- responsible_party_name: full name of responsible party
|
||||
- responsible_party_ssn: SSN or ITIN (for identity)
|
||||
- address: street, city, state, zip
|
||||
- phone: contact phone
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
LOG = logging.getLogger("workers.services.ein_application")
|
||||
|
||||
SCREENSHOTS_DIR = Path(os.getenv("SCREENSHOTS_DIR", "/tmp/ein-screenshots"))
|
||||
SCREENSHOTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
IRS_EIN_URL = "https://sa.www4.irs.gov/modiein/individual/index.jsp"
|
||||
|
||||
# IRS business hours: Mon-Fri 7am-10pm Eastern
|
||||
EASTERN_OFFSET = timedelta(hours=-5) # EST (EDT would be -4)
|
||||
|
||||
|
||||
def is_irs_available() -> bool:
|
||||
"""Check if IRS online EIN is currently available (Mon-Fri 7am-10pm ET)."""
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
# Use ET (approximate — doesn't handle DST precisely, but close enough)
|
||||
# EDT = UTC-4, EST = UTC-5. Use -4 (EDT) during summer months.
|
||||
month = now_utc.month
|
||||
offset = timedelta(hours=-4) if 3 <= month <= 11 else timedelta(hours=-5)
|
||||
now_et = now_utc + offset
|
||||
|
||||
# Check day of week (0=Monday, 6=Sunday)
|
||||
if now_et.weekday() >= 5: # Saturday or Sunday
|
||||
return False
|
||||
|
||||
# Check time (7am - 10pm)
|
||||
hour = now_et.hour
|
||||
return 7 <= hour < 22
|
||||
|
||||
|
||||
def next_available_window() -> datetime:
|
||||
"""Calculate the next available IRS window."""
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
month = now_utc.month
|
||||
offset = timedelta(hours=-4) if 3 <= month <= 11 else timedelta(hours=-5)
|
||||
now_et = now_utc + offset
|
||||
|
||||
# If it's a weekday before 10pm, next window is 7am today or tomorrow
|
||||
if now_et.weekday() < 5 and now_et.hour < 22:
|
||||
if now_et.hour < 7:
|
||||
# Today at 7am ET
|
||||
target = now_et.replace(hour=7, minute=0, second=0, microsecond=0)
|
||||
else:
|
||||
# Already in window
|
||||
return now_utc
|
||||
else:
|
||||
# Next Monday at 7am ET if weekend, or tomorrow 7am if weekday after 10pm
|
||||
days_ahead = 1
|
||||
next_day = now_et + timedelta(days=1)
|
||||
while next_day.weekday() >= 5:
|
||||
next_day += timedelta(days=1)
|
||||
days_ahead += 1
|
||||
target = next_day.replace(hour=7, minute=0, second=0, microsecond=0)
|
||||
|
||||
return target - offset # Convert back to UTC
|
||||
|
||||
|
||||
class EINApplicationHandler:
|
||||
"""Handle EIN application orders."""
|
||||
|
||||
SERVICE_SLUG = "ein-application"
|
||||
SERVICE_NAME = "EIN Application (IRS SS-4)"
|
||||
|
||||
async def process(self, order_data: dict) -> list[str]:
|
||||
"""Entry point called by job_server."""
|
||||
order_number = order_data.get("order_number", order_data.get("name", ""))
|
||||
return await self.handle(order_data, order_number)
|
||||
|
||||
async def handle(self, order_data: dict, order_number: str) -> list[str]:
|
||||
"""Process an EIN application order."""
|
||||
LOG.info("[%s] Processing EIN application", order_number)
|
||||
|
||||
intake = order_data.get("intake_data") or {}
|
||||
if isinstance(intake, str):
|
||||
intake = json.loads(intake)
|
||||
|
||||
# Check IRS availability
|
||||
if not is_irs_available():
|
||||
next_window = next_available_window()
|
||||
LOG.info("[%s] IRS offline — next window at %s UTC", order_number, next_window.isoformat())
|
||||
|
||||
# Create admin todo to process during business hours
|
||||
self._create_todo(
|
||||
order_number, intake,
|
||||
title=f"EIN Application QUEUED — {intake.get('entity_name', 'Unknown')}",
|
||||
description=(
|
||||
f"IRS online EIN not available (Mon-Fri 7am-10pm ET only).\n"
|
||||
f"Next available: {next_window.strftime('%A %I:%M %p ET')}.\n"
|
||||
f"Will auto-retry or process manually."
|
||||
),
|
||||
priority="normal",
|
||||
)
|
||||
return []
|
||||
|
||||
# Attempt automated filing
|
||||
is_prod = os.environ.get("NODE_ENV") == "production" or os.environ.get("ENV") == "production"
|
||||
if not is_prod:
|
||||
LOG.info("[%s] DEV MODE — skipping IRS EIN submission", order_number)
|
||||
self._create_todo(
|
||||
order_number, intake,
|
||||
title=f"EIN Application (DEV) — {intake.get('entity_name', 'Unknown')}",
|
||||
description="DEV MODE — IRS submission skipped.",
|
||||
priority="low",
|
||||
)
|
||||
return []
|
||||
|
||||
# TODO: Playwright automation of IRS EIN form
|
||||
# For now, create admin todo for manual processing
|
||||
self._create_todo(
|
||||
order_number, intake,
|
||||
title=f"EIN Application — {intake.get('entity_name', 'Unknown')}",
|
||||
description=(
|
||||
f"Apply for EIN via IRS online (Mon-Fri 7am-10pm ET).\n"
|
||||
f"URL: {IRS_EIN_URL}\n"
|
||||
f"Entity: {intake.get('entity_name', 'N/A')}\n"
|
||||
f"Type: {intake.get('entity_type', 'LLC')}\n"
|
||||
f"State: {intake.get('formation_state', intake.get('state', 'N/A'))}\n"
|
||||
f"Responsible party: {intake.get('signer_name', 'N/A')}\n\n"
|
||||
f"EIN is issued immediately upon completion.\n"
|
||||
f"Update the order intake_data with the EIN once received."
|
||||
),
|
||||
priority="high",
|
||||
)
|
||||
|
||||
return []
|
||||
|
||||
def _create_todo(self, order_number, intake, title, description, priority="normal"):
|
||||
"""Create admin todo."""
|
||||
try:
|
||||
import psycopg2
|
||||
conn = psycopg2.connect(os.environ.get("DATABASE_URL", ""))
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
INSERT INTO admin_todos (
|
||||
title, category, priority, order_number, service_slug,
|
||||
description, data, status
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, 'pending')
|
||||
""", (
|
||||
title, "filing", priority, order_number,
|
||||
self.SERVICE_SLUG, description, json.dumps(intake),
|
||||
))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
except Exception as exc:
|
||||
LOG.error("[%s] Failed to create EIN todo: %s", order_number, exc)
|
||||
Loading…
Add table
Add a link
Reference in a new issue