From 8a2c0387ffc5295b6b63ced08ce2a3f1a0f6d3a9 Mon Sep 17 00:00:00 2001 From: devkryssie Date: Sun, 27 Sep 2026 20:31:41 +0100 Subject: [PATCH] feat(messaging): REGISTER, ONBOARD, UPDATE WhatsApp commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merchants can now complete the full onboarding flow from WhatsApp: REGISTER — create merchant account ONBOARD — verify bank account, create Paystack subaccount, activate merchant UPDATE — change payout account (Rule 5 cooling-off, immediate notification, reply CANCEL to abort) Unregistered senders now receive the REGISTER prompt instead of a generic 'contact support' message. USAGE_MESSAGE updated to list all available commands. --- services/messaging/main.py | 278 ++++++++++++++++++++++++++++++++++++- 1 file changed, 272 insertions(+), 6 deletions(-) diff --git a/services/messaging/main.py b/services/messaging/main.py index c22d49a..7efe62a 100644 --- a/services/messaging/main.py +++ b/services/messaging/main.py @@ -138,8 +138,12 @@ class ParseError(ValueError): USAGE_MESSAGE = ( "Sorry, I didn't understand that.\n\n" - "To create a payment link, send:\n" - " PAY \n\n" + "Available commands:\n" + " REGISTER — register your business\n" + " ONBOARD — set up your payout account\n" + " UPDATE — change payout account\n" + " PAY — create a payment link\n" + " CANCEL — cancel a pending account change\n\n" "Example:\n" " PAY 750 Ankara fabric x2" ) @@ -186,6 +190,46 @@ def parse_pay_command(text: str) -> tuple[int, str]: # Merchant resolution (updated for Meta's bare-digit ID format) # --------------------------------------------------------------------------- + +def parse_register_command(text: str) -> str: + """ + Parse 'REGISTER ' from the merchant's message. + Returns the business name. + Raises ParseError if format is wrong. + """ + parts = text.strip().split(None, 1) + if len(parts) < 2 or parts[0].upper() != "REGISTER": + raise ParseError(USAGE_MESSAGE) + business_name = parts[1].strip() + if not business_name: + raise ParseError("Please include your business name.\nExample: REGISTER Adaeze Fashion Store") + if len(business_name) > 200: + raise ParseError("Business name must be 200 characters or fewer.") + return business_name + + +def parse_account_command(text: str, keyword: str) -> tuple[str, str]: + """ + Parse 'ONBOARD ' or + 'UPDATE '. + Returns (account_number, bank_code). + Raises ParseError on bad format. + """ + parts = text.strip().split() + if len(parts) != 3 or parts[0].upper() != keyword.upper(): + raise ParseError( + f"Format: {keyword.upper()} <10-digit account number> \n" + f"Example: {keyword.upper()} 0123456789 058\n\n" + "To find your bank code, ask ConFam support." + ) + account_number, bank_code = parts[1], parts[2] + if not account_number.isdigit() or len(account_number) != 10: + raise ParseError("Account number must be exactly 10 digits.\nExample: 0123456789") + if not bank_code.isdigit(): + raise ParseError("Bank code must be numeric.\nExample: 058 for GTBank") + return account_number, bank_code + + class UnregisteredSender(Exception): """Raised when the sender's WhatsApp ID isn't registered as a merchant.""" @@ -322,14 +366,27 @@ async def whatsapp_webhook(request: Request) -> Response: checkout_base = os.environ.get("CHECKOUT_BASE_URL", "http://localhost:8001") with get_conn() as conn: + # Handle REGISTER before merchant resolution — unregistered senders can register + cmd_word = message_text.strip().split()[0].upper() if message_text.strip() else "" + if cmd_word == "REGISTER": + try: + business_name = parse_register_command(message_text) + except ParseError as exc: + _send_whatsapp(from_id, str(exc)) + return Response(status_code=200) + return _handle_register_command(conn, from_id, business_name) + # Resolve sender to merchant try: merchant = resolve_merchant(conn, from_id) except UnregisteredSender: _send_whatsapp( from_id, - "You're not registered with ConFam. " - "Please contact support to get started.", + "You're not registered with ConFam yet.\n\n" + "To get started, send:\n" + " REGISTER \n\n" + "Example:\n" + " REGISTER Adaeze Fashion Store", ) log.warning("whatsapp_unregistered_sender", from_id=from_id) return Response(status_code=200) @@ -344,10 +401,28 @@ async def whatsapp_webhook(request: Request) -> Response: ) return Response(status_code=200) - # Handle CANCEL / STOP — payout account change cancellation (OQ-025) - if message_text.strip().upper() in ("CANCEL", "STOP"): + # Route commands + cmd = message_text.strip().split()[0].upper() if message_text.strip() else "" + + if cmd in ("CANCEL", "STOP"): return _handle_cancel_command(conn, from_id, merchant_id) + if cmd == "ONBOARD": + try: + account_number, bank_code = parse_account_command(message_text, "ONBOARD") + except ParseError as exc: + _send_whatsapp(from_id, str(exc)) + return Response(status_code=200) + return _handle_onboard_command(conn, from_id, merchant_id, account_number, bank_code) + + if cmd == "UPDATE": + try: + account_number, bank_code = parse_account_command(message_text, "UPDATE") + except ParseError as exc: + _send_whatsapp(from_id, str(exc)) + return Response(status_code=200) + return _handle_update_command(conn, from_id, merchant_id, account_number, bank_code) + # Parse PAY command try: amount_minor_units, description = parse_pay_command(message_text) @@ -397,6 +472,197 @@ async def whatsapp_webhook(request: Request) -> Response: # CANCEL command handler (OQ-025) # --------------------------------------------------------------------------- + +def _handle_register_command(conn, from_id: str, business_name: str) -> Response: + """ + REGISTER — create a new merchant account. + + Creates a merchant with status=pending_verification and confam_thread_id + set to the sender's WhatsApp ID (bare digits). The merchant can then + use ONBOARD to add their bank account. + """ + import psycopg2.errors as pg_errors + + # Check if already registered + existing = resolve_merchant.__wrapped__(conn, from_id) if hasattr(resolve_merchant, '__wrapped__') else None + try: + with conn.cursor() as cur: + cur.execute( + """INSERT INTO merchants (whatsapp_number, confam_thread_id, business_name, status) + VALUES (%s, %s, %s, 'pending_verification') RETURNING merchant_id""", + (f"+{from_id}", from_id, business_name), + ) + merchant_id = str(cur.fetchone()[0]) + conn.commit() + _send_whatsapp( + from_id, + f"✅ Business registered!\n\n" + f"Business: {business_name}\n\n" + f"Next step — set up your payout account:\n" + f"Send: ONBOARD \n" + f"Example: ONBOARD 0123456789 058", + ) + log.info("merchant_registered_via_whatsapp", from_id=from_id, merchant_id=merchant_id) + except Exception as exc: + if "unique" in str(exc).lower(): + conn.rollback() + _send_whatsapp( + from_id, + "You already have a ConFam account.\n" + "Send ONBOARD to set up your payout account,\n" + "or PAY to create a payment link.", + ) + else: + conn.rollback() + log.error("register_command_failed", from_id=from_id, error=str(exc)) + _send_whatsapp(from_id, "Registration failed. Please try again or contact support.") + return Response(status_code=200) + + +def _handle_onboard_command(conn, from_id: str, merchant_id: str, account_number: str, bank_code: str) -> Response: + """ + ONBOARD — first-time payout account setup. + Calls Paystack bank/resolve + create_subaccount in-process. + """ + from confam.paystack import PaystackError, resolve_bank_account, create_subaccount + from confam.payout_accounts import PendingChangeAlreadyExists + + # Verify bank account with Paystack + try: + resolved = resolve_bank_account(account_number=account_number, bank_code=bank_code) + except PaystackError as exc: + _send_whatsapp( + from_id, + f"❌ Could not verify that bank account.\n\n" + f"Please check the account number and bank code are correct.\n" + f"Error: {str(exc)[:100]}", + ) + log.warning("onboard_resolution_failed", from_id=from_id, error=str(exc)) + return Response(status_code=200) + + # Get merchant's business name for Paystack subaccount + with conn.cursor() as cur: + cur.execute("SELECT business_name FROM merchants WHERE merchant_id = %s", (merchant_id,)) + row = cur.fetchone() + business_name = (row[0] if row and row[0] else resolved.account_name) + + try: + subaccount = create_subaccount( + business_name=business_name, + bank_code=bank_code, + account_number=account_number, + percentage_charge=0.0, + ) + except PaystackError as exc: + _send_whatsapp(from_id, "❌ Account setup failed. Please try again or contact support.") + log.error("onboard_subaccount_failed", from_id=from_id, error=str(exc)) + return Response(status_code=200) + + # Write PayoutAccount and activate merchant + try: + with conn.cursor() as cur: + cur.execute( + """INSERT INTO payout_accounts ( + merchant_id, bank_account_number, bank_code, account_holder_name, + verification_method, verified_at, active_from, paystack_subaccount_code + ) VALUES (%s, %s, %s, %s, 'bank_api_resolve', now(), now(), %s)""", + (merchant_id, account_number, bank_code, resolved.account_name, subaccount.subaccount_code), + ) + cur.execute("UPDATE merchants SET status = 'active' WHERE merchant_id = %s", (merchant_id,)) + conn.commit() + except Exception as exc: + conn.rollback() + if "unique" in str(exc).lower(): + _send_whatsapp( + from_id, + "You already have an active payout account.\n" + "To change it, send: UPDATE ", + ) + else: + log.error("onboard_db_write_failed", from_id=from_id, error=str(exc)) + _send_whatsapp(from_id, "❌ Setup failed. Please try again or contact support.") + return Response(status_code=200) + + masked = "*" * (len(account_number) - 4) + account_number[-4:] + _send_whatsapp( + from_id, + f"✅ Payout account set up!\n\n" + f"Account holder: {resolved.account_name}\n" + f"Account: {masked}\n\n" + f"You're ready to accept payments.\n" + f"Send: PAY \n" + f"Example: PAY 750 Ankara fabric x2", + ) + log.info("onboard_command_succeeded", from_id=from_id, merchant_id=merchant_id) + return Response(status_code=200) + + +def _handle_update_command(conn, from_id: str, merchant_id: str, account_number: str, bank_code: str) -> Response: + """ + UPDATE — change payout account with cooling-off. + Sends immediate notification. Merchant can reply CANCEL to abort. + """ + from confam.paystack import PaystackError, resolve_bank_account, create_subaccount + from confam.payout_accounts import ( + NoActivePayoutAccount, PendingChangeAlreadyExists, request_payout_account_change, + ) + + try: + resolved = resolve_bank_account(account_number=account_number, bank_code=bank_code) + except PaystackError as exc: + _send_whatsapp(from_id, f"❌ Could not verify that bank account.\nError: {str(exc)[:100]}") + return Response(status_code=200) + + with conn.cursor() as cur: + cur.execute("SELECT business_name FROM merchants WHERE merchant_id = %s", (merchant_id,)) + row = cur.fetchone() + business_name = (row[0] if row and row[0] else resolved.account_name) + + try: + subaccount = create_subaccount( + business_name=business_name, bank_code=bank_code, + account_number=account_number, percentage_charge=0.0, + ) + except PaystackError as exc: + _send_whatsapp(from_id, "❌ Update failed. Please try again or contact support.") + return Response(status_code=200) + + try: + pending = request_payout_account_change( + conn, merchant_id=merchant_id, + bank_account_number=account_number, bank_code=bank_code, + account_holder_name=resolved.account_name, + paystack_subaccount_code=subaccount.subaccount_code, + ) + except NoActivePayoutAccount: + _send_whatsapp( + from_id, + "You don't have a payout account set up yet.\n" + "Send: ONBOARD ", + ) + return Response(status_code=200) + except PendingChangeAlreadyExists: + _send_whatsapp( + from_id, + "⚠️ You already have a pending account change.\n" + "Reply CANCEL to cancel it first, then send UPDATE again.", + ) + return Response(status_code=200) + + import os as _os + cooling = int(_os.environ.get("PAYOUT_ACCOUNT_COOLING_OFF_SECONDS", "172800")) // 3600 + masked = "*" * (len(account_number) - 4) + account_number[-4:] + _send_whatsapp( + from_id, + f"⚠️ Payout account change requested\n\n" + f"New account: {masked} ({resolved.account_name})\n" + f"Takes effect in: {cooling} hours\n\n" + f"If this wasn't you, reply CANCEL immediately.", + ) + log.info("update_command_succeeded", from_id=from_id, merchant_id=merchant_id) + return Response(status_code=200) + + def _handle_cancel_command(conn, from_id: str, merchant_id: str) -> Response: """ Handle a CANCEL or STOP reply from a merchant during the cooling-off window.