"""Pull banking details from Payoneer API processor.""" from collections import Counter import concurrent.futures import csv from datetime import datetime from io import StringIO import logging import pathlib import threading from typing import Any from pydantic import ValidationError from pyrate_limiter import Duration, Limiter, Rate from config import app_logger as logger from src.connectors import s3 from src.connectors.payoneer import get_payee_details, PayoneerApiException from src.models import PullBankingDetailsInputRow from src.processors.banking_details.payoneer_mapping import ( map_payoneer_response, REPORT_COLUMNS, ) from src.processors.base import Processor from src.processors.exceptions import ProcessingError from src.utils import chunks, sanitize_csv_cell # Bounded so humans can eyeball a report before re-ingesting via # PostBankingDetailsProcessor; larger chunks slow spreadsheet review. REPORT_CHUNK_SIZE = 1000 # Payoneer's published cap is 2400 req/min = 40 req/s. Default to 30 to # leave headroom for bursts and other clients sharing the same token. FETCH_RATE_LIMIT = 30 # Rate limiter is the real throughput cap; workers only need to keep the # pipeline full during per-request latency and tenacity retry sleeps. FETCH_WORKERS = 20 class PullBankingDetailsProcessor(Processor): """ Processor to pull payee banking details from the Payoneer API. Expects a CSV file with the following columns: - program_id (int): Payoneer program ID - payee_id (int|str): Payee ID (our account_payee_id) - vendor_id (int|str): Account ID (our account_id) - date_of_birth (str, optional): yyyy-mm-dd override that wins over Payoneer's `contact.date_of_birth` in the output report. When blank/absent the mapper passes Payoneer's raw value through unchanged — no format normalization is applied to it. The first row is a header. Outputs: - Report CSV(s) saved to reports/ (max 1000 records per file). Downstream, these are reviewed by humans and re-ingested via PostBankingDetailsProcessor, which handles the ows-payee save. """ _input_rows: list[PullBankingDetailsInputRow] _results: list[dict[str, Any]] _failure_counts: Counter[str] _lock: threading.Lock _limiter: Limiter def process(self) -> None: """Run main logic.""" self._load_input() self._fetch_banking_details() self._upload_reports() def _load_input(self) -> None: """Load and validate input CSV. Per-row validation errors are collected into the failure tally so the end-of-run breakdown still reflects "N invalid_program_id" etc. instead of bailing on the first bad row. """ logger.info('Loading input CSV') try: raw_rows = list(self.csv_dict_reader) except Exception as e: raise ProcessingError(f'Unable to read the file: {e}') if not raw_rows: raise ProcessingError('Input file is empty') # Validate required columns up-front: a missing column affects # every row, so a single error message is clearer than N # identical ValidationErrors. missing = {'program_id', 'payee_id', 'vendor_id'} - set(raw_rows[0].keys()) if missing: raise ProcessingError( f'Missing required columns: {", ".join(sorted(missing))}. ' f'Expected: program_id, payee_id, vendor_id' ) self._input_rows = [] self._failure_counts = Counter() for i, raw in enumerate(raw_rows): try: self._input_rows.append(PullBankingDetailsInputRow.model_validate(raw)) except ValidationError as e: self._record_input_failure(i, e) logger.info( f'Loaded {len(self._input_rows)} valid rows from input file ' f'({len(raw_rows)} total)' ) def _record_input_failure(self, row_index: int, err: ValidationError) -> None: """Log a row-level ValidationError and count it by the first bad field. Category maps 1:1 with the old `parse_int` / missing-field messages so the end-of-run breakdown stays backward-compatible (`invalid_program_id`, `invalid_payee_id`, `invalid_input`). """ errors = err.errors() first = errors[0] if errors else None field = first['loc'][0] if first and first['loc'] else None if field == 'program_id': category = 'invalid_program_id' msg = f'invalid program_id, skipping' elif field == 'payee_id': category = 'invalid_payee_id' msg = f'invalid payee_id, skipping' elif field == 'date_of_birth': category = 'invalid_date_of_birth' msg = 'invalid date_of_birth (expected yyyy-mm-dd), skipping' else: category = 'invalid_input' msg = 'missing program_id, payee_id, or vendor_id, skipping' self._add_log(f'Row {row_index + 1}: {msg}', log_level=logging.ERROR) self._failure_counts[category] += 1 def _fetch_banking_details(self) -> None: """Fetch banking details from Payoneer API in parallel. Uses a thread pool (FETCH_WORKERS) with a pyrate-limiter token bucket (FETCH_RATE_LIMIT req/s) to stay within Payoneer's 2400 rpm cap. """ self._results = [] self._lock = threading.Lock() self._limiter = Limiter(Rate(FETCH_RATE_LIMIT, Duration.SECOND)) # `_load_input` seeds this; when the fetch step is invoked # directly (e.g. from a unit test bypassing _load_input) there # are no prior failures to carry over. if not hasattr(self, '_failure_counts'): self._failure_counts = Counter() total = len(self._input_rows) with concurrent.futures.ThreadPoolExecutor(max_workers=FETCH_WORKERS) as pool: future_to_index = { pool.submit(self._fetch_row, i, row, total): i for i, row in enumerate(self._input_rows) } for future in concurrent.futures.as_completed(future_to_index): try: future.result() except Exception as e: # `_fetch_row` already catches every expected error # per-row; this is the last-resort guard so a # thread-level fault (e.g. MemoryError, an unhandled # exception path we haven't anticipated) does not # abort `as_completed` and discard the remaining # successful futures. self._fail( future_to_index[future], f'unexpected worker failure: {e}', 'worker_error', ) logger.info(f'Fetched {len(self._results)} of {total} payee details') if not self._results: breakdown = ', '.join( f'{count} {category}' for category, count in sorted(self._failure_counts.items()) if count ) detail = f' ({breakdown})' if breakdown else '' raise ProcessingError(f'No banking details retrieved from Payoneer{detail}') def _fail( self, row_index: int, msg: str, category: str, level: int = logging.ERROR ) -> None: """Log a row-level failure and count it by category.""" with self._lock: self._add_log(f'Row {row_index + 1}: {msg}', log_level=level) self._failure_counts[category] += 1 def _fetch_row( self, row_index: int, row: PullBankingDetailsInputRow, total: int ) -> None: self._limiter.try_acquire('payoneer') try: details = get_payee_details(row.program_id, row.payee_id) except PayoneerApiException as e: self._fail( row_index, f'Payoneer API error for program={row.program_id} ' f'payee={row.payee_id}: {e}', 'payoneer_api_error', ) return if details is None: self._fail( row_index, f'payee not found in Payoneer ' f'(program={row.program_id} payee={row.payee_id})', 'not_found', level=logging.WARNING, ) return try: mapped, mapper_warnings = map_payoneer_response( row.vendor_id, str(row.payee_id), details, date_of_birth_override=row.date_of_birth, ) except Exception as e: # Defensive: the mapper is best-effort against schema drift; # an unexpected KeyError / AttributeError here must not # escape the worker and abort the rest of the batch via # future.result(). self._fail( row_index, f'failed to map Payoneer response for ' f'program={row.program_id} payee={row.payee_id}: {e}', 'mapper_error', ) return with self._lock: self._results.append(mapped) for warning in mapper_warnings: self._add_log( f'Row {row_index + 1}: {warning}', log_level=logging.WARNING ) new_count = len(self._results) if new_count % 500 == 0: logger.info(f'Fetch progress: {new_count}/{total} successful') def _upload_reports(self) -> None: """Generate and upload report CSV files, chunked to REPORT_CHUNK_SIZE.""" ts = datetime.now().strftime('%Y%m%d_%H%M%S') original_name = pathlib.PurePath(self._file_path).name multi_part = len(self._results) > REPORT_CHUNK_SIZE for idx, chunk in enumerate(chunks(self._results, REPORT_CHUNK_SIZE), 1): suffix = f'_part{idx}' if multi_part else '' key = f'reports/pull_banking_details_report_{ts}{suffix}.csv' buffer = StringIO() writer = csv.DictWriter( buffer, fieldnames=REPORT_COLUMNS, extrasaction='ignore' ) writer.writeheader() for row in chunk: writer.writerow({k: sanitize_csv_cell(v) for k, v in row.items()}) try: s3.upload_file( self._bucket_name, key, buffer, metadata={'original-file-name': original_name}, tags={'report-type': 'pull_banking_details'}, ) except Exception as e: logger.error( f'Unable to put a file to the bucket: ' f'{self._bucket_name}/{key}, {e}' ) raise logger.info(f'Uploaded report: {key} ({len(chunk)} records)')