changes
This commit is contained in:
@@ -22,6 +22,7 @@
|
||||
'views/payment_poynt_templates.xml',
|
||||
'views/poynt_terminal_views.xml',
|
||||
'views/account_move_views.xml',
|
||||
'views/account_payment_views.xml',
|
||||
'views/sale_order_views.xml',
|
||||
'views/res_config_settings_views.xml',
|
||||
'views/poynt_settlement_views.xml',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import account_move
|
||||
from . import account_payment
|
||||
from . import payment_provider
|
||||
from . import payment_token
|
||||
from . import payment_transaction
|
||||
|
||||
42
fusion_poynt/models/account_payment.py
Normal file
42
fusion_poynt/models/account_payment.py
Normal file
@@ -0,0 +1,42 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
|
||||
|
||||
class AccountPayment(models.Model):
|
||||
_inherit = 'account.payment'
|
||||
|
||||
poynt_settlement_line_ids = fields.One2many(
|
||||
'poynt.settlement.line',
|
||||
'existing_payment_id',
|
||||
string="Settlement Lines",
|
||||
)
|
||||
poynt_settlement_count = fields.Integer(
|
||||
string="Settlements",
|
||||
compute='_compute_poynt_settlement_count',
|
||||
)
|
||||
|
||||
@api.depends('poynt_settlement_line_ids')
|
||||
def _compute_poynt_settlement_count(self):
|
||||
for payment in self:
|
||||
payment.poynt_settlement_count = len(payment.poynt_settlement_line_ids)
|
||||
|
||||
def action_view_poynt_settlement(self):
|
||||
"""Open the settlement batch linked to this payment."""
|
||||
self.ensure_one()
|
||||
batch_ids = self.poynt_settlement_line_ids.mapped('batch_id').ids
|
||||
if len(batch_ids) == 1:
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _("Settlement Batch"),
|
||||
'res_model': 'poynt.settlement.batch',
|
||||
'view_mode': 'form',
|
||||
'res_id': batch_ids[0],
|
||||
}
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _("Settlement Batches"),
|
||||
'res_model': 'poynt.settlement.batch',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('id', 'in', batch_ids)],
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -52,10 +52,10 @@ class PoyntSettlementBatch(models.Model):
|
||||
)
|
||||
state = fields.Selection([
|
||||
('draft', "Draft"),
|
||||
('matched', "Matched"),
|
||||
('matched', "Matched to Deposit"),
|
||||
('reconciled', "Reconciled"),
|
||||
('error', "Error"),
|
||||
], string="Status", required=True, default='draft', tracking=True)
|
||||
], string="Status", required=True, default='draft')
|
||||
|
||||
currency_id = fields.Many2one(
|
||||
'res.currency',
|
||||
@@ -93,10 +93,18 @@ class PoyntSettlementBatch(models.Model):
|
||||
store=True,
|
||||
)
|
||||
matched_count = fields.Integer(
|
||||
string="Matched to Customers",
|
||||
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 = [
|
||||
@@ -113,7 +121,7 @@ class PoyntSettlementBatch(models.Model):
|
||||
) or '/'
|
||||
return super().create(vals_list)
|
||||
|
||||
@api.depends('line_ids.amount', 'line_ids.action', 'line_ids.partner_id', 'elavon_deposit')
|
||||
@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(
|
||||
@@ -127,7 +135,38 @@ class PoyntSettlementBatch(models.Model):
|
||||
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))
|
||||
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 === #
|
||||
|
||||
@@ -165,7 +204,7 @@ class PoyntSettlementBatch(models.Model):
|
||||
|
||||
card = txn.get('fundingSource', {}).get('card', {})
|
||||
|
||||
# Convert ISO 8601 timestamp (2025-03-05T19:19:10Z) to Odoo format
|
||||
# Convert ISO 8601 timestamp to Odoo format
|
||||
created_at = txn.get('createdAt', '')
|
||||
if created_at:
|
||||
created_at = created_at.replace('T', ' ').replace('Z', '')
|
||||
@@ -198,90 +237,117 @@ class PoyntSettlementBatch(models.Model):
|
||||
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')
|
||||
# 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 candidates:
|
||||
self.notes = f"No unreconciled Elavon deposit found near {self.settlement_date}"
|
||||
return False
|
||||
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,
|
||||
},
|
||||
}
|
||||
|
||||
# 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)
|
||||
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 diff <= net_amount * 0.05:
|
||||
if diff < best_diff and (net_amount == 0 or diff <= abs(net_amount) * 0.05):
|
||||
best_diff = diff
|
||||
best_match = line
|
||||
best_match = (row_id, row_date, float(row_amount))
|
||||
|
||||
if best_match:
|
||||
self.write({
|
||||
'bank_statement_line_id': best_match.id,
|
||||
'elavon_deposit': best_match.amount,
|
||||
'settlement_date': best_match.date,
|
||||
'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.id, best_match.amount, self.fee_amount,
|
||||
self.name, best_match[0], best_match[2], self.fee_amount,
|
||||
)
|
||||
return True
|
||||
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:
|
||||
self.notes = (
|
||||
f"No matching Elavon deposit found. "
|
||||
f"Poynt net: ${net_amount:.2f}, "
|
||||
f"closest candidate: ${candidates[0].amount:.2f}"
|
||||
)
|
||||
return False
|
||||
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_customers(self):
|
||||
"""Attempt to match settlement lines to Odoo customers and invoices."""
|
||||
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.partner_id and l.action == 'SALE'):
|
||||
if line._match_to_customer():
|
||||
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 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
|
||||
"Poynt batch %s: matched %d/%d lines to existing payments",
|
||||
self.name, matched, len(self.line_ids.filtered(lambda l: l.action == 'SALE')),
|
||||
)
|
||||
|
||||
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
|
||||
# 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 all_paid:
|
||||
if not unmatched and self.state == 'matched':
|
||||
self.state = 'reconciled'
|
||||
|
||||
return True
|
||||
@@ -318,10 +384,10 @@ class PoyntSettlementBatch(models.Model):
|
||||
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
|
||||
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:
|
||||
@@ -334,7 +400,6 @@ class PoyntSettlementBatch(models.Model):
|
||||
})
|
||||
|
||||
try:
|
||||
# Fetch all transactions for the date range
|
||||
transactions = provider._poynt_fetch_settlement_transactions(
|
||||
txn_date_from, yesterday,
|
||||
)
|
||||
@@ -360,7 +425,6 @@ class PoyntSettlementBatch(models.Model):
|
||||
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', '')
|
||||
@@ -384,8 +448,8 @@ class PoyntSettlementBatch(models.Model):
|
||||
# Try to match to bank deposit
|
||||
batch.action_match_deposit()
|
||||
|
||||
# Try to match customers
|
||||
batch.action_match_customers()
|
||||
# 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",
|
||||
@@ -427,23 +491,28 @@ class PoyntSettlementLine(models.Model):
|
||||
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',
|
||||
)
|
||||
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"),
|
||||
@@ -451,13 +520,13 @@ class PoyntSettlementLine(models.Model):
|
||||
], string="Action", required=True)
|
||||
state = fields.Selection([
|
||||
('fetched', "Fetched"),
|
||||
('matched', "Matched"),
|
||||
('paid', "Payment Created"),
|
||||
('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 a customer (e.g., 'odoo_txn', 'card_token', 'invoice_amount', 'name').",
|
||||
help="How this line was matched to an existing payment.",
|
||||
)
|
||||
notes = fields.Text(string="Notes")
|
||||
|
||||
@@ -466,167 +535,131 @@ class PoyntSettlementLine(models.Model):
|
||||
'This Poynt transaction has already been recorded.'),
|
||||
]
|
||||
|
||||
# === CUSTOMER MATCHING === #
|
||||
# === MATCH TO EXISTING PAYMENTS === #
|
||||
|
||||
def _match_to_customer(self):
|
||||
"""Attempt to match this settlement line to an Odoo customer/invoice.
|
||||
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. 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
|
||||
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.partner_id:
|
||||
if self.existing_payment_id:
|
||||
return True
|
||||
|
||||
# Strategy 1: Direct Odoo payment transaction
|
||||
# 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.partner_id:
|
||||
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,
|
||||
'invoice_id': odoo_txn.invoice_ids[:1].id if odoo_txn.invoice_ids else False,
|
||||
'match_method': 'odoo_txn',
|
||||
'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: Card token match
|
||||
if self.card_last4:
|
||||
token = self.env['payment.token'].search([
|
||||
('payment_details', 'ilike', self.card_last4),
|
||||
('provider_id.code', '=', 'poynt'),
|
||||
# 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 token and token.partner_id:
|
||||
if memo_match:
|
||||
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',
|
||||
'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
|
||||
|
||||
# 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
|
||||
# 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 _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
|
||||
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
|
||||
|
||||
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'})
|
||||
def _find_invoice_for_payment(self, payment):
|
||||
"""Find the invoice that a payment was applied to."""
|
||||
if not payment.partner_id:
|
||||
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)
|
||||
# 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
|
||||
|
||||
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
|
||||
return False
|
||||
|
||||
19
fusion_poynt/views/account_payment_views.xml
Normal file
19
fusion_poynt/views/account_payment_views.xml
Normal file
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="view_account_payment_form_inherit_poynt_settlement" model="ir.ui.view">
|
||||
<field name="name">account.payment.form.inherit.poynt.settlement</field>
|
||||
<field name="model">account.payment</field>
|
||||
<field name="inherit_id" ref="account.view_account_payment_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//div[@name='button_box']" position="inside">
|
||||
<button name="action_view_poynt_settlement" type="object"
|
||||
class="oe_stat_button" icon="fa-credit-card"
|
||||
invisible="poynt_settlement_count == 0">
|
||||
<field name="poynt_settlement_count" string="Settlement" widget="statinfo"/>
|
||||
</button>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -40,19 +40,27 @@
|
||||
<button name="action_match_deposit" type="object"
|
||||
string="Match Bank Deposit" class="btn-primary"
|
||||
invisible="state != 'draft' or not line_ids"/>
|
||||
<button name="action_match_customers" type="object"
|
||||
string="Match Customers" class="btn-secondary"
|
||||
<button name="action_match_existing_payments" type="object"
|
||||
string="Match Existing Payments" class="btn-secondary"
|
||||
invisible="state not in ('draft', 'matched')"/>
|
||||
<button name="action_create_payments" type="object"
|
||||
string="Create Payments" class="btn-primary"
|
||||
invisible="state not in ('matched',)"
|
||||
confirm="This will create customer payment records for all matched lines. Continue?"/>
|
||||
<button name="action_reset_to_draft" type="object"
|
||||
string="Reset to Draft" class="btn-secondary"
|
||||
invisible="state in ('draft', 'reconciled')"/>
|
||||
<field name="state" widget="statusbar" statusbar_visible="draft,matched,reconciled"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_button_box" name="button_box">
|
||||
<button name="action_view_payments" type="object"
|
||||
class="oe_stat_button" icon="fa-money"
|
||||
invisible="payment_count == 0">
|
||||
<field name="payment_count" string="Payments" widget="statinfo"/>
|
||||
</button>
|
||||
<button name="action_view_invoices" type="object"
|
||||
class="oe_stat_button" icon="fa-pencil-square-o"
|
||||
invisible="invoice_count == 0">
|
||||
<field name="invoice_count" string="Invoices" widget="statinfo"/>
|
||||
</button>
|
||||
</div>
|
||||
<div class="oe_title">
|
||||
<h1>
|
||||
<field name="name" readonly="1"/>
|
||||
@@ -83,8 +91,8 @@
|
||||
<page string="Transaction Lines" name="lines">
|
||||
<field name="line_ids">
|
||||
<list editable="bottom"
|
||||
decoration-success="state == 'paid'"
|
||||
decoration-warning="state == 'matched'"
|
||||
decoration-success="state == 'matched'"
|
||||
decoration-muted="state == 'no_match'"
|
||||
decoration-danger="state == 'error'">
|
||||
<field name="transaction_date"/>
|
||||
<field name="action"/>
|
||||
@@ -93,12 +101,12 @@
|
||||
<field name="card_last4"/>
|
||||
<field name="card_holder_name"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="invoice_id"/>
|
||||
<field name="payment_id"/>
|
||||
<field name="existing_payment_id" string="Staff Payment"/>
|
||||
<field name="existing_invoice_id" string="Invoice"/>
|
||||
<field name="match_method"/>
|
||||
<field name="state" widget="badge"
|
||||
decoration-success="state == 'paid'"
|
||||
decoration-warning="state == 'matched'"
|
||||
decoration-success="state == 'matched'"
|
||||
decoration-muted="state == 'no_match'"
|
||||
decoration-danger="state == 'error'"/>
|
||||
<field name="currency_id" column_invisible="1"/>
|
||||
</list>
|
||||
@@ -158,7 +166,7 @@
|
||||
<field name="name">poynt.settlement.line.list</field>
|
||||
<field name="model">poynt.settlement.line</field>
|
||||
<field name="arch" type="xml">
|
||||
<list decoration-success="state == 'paid'" decoration-warning="state == 'matched'" decoration-danger="state == 'error'">
|
||||
<list decoration-success="state == 'matched'" decoration-muted="state == 'no_match'" decoration-danger="state == 'error'">
|
||||
<field name="batch_id"/>
|
||||
<field name="transaction_date"/>
|
||||
<field name="action"/>
|
||||
@@ -167,12 +175,12 @@
|
||||
<field name="card_last4"/>
|
||||
<field name="card_holder_name"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="invoice_id"/>
|
||||
<field name="payment_id"/>
|
||||
<field name="existing_payment_id" string="Staff Payment"/>
|
||||
<field name="existing_invoice_id" string="Invoice"/>
|
||||
<field name="match_method"/>
|
||||
<field name="state" widget="badge"
|
||||
decoration-success="state == 'paid'"
|
||||
decoration-warning="state == 'matched'"
|
||||
decoration-success="state == 'matched'"
|
||||
decoration-muted="state == 'no_match'"
|
||||
decoration-danger="state == 'error'"/>
|
||||
</list>
|
||||
</field>
|
||||
@@ -188,9 +196,9 @@
|
||||
<field name="card_last4"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="poynt_transaction_id"/>
|
||||
<filter name="filter_unmatched" string="Unmatched" domain="[('partner_id', '=', False), ('action', '=', 'SALE')]"/>
|
||||
<filter name="filter_matched" string="Matched" domain="[('state', '=', 'matched')]"/>
|
||||
<filter name="filter_paid" string="Paid" domain="[('state', '=', 'paid')]"/>
|
||||
<filter name="filter_unmatched" string="No Payment Found" domain="[('state', '=', 'no_match')]"/>
|
||||
<filter name="filter_matched" string="Matched to Payment" domain="[('state', '=', 'matched')]"/>
|
||||
<filter name="filter_fetched" string="Pending Match" domain="[('state', '=', 'fetched')]"/>
|
||||
<filter name="filter_errors" string="Errors" domain="[('state', '=', 'error')]"/>
|
||||
<separator/>
|
||||
<filter name="group_batch" string="Batch" context="{'group_by': 'batch_id'}" domain="[]"/>
|
||||
|
||||
Reference in New Issue
Block a user