Files
Odoo-Modules/fusion_poynt/models/poynt_settlement.py
gsinghpal 3cc93b8783 changes
2026-04-04 15:37:16 -04:00

666 lines
24 KiB
Python

# 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
_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 to Deposit"),
('reconciled', "Reconciled"),
('error', "Error"),
], string="Status", required=True, default='draft')
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 Existing Payments",
compute='_compute_totals',
store=True,
)
payment_count = fields.Integer(
string="Payments",
compute='_compute_smart_buttons',
)
invoice_count = fields.Integer(
string="Invoices",
compute='_compute_smart_buttons',
)
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.existing_payment_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.existing_payment_id))
def _compute_smart_buttons(self):
for batch in self:
payments = batch.line_ids.mapped('existing_payment_id')
invoices = batch.line_ids.mapped('existing_invoice_id')
batch.payment_count = len(payments)
batch.invoice_count = len(invoices)
def action_view_payments(self):
"""Open linked payments in a list view."""
self.ensure_one()
payment_ids = self.line_ids.mapped('existing_payment_id').ids
return {
'type': 'ir.actions.act_window',
'name': _("Payments - %s", self.name),
'res_model': 'account.payment',
'view_mode': 'list,form',
'domain': [('id', 'in', payment_ids)],
}
def action_view_invoices(self):
"""Open linked invoices in a list view."""
self.ensure_one()
invoice_ids = self.line_ids.mapped('existing_invoice_id').ids
return {
'type': 'ir.actions.act_window',
'name': _("Invoices - %s", self.name),
'res_model': 'account.move',
'view_mode': 'list,form',
'domain': [('id', 'in', invoice_ids)],
}
# === 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 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."))
# Search for Elavon deposits near the settlement date
# Use journal_id = 50 (Scotia Current) and SQL for the date
# since date is a related field from account.move
self.env.cr.execute("""
SELECT absl.id, am.date, absl.amount
FROM account_bank_statement_line absl
JOIN account_move am ON am.id = absl.move_id
WHERE absl.journal_id = 50
AND am.date >= %s
AND am.date <= %s
AND absl.amount > 0
AND absl.payment_ref ILIKE '%%ELAVON%%'
ORDER BY am.date
""", [
self.settlement_date - timedelta(days=1),
self.settlement_date + timedelta(days=1),
])
rows = self.env.cr.fetchall()
if not rows:
self.write({
'notes': f"No Elavon deposit found near {self.settlement_date}",
})
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'message': _("No Elavon deposit found near %s", self.settlement_date),
'type': 'warning',
'sticky': False,
},
}
net_amount = self.poynt_total
best_match = None
best_diff = float('inf')
for row_id, row_date, row_amount in rows:
diff = abs(float(row_amount) - net_amount)
# Allow up to 5% tolerance for processing fees
if diff < best_diff and (net_amount == 0 or diff <= abs(net_amount) * 0.05):
best_diff = diff
best_match = (row_id, row_date, float(row_amount))
if best_match:
self.write({
'bank_statement_line_id': best_match[0],
'elavon_deposit': best_match[2],
'settlement_date': best_match[1],
'state': 'matched',
})
_logger.info(
"Poynt batch %s matched to bank line %s (deposit $%.2f, fees $%.2f)",
self.name, best_match[0], best_match[2], self.fee_amount,
)
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'message': _("Matched to Elavon deposit of $%(amount).2f (fees: $%(fees).2f)",
amount=best_match[2], fees=self.fee_amount),
'type': 'success',
'sticky': False,
},
}
else:
closest = min(rows, key=lambda r: abs(float(r[2]) - net_amount))
self.write({
'notes': (
f"No matching deposit. "
f"Poynt net: ${net_amount:.2f}, "
f"closest: ${float(closest[2]):.2f} on {closest[1]}"
),
})
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'message': _(
"No matching deposit found. Poynt net: $%(net).2f, "
"closest deposit: $%(closest).2f",
net=net_amount, closest=float(closest[2]),
),
'type': 'warning',
'sticky': False,
},
}
def action_match_existing_payments(self):
"""Match settlement lines to EXISTING payments already recorded by staff.
This does NOT create new payments. Staff already record payments when
customers pay at the terminal. This method links the Poynt transaction
to that existing payment for audit/reconciliation purposes.
"""
self.ensure_one()
matched = 0
for line in self.line_ids.filtered(lambda l: not l.existing_payment_id and l.action == 'SALE'):
if line._match_to_existing_payment():
matched += 1
_logger.info(
"Poynt batch %s: matched %d/%d lines to existing payments",
self.name, matched, len(self.line_ids.filtered(lambda l: l.action == 'SALE')),
)
# Check if all SALE lines are matched
unmatched = self.line_ids.filtered(
lambda l: l.action == 'SALE' and not l.existing_payment_id and l.state != 'no_match'
)
if not unmatched and self.state == 'matched':
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()
if weekday == 6: # Sunday → fetch Fri-Sun
txn_date_from = yesterday - timedelta(days=2)
elif weekday == 5: # Saturday → skip
_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:
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', {})
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 to existing payments (NOT create new ones)
batch.action_match_existing_payments()
_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")
# Links to EXISTING records (staff-created, not settlement-created)
existing_payment_id = fields.Many2one(
'account.payment',
string="Existing Payment",
readonly=True,
ondelete='set null',
help="The payment already recorded by staff for this transaction.",
)
existing_invoice_id = fields.Many2one(
'account.move',
string="Linked Invoice",
domain="[('move_type', '=', 'out_invoice')]",
ondelete='set null',
help="The invoice this payment was applied to.",
)
partner_id = fields.Many2one(
'res.partner',
string="Customer",
ondelete='set null',
)
action = fields.Selection([
('SALE', "Sale"),
('REFUND', "Refund"),
('VOID', "Void"),
], string="Action", required=True)
state = fields.Selection([
('fetched', "Fetched"),
('matched', "Matched to Payment"),
('no_match', "No Existing Payment"),
('error', "Error"),
], string="Status", required=True, default='fetched')
match_method = fields.Char(
string="Match Method",
help="How this line was matched to an existing payment.",
)
notes = fields.Text(string="Notes")
_sql_constraints = [
('unique_poynt_txn', 'unique(poynt_transaction_id)',
'This Poynt transaction has already been recorded.'),
]
# === MATCH TO EXISTING PAYMENTS === #
def _match_to_existing_payment(self):
"""Match this Poynt transaction to an existing payment already in Odoo.
Staff record payments when customers pay at the terminal. This method
finds that existing payment — it does NOT create a new one.
Matching strategy (in priority order):
1. Poynt transaction ID in payment.transaction (direct Odoo integration)
2. Poynt transaction UUID found in payment memo field
3. Exact amount + cardholder name match on same date (±2 days)
4. Exact amount match on same date (±2 days)
:return: True if matched, False otherwise.
"""
self.ensure_one()
if self.existing_payment_id:
return True
# Strategy 1: Direct Odoo payment transaction (Poynt-integrated payments)
PaymentTxn = self.env['payment.transaction']
odoo_txn = PaymentTxn.search([
('poynt_transaction_id', '=', self.poynt_transaction_id),
], limit=1)
if odoo_txn and odoo_txn.payment_id:
self.write({
'existing_payment_id': odoo_txn.payment_id.id,
'partner_id': odoo_txn.partner_id.id,
'existing_invoice_id': odoo_txn.invoice_ids[:1].id if odoo_txn.invoice_ids else False,
'match_method': 'poynt_txn',
'state': 'matched',
})
return True
# Strategy 2: Poynt transaction UUID in payment memo field
# Staff sometimes record the UUID when entering payments manually
if self.poynt_transaction_id:
memo_match = self.env['account.payment'].search([
('memo', 'ilike', self.poynt_transaction_id),
('payment_type', '=', 'inbound'),
('state', 'in', ('posted', 'in_process')),
], limit=1)
if memo_match:
self.write({
'existing_payment_id': memo_match.id,
'partner_id': memo_match.partner_id.id if memo_match.partner_id else False,
'existing_invoice_id': self._find_invoice_for_payment(memo_match),
'match_method': 'memo_uuid',
'state': 'matched',
})
return True
# Determine the date range for searching
if self.transaction_date:
txn_date = self.transaction_date.date()
else:
txn_date = self.batch_id.transaction_date
date_from = txn_date - timedelta(days=2)
date_to = txn_date + timedelta(days=2)
# Strategy 3: Exact amount + same date range on account.payment
# These are payments staff manually recorded
payments = self.env['account.payment'].search([
('amount', '=', self.amount),
('payment_type', '=', 'inbound'),
('date', '>=', date_from),
('date', '<=', date_to),
('state', 'in', ('posted', 'in_process')),
# Exclude payments already matched to other settlement lines
('id', 'not in', self._get_already_matched_payment_ids()),
], order='date asc')
if payments:
# Prefer one with a partner that matches cardholder name
if self.card_holder_name:
name = self.card_holder_name.strip()
for pay in payments:
if pay.partner_id and name.lower() in (pay.partner_id.name or '').lower():
self.write({
'existing_payment_id': pay.id,
'partner_id': pay.partner_id.id,
'existing_invoice_id': self._find_invoice_for_payment(pay),
'match_method': 'amount_name',
'state': 'matched',
})
return True
# Fall back to first matching payment
pay = payments[0]
self.write({
'existing_payment_id': pay.id,
'partner_id': pay.partner_id.id if pay.partner_id else False,
'existing_invoice_id': self._find_invoice_for_payment(pay),
'match_method': 'amount_date',
'state': 'matched',
})
return True
# No existing payment found — mark for review
self.write({
'state': 'no_match',
'notes': f"No existing payment found for ${self.amount:.2f} near {txn_date}",
})
return False
def _get_already_matched_payment_ids(self):
"""Get payment IDs already matched to other lines in this batch."""
return self.batch_id.line_ids.filtered(
lambda l: l.existing_payment_id and l.id != self.id
).mapped('existing_payment_id').ids
def _find_invoice_for_payment(self, payment):
"""Find the invoice that a payment was applied to."""
if not payment.partner_id:
return False
# Check reconciled invoices via the payment's move lines
receivable_lines = payment.move_id.line_ids.filtered(
lambda l: l.account_id.account_type == 'asset_receivable' and l.reconciled
)
for line in receivable_lines:
for partial in (line.matched_debit_ids | line.matched_credit_ids):
counterpart = partial.debit_move_id if partial.credit_move_id == line else partial.credit_move_id
if counterpart.move_id.move_type == 'out_invoice':
return counterpart.move_id.id
return False