""" Relay Financial (relayfi.com) integration for Performance West. Relay is our business banking platform. No public API exists, so integration is via: 1. Encrypted card details stored in ERPNext Sensitive ID doctype 2. Playwright automation to enter card on state portal payment forms 3. Relay deposit email monitoring (relay_deposit_monitor.py) for funds gate 4. Manual ACH for commission payouts (via Relay dashboard) Multi-card support: - SID-0002 (Stripe Issuing): Virtual card funded by Stripe balance — used for card/ACH/Klarna orders. Funds available as soon as Stripe settlement clears (typically T+2 for cards, T+4 for ACH). - SID-0001 (PayPal MC): PayPal Mastercard — used for PayPal-funded orders. Funds available immediately in PayPal balance. - crypto-filing-card: Secondary card — used for crypto-funded orders (SHKeeper). Admin manually advances "Awaiting Funds" after confirming SHKeeper wallet balance. - relay-filing-card: Legacy Relay virtual debit card — fallback only. IMPORTANT: Card details are stored encrypted in ERPNext using the Password fieldtype (AES). They are decrypted only in memory during the filing automation, never written to disk or logs. """ from __future__ import annotations import logging import os from dataclasses import dataclass from typing import Optional LOG = logging.getenv("RELAY_LOG_LEVEL", "workers.relay") LOG = logging.getLogger("workers.relay") # ERPNext Sensitive ID names for each card RELAY_FILING_CARD_ID = os.getenv("RELAY_FILING_CARD_ID", "relay-filing-card") STRIPE_FILING_CARD_ID = os.getenv("STRIPE_FILING_CARD_ID", "SID-0002") CRYPTO_FILING_CARD_ID = os.getenv("CRYPTO_FILING_CARD_ID", "crypto-filing-card") PAYPAL_FILING_CARD_ID = os.getenv("PAYPAL_FILING_CARD_ID", "SID-0001") # Legacy alias — defaults to Relay card for backward compatibility FILING_CARD_ID = RELAY_FILING_CARD_ID @dataclass class CardDetails: """Debit card details for state filing payments.""" card_number: str exp_month: str exp_year: str cvv: str cardholder_name: str = "Performance West Inc" billing_address_line1: str = "" billing_address_line2: str = "" billing_city: str = "" billing_state: str = "" billing_zip: str = "82001" @property def exp_mmyy(self) -> str: return f"{self.exp_month}/{self.exp_year}" @property def exp_mmslashyyyy(self) -> str: yr = self.exp_year if len(self.exp_year) == 4 else f"20{self.exp_year}" return f"{self.exp_month}/{yr}" def masked(self) -> str: """Return masked card number for logging (last 4 only).""" return f"****{self.card_number[-4:]}" if len(self.card_number) >= 4 else "****" def _load_card_by_id(sensitive_id_name: str) -> Optional[CardDetails]: """ Load a debit card from ERPNext Sensitive ID by record name. Uses the whitelisted API endpoint performancewest_erpnext.api.get_filing_card which decrypts the Password field server-side. Frappe's standard REST API masks Password fields with ********, so get_list/get_doc cannot be used. """ from scripts.workers.erpnext_client import ERPNextClient try: client = ERPNextClient() data = client.call_method( "performancewest_erpnext.api.get_filing_card", {"card_name": sensitive_id_name}, ) if not data: LOG.error("Filing card not found or empty (name=%s)", sensitive_id_name) return None card = CardDetails( card_number=data["number"], exp_month=data["exp_month"], exp_year=data["exp_year"], cvv=data["cvv"], cardholder_name=data.get("name", "Performance West Inc"), billing_address_line1=data.get("address_line1", ""), billing_address_line2=data.get("address_line2", ""), billing_city=data.get("city", ""), billing_state=data.get("state", ""), billing_zip=data.get("zip", "82001"), ) LOG.info("Loaded card %s (%s) for filing", card.masked(), sensitive_id_name) return card except Exception as e: LOG.error("Failed to load card %s from ERPNext: %s", sensitive_id_name, e) return None def load_card_from_erpnext(payment_method: str = "card") -> Optional[CardDetails]: """ Load the appropriate filing card based on the order's payment method. - Stripe-funded orders (card, ach, klarna): use STRIPE_FILING_CARD_ID (Stripe Issuing virtual card) - PayPal-funded orders (paypal): use PAYPAL_FILING_CARD_ID (PayPal Mastercard) - Crypto-funded orders (crypto via SHKeeper): use CRYPTO_FILING_CARD_ID - Fallback (legacy Relay): use RELAY_FILING_CARD_ID Args: payment_method: The payment method used for the customer order. Returns: CardDetails or None if not found/error. """ if payment_method == "crypto": card_id = CRYPTO_FILING_CARD_ID elif payment_method == "paypal": card_id = PAYPAL_FILING_CARD_ID elif payment_method in ("card", "ach", "klarna"): card_id = STRIPE_FILING_CARD_ID else: card_id = RELAY_FILING_CARD_ID return _load_card_by_id(card_id) def populate_order_payment(order, payment_method: str = "card") -> bool: """ Load the correct filing card and populate it on the FormationOrder. Selects card based on payment_method: - card/ach/klarna → Relay virtual debit card (funded by Stripe payouts) - crypto → Crypto.com card (funded by crypto proceeds) Card details are held in memory only — never logged or written to disk. """ # Try to infer payment_method from the order itself if not passed if hasattr(order, "payment_method") and order.payment_method: payment_method = order.payment_method card = load_card_from_erpnext(payment_method) if not card: return False order.payment_card_number = card.card_number order.payment_card_exp = card.exp_mmyy order.payment_card_cvv = card.cvv order.payment_card_name = card.cardholder_name order.payment_card_zip = card.billing_zip LOG.info( "Payment card %s (%s) loaded onto order %s", card.masked(), "Crypto.com" if payment_method == "crypto" else "Relay", getattr(order, "order_id", "?"), ) return True def mark_reservation_spent(order_id: str, amount_cents: int) -> None: """Mark a filing fee reservation as spent after Playwright confirms the charge.""" try: import psycopg2 DATABASE_URL = os.getenv("DATABASE_URL", "") if not DATABASE_URL: return with psycopg2.connect(DATABASE_URL) as conn: with conn.cursor() as cur: cur.execute( """ UPDATE filing_fee_reservations SET status = 'spent', spent_at = NOW() WHERE order_id = %s AND status = 'reserved' """, (order_id,), ) conn.commit() LOG.info("Marked reservation spent: %s ($%.2f)", order_id, amount_cents / 100) except Exception as exc: LOG.error("Failed to mark reservation spent for %s: %s", order_id, exc) def release_reservation(order_id: str) -> None: """Release a reserved filing fee back to the available pool (on filing failure).""" try: import psycopg2 DATABASE_URL = os.getenv("DATABASE_URL", "") if not DATABASE_URL: return with psycopg2.connect(DATABASE_URL) as conn: with conn.cursor() as cur: cur.execute( """ UPDATE filing_fee_reservations SET status = 'released', released_at = NOW() WHERE order_id = %s AND status = 'reserved' """, (order_id,), ) conn.commit() LOG.info("Released filing fee reservation for %s", order_id) except Exception as exc: LOG.error("Failed to release reservation for %s: %s", order_id, exc) def record_filing_payment( order_name: str, state_code: str, amount_cents: int, card_last4: str, confirmation: str = "", ) -> bool: """Record a filing payment in ERPNext for reconciliation with Relay transactions. Creates a Journal Entry or custom Payment record that can be matched against the Relay bank statement when imported via Plaid/CSV. """ from scripts.workers.erpnext_client import ERPNextClient try: client = ERPNextClient() # Add a comment to the Formation Order with payment details client.update_resource("Formation Order", order_name, { "admin_notes": ( f"Payment: ${amount_cents/100:.2f} charged to Relay card ****{card_last4} " f"for {state_code} filing. " f"{'Confirmation: ' + confirmation if confirmation else 'Pending confirmation.'}" ), }) LOG.info("Recorded payment of $%.2f for %s on Relay card ****%s", amount_cents / 100, order_name, card_last4) return True except Exception as e: LOG.error("Failed to record filing payment: %s", e) return False def track_commission_payout( partner_name: str, partner_email: str, amount_cents: int, order_numbers: list[str], ) -> bool: """Track a commission payout owed to a referral partner. Since Relay has no ACH API, this creates an ERPNext record that an admin uses to manually send the ACH via the Relay dashboard. The ERPNext record serves as: - A reminder to send the payout - An audit trail of what was paid - Reconciliation when the Relay transaction appears """ from scripts.workers.erpnext_client import ERPNextClient try: client = ERPNextClient() client.create_resource("Issue", { "subject": f"Commission Payout: ${amount_cents/100:.2f} to {partner_name}", "description": ( f"Referral commission payout due:\n\n" f"Partner: {partner_name}\n" f"Email: {partner_email}\n" f"Amount: ${amount_cents/100:.2f}\n" f"Orders: {', '.join(order_numbers)}\n\n" f"Action: Send ACH payment via Relay dashboard to {partner_email}.\n" f"Mark this issue as resolved after payment is sent." ), "issue_type": "Feature Request", # Using as a task type "priority": "Medium", }) LOG.info("Created commission payout task: $%.2f to %s", amount_cents / 100, partner_name) return True except Exception as e: LOG.error("Failed to create commission payout task: %s", e) return False