"""Map Payoneer payee-details responses onto the banking-details report row.""" from datetime import date from typing import Any, Optional from src.constants import BANK_FIELDS_DETAILS_NAMES from src.models import PayoneerPayeeDetails, PayoneerPayoutMethod # Payoneer returns bank_field_details with human-readable names ("Bank Name", # "SWIFT / BIC"), but our downstream schema uses PascalCase identifiers # (BankName, Swift). Most can be derived by stripping spaces; a few need # explicit aliases where the Payoneer label merges two of our fields. PAYONEER_BANK_FIELD_ALIASES: dict[str, str] = { 'SWIFT / BIC': 'Swift', 'SWIFT/BIC': 'Swift', # Payoneer sometimes abbreviates "Routing Number" to just "Routing". 'Routing': 'RoutingNumber', # Casing drift: Payoneer uses "ID", our schema uses "Id". 'ID Number': 'IdNumber', 'ID Type': 'IdType', # Parenthesised qualifier in the label. 'Account Name (English)': 'AccountNameEnglish', # Payoneer writes this label in lowercase. 'Account holder citizenship': 'AccountHolderCitizenship', # Lowercase "number". 'Passport number': 'PassportNumber', # Chinese bank payloads surface a "Prov / State" field alongside a # separate numeric "Province Code"; map the human-readable name to # our bank `State` column. 'Prov / State': 'State', } # Payoneer's payout_method.bank_account_type is a numeric code; ows-payee's # bank_account_type enum is PERSONAL/COMPANY. _PAYONEER_BANK_ACCOUNT_TYPE: dict[str, str] = { '1': 'PERSONAL', '2': 'COMPANY', } # Payoneer uses non-ISO "UK" for the United Kingdom; downstream expects the # ISO 3166-1 alpha-2 code "GB". _COUNTRY_CODE_OVERRIDES: dict[str, str] = { 'UK': 'GB', } # Report columns match PostBankingDetailsProcessor's expected input so the # output can be reviewed and then dropped into post_banking_details/ as-is. REPORT_COLUMNS = [ 'vendorId', 'payee_id', 'payeeType', 'firstName', 'lastName', 'dateOfBirth', 'email', 'companyName', 'address1', 'address2', 'city', 'state', 'country', 'postal_code', 'bankAccountType', 'bankCountry', 'currency', *BANK_FIELDS_DETAILS_NAMES, ] def _normalize_country(code: str) -> str: return _COUNTRY_CODE_OVERRIDES.get(code.upper(), code) def _normalize_bank_field_name(payoneer_name: str) -> str: """Map a Payoneer bank_field_details.name to our normalized identifier.""" return PAYONEER_BANK_FIELD_ALIASES.get( payoneer_name, payoneer_name.replace(' ', '') ) def map_payoneer_response( vendor_id: str, payee_id: str, details: PayoneerPayeeDetails, date_of_birth_override: Optional[date] = None, ) -> tuple[dict[str, Any], list[str]]: """Map a validated Payoneer payee details model to a flat report row. The output matches PostBankingDetailsProcessor's expected CSV format so the report can be reviewed and re-uploaded without modification. Returns a `(row, warnings)` pair: warnings describe sparse / unknown Payoneer fields that were dropped during mapping. The caller is expected to forward them to the processor's row-level log so they surface in Sentry alongside other processor warnings. """ bank_method = details.payout_method or PayoneerPayoutMethod() warnings: list[str] = [] bank_fields: dict[str, str] = {} for field in bank_method.bank_field_details: if not field.name: warnings.append( f'Payoneer bank_field_details entry missing name for ' f'vendor={vendor_id} payee={payee_id} — skipped' ) continue normalized = _normalize_bank_field_name(field.name) if normalized not in BANK_FIELDS_DETAILS_NAMES: warnings.append( f'Unrecognised Payoneer bank field for ' f'vendor={vendor_id} payee={payee_id}: ' f'raw={field.name!r} normalized={normalized!r} — value dropped' ) continue bank_fields[normalized] = field.value raw_account_type = bank_method.bank_account_type bank_account_type = _PAYONEER_BANK_ACCOUNT_TYPE.get(raw_account_type, '') if raw_account_type and not bank_account_type: # Unknown non-empty code means PostBankingDetailsProcessor will # reject the row downstream; surface the unmapped value so we can # extend `_PAYONEER_BANK_ACCOUNT_TYPE` instead of debugging blanks. warnings.append( f'Unknown Payoneer bank_account_type for ' f'vendor={vendor_id} payee={payee_id}: ' f'raw={raw_account_type!r} — bankAccountType emitted blank' ) # Payoneer API does not currently return date_of_birth on the contact; # `details.contact.date_of_birth` is expected to be empty today. The # override is supplied via the input CSV so the row can still be submitted # downstream, and is kept forward-compatible with a future Payoneer API # revision that exposes the field (override wins when both are present). date_of_birth = ( date_of_birth_override.isoformat() if date_of_birth_override is not None else details.contact.date_of_birth ) # COMPANY-typed payees carry the legal entity name in `contact.first_name`; # Payoneer's v4 response never populates `company.name`. Promote it to # `companyName` so the same value is not duplicated as a person's name. is_company = details.type.upper() == 'COMPANY' first_name = '' if is_company else details.contact.first_name company_name = details.contact.first_name if is_company else '' row: dict[str, Any] = { 'vendorId': vendor_id, 'payee_id': payee_id, 'payeeType': details.type, 'firstName': first_name, 'lastName': details.contact.last_name, 'dateOfBirth': date_of_birth, 'email': details.contact.email, 'companyName': company_name, 'address1': details.address.address_line_1, 'address2': details.address.address_line_2, 'city': details.address.city, 'state': details.address.state, 'country': _normalize_country(details.address.country), 'postal_code': details.address.zip_code, 'bankAccountType': bank_account_type, 'bankCountry': _normalize_country(bank_method.country), 'currency': bank_method.currency, } for name in BANK_FIELDS_DETAILS_NAMES: row[name] = bank_fields.get(name, '') return row, warnings