# Part of Odoo. See LICENSE file for full copyright and licensing details. import logging from datetime import timedelta from odoo import _, api, fields, models from odoo.exceptions import UserError, ValidationError _logger = logging.getLogger(__name__) class PoyntSettlementBatch(models.Model): _name = 'poynt.settlement.batch' _description = 'Poynt Settlement Batch' _order = 'settlement_date desc, id desc' _rec_name = 'name' name = fields.Char( string="Batch Reference", required=True, readonly=True, default='/', copy=False, ) settlement_date = fields.Date( string="Settlement Date", required=True, help="The date Elavon deposits into the bank (T+1 business day from transactions).", ) transaction_date = fields.Date( string="Transaction Date", required=True, help="The date card transactions were processed at the terminal.", ) provider_id = fields.Many2one( 'payment.provider', string="Payment Provider", required=True, domain="[('code', '=', 'poynt')]", ondelete='restrict', ) bank_statement_line_id = fields.Many2one( 'account.bank.statement.line', string="Bank Statement Line", help="The Elavon deposit line on the bank statement.", ondelete='set null', ) line_ids = fields.One2many( 'poynt.settlement.line', 'batch_id', string="Settlement Lines", ) state = fields.Selection([ ('draft', "Draft"), ('matched', "Matched"), ('reconciled', "Reconciled"), ('error', "Error"), ], string="Status", required=True, default='draft', tracking=True) currency_id = fields.Many2one( 'res.currency', string="Currency", required=True, default=lambda self: self.env.company.currency_id, ) poynt_total = fields.Monetary( string="Poynt Total", currency_field='currency_id', compute='_compute_totals', store=True, help="Sum of all Poynt transactions (sales - refunds) for this batch.", ) elavon_deposit = fields.Monetary( string="Elavon Deposit", currency_field='currency_id', help="The amount Elavon deposited into the bank account.", ) fee_amount = fields.Monetary( string="Processing Fees", currency_field='currency_id', compute='_compute_totals', store=True, help="Difference between Poynt total and Elavon deposit (Elavon processing fees).", ) sale_count = fields.Integer( string="Sales", compute='_compute_totals', store=True, ) refund_count = fields.Integer( string="Refunds", compute='_compute_totals', store=True, ) matched_count = fields.Integer( string="Matched to Customers", compute='_compute_totals', store=True, ) notes = fields.Text(string="Notes") _sql_constraints = [ ('unique_provider_txn_date', 'unique(provider_id, transaction_date)', 'A settlement batch already exists for this provider and transaction date.'), ] @api.model_create_multi def create(self, vals_list): for vals in vals_list: if vals.get('name', '/') == '/': vals['name'] = self.env['ir.sequence'].next_by_code( 'poynt.settlement.batch' ) or '/' return super().create(vals_list) @api.depends('line_ids.amount', 'line_ids.action', 'line_ids.partner_id', 'elavon_deposit') def _compute_totals(self): for batch in self: sales = sum( line.amount for line in batch.line_ids if line.action == 'SALE' ) refunds = sum( line.amount for line in batch.line_ids if line.action == 'REFUND' ) net = sales - refunds batch.poynt_total = net batch.fee_amount = net - batch.elavon_deposit if batch.elavon_deposit else 0.0 batch.sale_count = len(batch.line_ids.filtered(lambda l: l.action == 'SALE')) batch.refund_count = len(batch.line_ids.filtered(lambda l: l.action == 'REFUND')) batch.matched_count = len(batch.line_ids.filtered(lambda l: l.partner_id)) # === BUSINESS METHODS === # def action_fetch_transactions(self): """Fetch Poynt transactions for this batch's transaction date.""" self.ensure_one() if self.line_ids: raise UserError(_("This batch already has transaction lines. Clear them first.")) provider = self.provider_id transactions = provider._poynt_fetch_settlement_transactions( self.transaction_date, self.transaction_date, ) lines_vals = [] existing_txn_ids = set() for txn in transactions: txn_id = txn.get('id', '') if txn_id in existing_txn_ids: continue existing_txn_ids.add(txn_id) action = txn.get('action', '') if action not in ('SALE', 'REFUND'): continue status = txn.get('processorResponse', {}).get('status', '') settlement = txn.get('settlementStatus', '') if status != 'Approved' and settlement != 'SETTLED': continue amounts = txn.get('amounts', {}) amount_cents = amounts.get('transactionAmount', 0) amount = amount_cents / 100.0 card = txn.get('fundingSource', {}).get('card', {}) # Convert ISO 8601 timestamp (2025-03-05T19:19:10Z) to Odoo format created_at = txn.get('createdAt', '') if created_at: created_at = created_at.replace('T', ' ').replace('Z', '') lines_vals.append({ 'batch_id': self.id, 'poynt_transaction_id': txn_id, 'poynt_order_id': txn.get('context', {}).get('orderId', ''), 'transaction_date': created_at, 'amount': amount, 'card_brand': card.get('type', ''), 'card_last4': card.get('numberLast4', ''), 'card_holder_name': card.get('cardHolderFullName', ''), 'action': action, 'state': 'fetched', }) if lines_vals: self.env['poynt.settlement.line'].create(lines_vals) _logger.info( "Poynt settlement batch %s: fetched %d transactions for %s", self.name, len(lines_vals), self.transaction_date, ) return True def action_match_deposit(self): """Match this batch to an Elavon bank statement line.""" self.ensure_one() if not self.line_ids: raise UserError(_("No transaction lines to match. Fetch transactions first.")) # Look for Elavon deposit on the settlement date (or ±1 day for timing) StmtLine = self.env['account.bank.statement.line'] domain = [ ('journal_id.name', 'ilike', 'Scotia'), ('date', '>=', self.settlement_date - timedelta(days=1)), ('date', '<=', self.settlement_date + timedelta(days=1)), ('amount', '>', 0), ('payment_ref', 'ilike', 'ELAVON'), ('is_reconciled', '=', False), ] candidates = StmtLine.search(domain, order='date asc') if not candidates: self.notes = f"No unreconciled Elavon deposit found near {self.settlement_date}" return False # Try to find the closest match by amount net_amount = self.poynt_total best_match = None best_diff = float('inf') for line in candidates: diff = abs(line.amount - net_amount) # Allow up to 5% tolerance for processing fees if diff < best_diff and diff <= net_amount * 0.05: best_diff = diff best_match = line if best_match: self.write({ 'bank_statement_line_id': best_match.id, 'elavon_deposit': best_match.amount, 'settlement_date': best_match.date, 'state': 'matched', }) _logger.info( "Poynt batch %s matched to bank line %s (deposit $%.2f, fees $%.2f)", self.name, best_match.id, best_match.amount, self.fee_amount, ) return True else: self.notes = ( f"No matching Elavon deposit found. " f"Poynt net: ${net_amount:.2f}, " f"closest candidate: ${candidates[0].amount:.2f}" ) return False def action_match_customers(self): """Attempt to match settlement lines to Odoo customers and invoices.""" self.ensure_one() matched = 0 for line in self.line_ids.filtered(lambda l: not l.partner_id and l.action == 'SALE'): if line._match_to_customer(): matched += 1 _logger.info( "Poynt batch %s: matched %d/%d lines to customers", self.name, matched, len(self.line_ids), ) return True def action_create_payments(self): """Create account.payment records for matched settlement lines.""" self.ensure_one() if self.state == 'reconciled': raise UserError(_("This batch is already reconciled.")) payable_lines = self.line_ids.filtered( lambda l: l.partner_id and l.action == 'SALE' and l.state in ('fetched', 'matched') and not l.payment_id ) if not payable_lines: raise UserError(_("No matched lines available for payment creation.")) for line in payable_lines: line._create_customer_payment() # Check if all lines are processed all_paid = all( l.state in ('paid', 'error') or l.action == 'REFUND' for l in self.line_ids ) if all_paid: self.state = 'reconciled' return True def action_reset_to_draft(self): """Reset batch to draft state.""" self.ensure_one() self.write({'state': 'draft'}) return True # === CRON === # @api.model def _cron_daily_settlement_sync(self): """Daily cron: fetch yesterday's transactions, match to today's deposit.""" provider = self.env['payment.provider'].search([ ('code', '=', 'poynt'), ('state', '=', 'enabled'), ], limit=1) if not provider: _logger.info("Poynt settlement cron: no active Poynt provider found.") return yesterday = fields.Date.today() - timedelta(days=1) today = fields.Date.today() # Check if batch already exists existing = self.search([ ('provider_id', '=', provider.id), ('transaction_date', '=', yesterday), ]) if existing: _logger.info("Poynt settlement cron: batch for %s already exists.", yesterday) return # Handle weekend: if today is Monday, fetch Fri+Sat+Sun weekday = yesterday.weekday() # 0=Monday, 6=Sunday if weekday == 6: # Sunday → fetch Fri-Sun, deposit Monday txn_date_from = yesterday - timedelta(days=2) # Friday elif weekday == 5: # Saturday → skip, will be batched with Sunday _logger.info("Poynt settlement cron: Saturday — will batch with Sunday/Monday.") return else: txn_date_from = yesterday batch = self.create({ 'provider_id': provider.id, 'transaction_date': txn_date_from, 'settlement_date': today, }) try: # Fetch all transactions for the date range transactions = provider._poynt_fetch_settlement_transactions( txn_date_from, yesterday, ) lines_vals = [] seen = set() for txn in transactions: txn_id = txn.get('id', '') if txn_id in seen: continue seen.add(txn_id) action = txn.get('action', '') if action not in ('SALE', 'REFUND'): continue status = txn.get('processorResponse', {}).get('status', '') settlement = txn.get('settlementStatus', '') if status != 'Approved' and settlement != 'SETTLED': continue amounts = txn.get('amounts', {}) amount = amounts.get('transactionAmount', 0) / 100.0 card = txn.get('fundingSource', {}).get('card', {}) # Convert ISO 8601 timestamp to Odoo format created_at = txn.get('createdAt', '') if created_at: created_at = created_at.replace('T', ' ').replace('Z', '') lines_vals.append({ 'batch_id': batch.id, 'poynt_transaction_id': txn_id, 'poynt_order_id': txn.get('context', {}).get('orderId', ''), 'transaction_date': created_at, 'amount': amount, 'card_brand': card.get('type', ''), 'card_last4': card.get('numberLast4', ''), 'card_holder_name': card.get('cardHolderFullName', ''), 'action': action, 'state': 'fetched', }) if lines_vals: self.env['poynt.settlement.line'].create(lines_vals) # Try to match to bank deposit batch.action_match_deposit() # Try to match customers batch.action_match_customers() _logger.info( "Poynt settlement cron: created batch %s with %d lines for %s→%s", batch.name, len(lines_vals), txn_date_from, yesterday, ) except Exception as e: batch.write({'state': 'error', 'notes': str(e)}) _logger.error("Poynt settlement cron failed: %s", e) class PoyntSettlementLine(models.Model): _name = 'poynt.settlement.line' _description = 'Poynt Settlement Line' _order = 'transaction_date desc, id desc' batch_id = fields.Many2one( 'poynt.settlement.batch', string="Settlement Batch", required=True, ondelete='cascade', index=True, ) poynt_transaction_id = fields.Char( string="Poynt Transaction ID", required=True, index=True, ) poynt_order_id = fields.Char(string="Poynt Order ID") transaction_date = fields.Datetime(string="Transaction Date") amount = fields.Monetary( string="Amount", currency_field='currency_id', required=True, ) currency_id = fields.Many2one( related='batch_id.currency_id', store=True, ) card_brand = fields.Char(string="Card Brand") card_last4 = fields.Char(string="Card Last 4", size=4) card_holder_name = fields.Char(string="Cardholder Name") partner_id = fields.Many2one( 'res.partner', string="Customer", ondelete='set null', ) invoice_id = fields.Many2one( 'account.move', string="Matched Invoice", domain="[('move_type', '=', 'out_invoice')]", ondelete='set null', ) payment_id = fields.Many2one( 'account.payment', string="Payment", readonly=True, ondelete='set null', ) action = fields.Selection([ ('SALE', "Sale"), ('REFUND', "Refund"), ('VOID', "Void"), ], string="Action", required=True) state = fields.Selection([ ('fetched', "Fetched"), ('matched', "Matched"), ('paid', "Payment Created"), ('error', "Error"), ], string="Status", required=True, default='fetched') match_method = fields.Char( string="Match Method", help="How this line was matched to a customer (e.g., 'odoo_txn', 'card_token', 'invoice_amount', 'name').", ) notes = fields.Text(string="Notes") _sql_constraints = [ ('unique_poynt_txn', 'unique(poynt_transaction_id)', 'This Poynt transaction has already been recorded.'), ] # === CUSTOMER MATCHING === # def _match_to_customer(self): """Attempt to match this settlement line to an Odoo customer/invoice. Matching strategy (in priority order): 1. Check poynt_transaction_id in payment.transaction (direct Odoo payment) 2. Match by card_last4 against payment.token records 3. Match by amount against open invoices within ±2 days 4. Match by card_holder_name fuzzy search against res.partner :return: True if matched, False otherwise. """ self.ensure_one() if self.partner_id: return True # Strategy 1: Direct Odoo payment transaction PaymentTxn = self.env['payment.transaction'] odoo_txn = PaymentTxn.search([ ('poynt_transaction_id', '=', self.poynt_transaction_id), ], limit=1) if odoo_txn and odoo_txn.partner_id: self.write({ 'partner_id': odoo_txn.partner_id.id, 'invoice_id': odoo_txn.invoice_ids[:1].id if odoo_txn.invoice_ids else False, 'match_method': 'odoo_txn', 'state': 'matched', }) return True # Strategy 2: Card token match if self.card_last4: token = self.env['payment.token'].search([ ('payment_details', 'ilike', self.card_last4), ('provider_id.code', '=', 'poynt'), ], limit=1) if token and token.partner_id: self.write({ 'partner_id': token.partner_id.id, 'match_method': 'card_token', 'state': 'matched', }) # Try to find matching invoice self._match_invoice() return True # Strategy 3: Amount match against open invoices if self.amount and self.transaction_date: date = self.transaction_date.date() if self.transaction_date else fields.Date.today() invoices = self.env['account.move'].search([ ('move_type', '=', 'out_invoice'), ('state', '=', 'posted'), ('payment_state', 'in', ('not_paid', 'partial')), ('amount_residual', '=', self.amount), ('invoice_date', '>=', date - timedelta(days=7)), ('invoice_date', '<=', date + timedelta(days=2)), ], limit=1) if invoices: self.write({ 'partner_id': invoices.partner_id.id, 'invoice_id': invoices.id, 'match_method': 'invoice_amount', 'state': 'matched', }) return True # Strategy 4: Cardholder name fuzzy match if self.card_holder_name: name = self.card_holder_name.strip() if len(name) >= 3: partners = self.env['res.partner'].search([ '|', ('name', 'ilike', name), ('name', 'ilike', name.split()[-1] if ' ' in name else name), ], limit=5) if len(partners) == 1: self.write({ 'partner_id': partners.id, 'match_method': 'name', 'state': 'matched', }) self._match_invoice() return True return False def _match_invoice(self): """Try to find a matching open invoice for this line's partner and amount.""" self.ensure_one() if self.invoice_id or not self.partner_id: return invoices = self.env['account.move'].search([ ('partner_id', '=', self.partner_id.id), ('move_type', '=', 'out_invoice'), ('state', '=', 'posted'), ('payment_state', 'in', ('not_paid', 'partial')), ('amount_residual', '=', self.amount), ], limit=1, order='invoice_date desc') if invoices: self.invoice_id = invoices.id # === PAYMENT CREATION === # def _create_customer_payment(self): """Create an account.payment for this matched settlement line.""" self.ensure_one() if not self.partner_id: self.write({'state': 'error', 'notes': 'No customer matched'}) return False if self.payment_id: return True try: # Use the provider's journal (Poynt payment journal) journal = self.batch_id.provider_id.journal_id if not journal: # Fall back to first bank journal journal = self.env['account.journal'].search([ ('type', '=', 'bank'), ('company_id', '=', self.env.company.id), ], limit=1) payment_vals = { 'partner_id': self.partner_id.id, 'amount': self.amount, 'currency_id': self.currency_id.id, 'journal_id': journal.id, 'payment_type': 'inbound', 'partner_type': 'customer', 'payment_method_line_id': journal.inbound_payment_method_line_ids[:1].id, 'memo': f"Poynt {self.card_brand or 'Card'} ****{self.card_last4 or '????'} - {self.batch_id.name}", } payment = self.env['account.payment'].create(payment_vals) payment.action_post() self.write({ 'payment_id': payment.id, 'state': 'paid', }) # Reconcile with invoice if matched if self.invoice_id and self.invoice_id.payment_state in ('not_paid', 'partial'): try: (payment.move_id.line_ids + self.invoice_id.line_ids).filtered( lambda l: l.account_id.account_type == 'asset_receivable' and not l.reconciled ).reconcile() except Exception as e: _logger.warning( "Could not auto-reconcile payment %s with invoice %s: %s", payment.name, self.invoice_id.name, e, ) return True except Exception as e: self.write({'state': 'error', 'notes': str(e)}) _logger.error( "Failed to create payment for settlement line %s: %s", self.poynt_transaction_id, e, ) return False