Files
2026-03-14 09:42:12 +00:00

1278 lines
46 KiB
Python

# This file is part of Tryton. The COPYRIGHT file at the top level of
# this repository contains the full copyright notices and license terms.
import logging
import urllib
import uuid
import braintree
from braintree.exceptions import TooManyRequestsError
from braintree.exceptions.braintree_error import BraintreeError
try:
from braintree.exceptions import GatewayTimeoutError
except ImportError:
class GatewayTimeoutError(Exception):
pass
import trytond.config as config
from trytond.cache import Cache
from trytond.i18n import gettext
from trytond.model import (
DeactivableMixin, Index, ModelSQL, ModelView, Workflow, fields)
from trytond.modules.account_payment.exceptions import (
PaymentValidationError, ProcessError)
from trytond.modules.company.model import (
employee_field, reset_employee, set_employee)
from trytond.modules.currency.fields import Monetary
from trytond.pool import Pool, PoolMeta
from trytond.pyson import Bool, Eval
from trytond.report import Report
from trytond.tools import sql_pairing
from trytond.transaction import Transaction
from trytond.url import http_host
from trytond.wizard import Button, StateTransition, StateView, Wizard
from .common import BraintreeCustomerMethodMixin
from .exceptions import BraintreeAccountWarning
logger = logging.getLogger(__name__)
SUCCEEDED_STATUSES = {braintree.Transaction.Status.Settled}
FAILED_STATUSES = {
braintree.Transaction.Status.AuthorizationExpired,
braintree.Transaction.Status.SettlementDeclined,
braintree.Transaction.Status.Failed,
braintree.Transaction.Status.GatewayRejected,
braintree.Transaction.Status.ProcessorDeclined,
braintree.Transaction.Status.Voided,
}
DISPUTE_FINAL_STATUSES = {
braintree.Dispute.Status.Expired,
braintree.Dispute.Status.Won,
braintree.Dispute.Status.Lost,
}
class PaymentJournal(metaclass=PoolMeta):
__name__ = 'account.payment.journal'
braintree_account = fields.Many2One(
'account.payment.braintree.account', "Account", ondelete='RESTRICT',
domain=[
('currency', '=', Eval('currency', -1)),
],
states={
'required': Eval('process_method') == 'braintree',
'invisible': Eval('process_method') != 'braintree',
})
@classmethod
def __setup__(cls):
super().__setup__()
braintree_method = ('braintree', "Braintree")
if braintree_method not in cls.process_method.selection:
cls.process_method.selection.append(braintree_method)
class PaymentGroup(metaclass=PoolMeta):
__name__ = 'account.payment.group'
@classmethod
def __setup__(cls):
super().__setup__()
cls._buttons['succeed']['invisible'] |= (
Eval('process_method') == 'braintree')
class CheckoutMixin:
__slots__ = ()
braintree_checkout_id = fields.Char(
"Braintree Checkout ID", readonly=True, strip=False)
braintree_client_token = fields.Char(
"Braintree Client Token", readonly=True, strip=False)
braintree_nonce = fields.Char(
"Braintree Nonce", readonly=True, strip=False,
states={
'invisible': ~Eval('braintree_nonce'),
})
braintree_device_data = fields.Char(
"Braintree Device Data", readonly=True, strip=False,
states={
'invisible': ~Eval('braintree_device_data'),
})
@classmethod
def copy(cls, records, default=None):
if default is None:
default = {}
else:
default = default.copy()
default.setdefault('braintree_checkout_id')
default.setdefault('braintree_client_token', None)
default.setdefault('braintree_nonce', None)
default.setdefault('braintree_device_data', None)
return super().copy(records, default=default)
@classmethod
@ModelView.button_action('account_payment_braintree.url_checkout')
def braintree_checkout(cls, records):
for record in records:
record.braintree_checkout_id = uuid.uuid4().hex
record.save()
return {
'url': record.braintree_checkout_url,
}
@property
def braintree_checkout_url(self):
pool = Pool()
database = Transaction().database.name
ModelData = pool.get('ir.model.data')
URL = pool.get('ir.action.url')
action = URL(ModelData.get_id(
'account_payment_braintree.url_checkout'))
return action.url % {
'http_host': http_host(),
'database': database,
'model': self.__class__.__name__,
'id': self.braintree_checkout_id,
}
def braintree_set_nonce(self, nonce, device_data=None):
self.braintree_nonce = nonce
self.braintree_device_data = device_data
self.save()
class Payment(CheckoutMixin, BraintreeCustomerMethodMixin, metaclass=PoolMeta):
__name__ = 'account.payment'
braintree_transaction_id = fields.Char(
"Braintree Transaction ID", readonly=True, strip=False,
states={
'invisible': ((Eval('process_method') != 'braintree')
| ~Eval('braintree_transaction_id')),
})
braintree_settle_payment = fields.Boolean(
"Braintree Settle Payment",
states={
'invisible': Eval('process_method') != 'braintree',
'readonly': Eval('state') != 'draft',
})
braintree_payment_settled = fields.Boolean(
"Braintree Payment Settled", readonly=True,
states={
'invisible': Eval('process_method') != 'braintree',
})
braintree_settlement_needed = fields.Function(
fields.Boolean(
"Braintree Settlement Needed",
states={
'invisible': Eval('process_method') != 'braintree',
}),
'get_braintree_settlement_needed')
braintree_refunds = fields.One2Many(
'account.payment.braintree.refund', 'payment', "Refunds",
states={
'invisible': ((Eval('process_method') != 'braintree')
| ~Eval('braintree_transaction_id')),
})
braintree_dispute_reason = fields.Char(
"Braintree Dispute Reason", readonly=True,
states={
'invisible': ~Eval('braintree_dispute_reason'),
})
braintree_dispute_status = fields.Char(
"Braintree Dispute Status", readonly=True,
states={
'invisible': ~Eval('braintree_dispute_status'),
})
braintree_error_message = fields.Text(
"Braintree Error Message", readonly=True,
states={
'invisible': (
~Eval('braintree_error_message')
| (Eval('state') == 'succeeded')),
})
@classmethod
def __setup__(cls):
super().__setup__()
cls.amount.states['readonly'] &= ~Eval('braintree_settlement_needed')
cls.braintree_customer.states['readonly'] = (
~Eval('state').in_(['draft', 'submitted', 'approved'])
| Eval('braintree_nonce'))
cls.braintree_customer_method.states['invisible'] |= (
Eval('braintree_nonce'))
cls.braintree_customer_method.states['readonly'] = (
~Eval('state').in_(['draft', 'submitted', 'approved']))
cls.braintree_customer_method_selection.states['invisible'] |= (
Eval('braintree_nonce'))
cls.braintree_customer_method_selection.states['readonly'] = (
~Eval('state').in_(['draft', 'submitted', 'approved']))
cls._buttons.update({
'braintree_checkout': {
'invisible': (~Eval('state', 'draft').in_(
['submitted', 'approved', 'processing'])
| (Eval('process_method') != 'braintree')
| Eval('braintree_nonce')
| Eval('braintree_customer_method')),
'depends': [
'state',
'process_method',
'braintree_nonce',
'braintree_customer_method',
],
},
'braintree_do_settle_payment': {
'invisible': ((Eval('process_method') != 'braintree')
| ((Eval('state', 'draft') != 'processing')
| ~Eval('braintree_settlement_needed'))),
'depends': ['state', 'braintree_settlement_needed'],
},
'braintree_do_pull': {
'invisible': ((Eval('process_method') != 'braintree')
| ~Eval('braintree_transaction_id')),
'depends': ['braintree_transaction_id'],
},
})
@classmethod
def default_braintree_settle_payment(cls):
return True
@classmethod
def default_braintree_payment_settled(cls):
return False
def get_braintree_settlement_needed(self, name):
return (self.journal.process_method == 'braintree'
and self.braintree_transaction_id
and not self.braintree_settle_payment
and not self.braintree_payment_settled)
@classmethod
def view_attributes(cls):
return super().view_attributes() + [
('//page[@id="braintree"]', 'states', {
'invisible': Eval('process_method') != 'braintree',
}),
]
@classmethod
def validate_fields(cls, payments, field_names):
super().validate_fields(payments, field_names)
cls.check_braintree_journal(payments, field_names)
@classmethod
def check_braintree_journal(cls, payments, field_names=None):
if field_names and not (field_names & {'kind', 'journal'}):
return
for payment in payments:
if (payment.kind != 'receivable'
and payment.journal.process_method == 'braintree'):
raise PaymentValidationError(gettext(
'account_payment_braintree.msg_braintree_receivable',
journal=payment.journal.rec_name,
payment=payment.rec_name))
@classmethod
def copy(cls, payments, default=None):
if default is None:
default = {}
else:
default = default.copy()
default.setdefault('braintree_transaction_id', None)
default.setdefault('braintree_payment_settled', False)
default.setdefault('braintree_error_message')
return super().copy(payments, default=default)
@classmethod
def braintree_checkout(cls, payments):
for payment in payments:
if payment.braintree_client_token:
continue
gateway = payment.braintree_account.gateway()
client_token = gateway.client_token.generate()
payment.braintree_client_token = client_token
return super().braintree_checkout(payments)
@classmethod
@ModelView.button
@Workflow.transition('draft')
def draft(cls, payments):
super().draft(payments)
for payment in payments:
if payment.braintree_nonce:
payment.braintree_nonce = None
payment.braintree_device_data = None
cls.save(payments)
def process_braintree(self):
assert self.process_method == 'braintree'
if not self.braintree_nonce and not self.braintree_customer:
account = self.journal.braintree_account
for customer in self.party.braintree_customers:
if (customer.braintree_account == account
and customer.braintree_customer_id):
self.braintree_customer = customer
break
else:
raise ProcessError(
gettext(
'account_payment_braintree.msg_no_braintree_nonce',
self=self.rec_name))
self.save()
self.__class__.__queue__.braintree_transact([self])
@classmethod
def braintree_transact(cls, payments=None):
"""Create transactions for braintree payments
The transaction is committed after each payment sale."""
if payments is None:
payments = cls.search([
('state', '=', 'processing'),
('journal.process_method', '=', 'braintree'),
['OR',
('braintree_nonce', '!=', None),
('braintree_customer_method', '!=', None),
('braintree_customer', '!=', None),
],
('braintree_transaction_id', '=', None),
('company', '=', Transaction().context.get('company')),
])
for payment in payments:
# Use clear cache after a commit
payment = cls(payment.id)
if (payment.braintree_transaction_id
or payment.journal.process_method != 'braintree'
or payment.state != 'processing'):
continue
payment.lock()
gateway = payment.braintree_account.gateway()
try:
result = gateway.transaction.sale(
payment._braintree_transaction_parameters())
except TooManyRequestsError as e:
logger.warning(str(e))
continue
except BraintreeError as e:
payment.braintree_error_message = str(e)
payment.save()
cls.fail([payment])
except Exception:
logger.error(
"Error when processing payment %d", payment.id,
exc_info=True)
continue
else:
if result.is_success:
payment.braintree_transaction_id = result.transaction.id
payment.braintree_payment_settled = (
payment.braintree_settle_payment)
payment.braintree_update(result.transaction)
if (payment.braintree_account.environment == 'sandbox'
and (payment.braintree_account
.sandbox_settle_transaction)
and payment.braintree_settle_payment):
gateway.testing.settle_transaction(
result.transaction.id)
else:
payment.braintree_error_message = result.message
payment.save()
cls.fail([payment])
Transaction().commit()
def _braintree_transaction_parameters(self):
params = {
'amount': self.amount,
'options': {
'submit_for_settlement': self.braintree_settle_payment,
},
}
if self.braintree_nonce:
params['payment_method_nonce'] = self.braintree_nonce
elif self.braintree_customer_method:
params['payment_method_token'] = self.braintree_customer_method
elif self.braintree_customer:
params['customer_id'] = (
self.braintree_customer.braintree_customer_id)
if self.braintree_device_data:
params['device_data'] = self.braintree_device_data
return params
@classmethod
@ModelView.button
def braintree_do_settle_payment(cls, payments):
cls.write(payments, {
'braintree_settle_payment': True,
})
cls.__queue__.braintree_settle_payment_(payments)
@classmethod
def braintree_settle_payment_(cls, payments=None):
"""Settle braintree payments
The transaction is committed after each payment settlement."""
if payments is None:
payments = cls.search([
('state', '=', 'processing'),
('journal.process_method', '=', 'braintree'),
('braintree_transaction_id', '!=', None),
('braintree_payment_settled', '=', False),
('braintree_settle_payment', '=', True),
('company', '=', Transaction().context.get('company')),
])
for payment in payments:
# Use clear cache after a commit
payment = cls(payment.id)
if (payment.journal.process_method != 'braintree'
or payment.braintree_payment_settled
or payment.state != 'processing'):
continue
payment.lock()
braintree_account = payment.braintree_account
gateway = braintree_account.gateway()
try:
result = gateway.transaction.submit_for_settlement(
payment.braintree_transaction_id,
payment.amount,
payment._braintree_settlement_parameters())
except TooManyRequestsError as e:
logger.warning(str(e))
continue
except BraintreeError as e:
payment.braintree_error_message = str(e)
payment.save()
cls.fail([payment])
except Exception:
logger.error(
"Error when processing payment %d", payment.id,
exc_info=True)
continue
else:
if result.is_success:
payment.braintree_payment_settled = True
payment.braintree_update(result.transaction)
if (braintree_account.environment == 'sandbox'
and (braintree_account
.sandbox_settle_transaction)):
gateway.testing.settle_transaction(
result.transaction.id)
else:
payment.braintree_error_message = result.message
payment.save()
cls.fail([payment])
Transaction().commit()
def _braintree_settlement_parameters(self):
return {}
@classmethod
@ModelView.button
def braintree_do_pull(cls, payments):
cls.braintree_pull(payments)
@classmethod
def braintree_pull(cls, payments=None):
"Update payments with braintree transactions"
if payments is None:
payments = cls.search([
('state', '=', 'processing'),
('journal.process_method', '=', 'braintree'),
('braintree_transaction_id', '!=', None),
('company', '=', Transaction().context.get('company')),
])
for payment in payments:
gateway = payment.braintree_account.gateway()
try:
transaction = gateway.transaction.find(
payment.braintree_transaction_id)
except TooManyRequestsError as e:
logger.warning(str(e))
continue
except Exception:
logger.error(
"Error when pulling payment %d", payment.id,
exc_info=True)
continue
else:
payment.braintree_update(transaction)
cls.save(payments)
def braintree_update(self, transaction):
"Update payment with braintree transaction"
assert transaction.id == self.braintree_transaction_id
gateway = transaction.gateway
amount = transaction.amount
for id_ in transaction.refund_ids:
refund = gateway.transaction.find(id_)
if refund.status in SUCCEEDED_STATUSES:
amount -= refund.amount
for dispute in transaction.disputes:
if dispute.status in DISPUTE_FINAL_STATUSES:
amount -= dispute.amount_disputed - dispute.amount_won
if (self.state not in {'succeeded', 'failed'}
or self.amount != amount
or (not amount and self.state != 'failed')):
if self.state == 'succeeded':
self.__class__.proceed([self])
if transaction.status in SUCCEEDED_STATUSES:
self.braintree_error_message = None
self.amount = amount
self.save()
if transaction.status in SUCCEEDED_STATUSES:
if amount:
self.__class__.succeed([self])
else:
self.__class__.fail([self])
elif transaction.status in FAILED_STATUSES:
self.__class__.fail([self])
class PaymentBraintreeRefund(Workflow, ModelSQL, ModelView):
__name__ = 'account.payment.braintree.refund'
payment = fields.Many2One(
'account.payment', "Payment", required=True,
domain=[
('process_method', '=', 'braintree'),
('braintree_transaction_id', '!=', None),
],
states={
'readonly': Eval('state') != 'draft',
})
amount = Monetary(
"Amount", currency='currency', digits='currency', required=True,
states={
'readonly': Eval('state') != 'draft',
})
submitted_by = employee_field(
"Submitted by",
states=['submitted', 'approved', 'processing', 'succeeded', 'failed'])
approved_by = employee_field(
"Approved by",
states=['approved', 'processing', 'succeeded', 'failed'])
state = fields.Selection([
('draft', "Draft"),
('submitted', "Submitted"),
('approved', "Approved"),
('processing', "Processing"),
('succeeded', "Succeeded"),
('failed', "Failed"),
], "State", readonly=True, sort=False)
braintree_transaction_id = fields.Char(
"Braintree Transaction ID", readonly=True, strip=False)
braintree_error_message = fields.Text(
"Braintree Error Message", readonly=True,
states={
'invisible': (
~Eval('braintree_error_message')
| (Eval('state') == 'succeeded')),
})
currency = fields.Function(fields.Many2One(
'currency.currency', "Currency"),
'on_change_with_currency')
company = fields.Function(
fields.Many2One('company.company', "Company"),
'on_change_with_company')
@classmethod
def __setup__(cls):
super().__setup__()
t = cls.__table__()
cls._sql_indexes.add(
Index(
t,
(t.state, Index.Equality(cardinality='low')),
where=t.state.in_([
'draft', 'submitted', 'approved', 'processing'])))
cls.__access__.add('payment')
cls._transitions |= set((
('draft', 'submitted'),
('submitted', 'approved'),
('approved', 'processing'),
('processing', 'succeeded'),
('processing', 'failed'),
('approved', 'draft'),
))
cls._buttons.update({
'draft': {
'invisible': ~Eval('state').in_(['approved', 'submitted']),
'icon': 'tryton-back',
'depends': ['state'],
},
'submit': {
'invisible': Eval('state') != 'draft',
'icon': 'tryton-forward',
'depends': ['state'],
},
'approve': {
'invisible': Eval('state') != 'submitted',
'icon': 'tryton-forward',
'depends': ['state'],
},
})
@classmethod
def default_state(cls):
return 'draft'
@fields.depends('payment', '_parent_payment.currency')
def on_change_with_currency(self, name=None):
return self.payment.currency if self.payment else None
@fields.depends('payment', '_parent_payment.company')
def on_change_with_company(self, name=None):
return self.payment.company if self.payment else None
@classmethod
def copy(cls, refunds, default=None):
if default is None:
default = {}
else:
default = default.copy()
default.setdefault('braintree_transaction_id')
default.setdefault('braintree_error_message')
return super().copy(refunds, default=default)
@classmethod
@ModelView.button
@Workflow.transition('draft')
@reset_employee('submitted_by', 'approved_by')
def draft(cls, refunds):
pass
@classmethod
@ModelView.button
@Workflow.transition('submitted')
@set_employee('submitted_by')
def submit(cls, refunds):
pass
@classmethod
@ModelView.button
@Workflow.transition('approved')
@set_employee('approved_by')
def approve(cls, refunds):
pass
@classmethod
@Workflow.transition('processing')
def process(cls, refunds):
pass
@classmethod
@Workflow.transition('succeeded')
def succeed(cls, refunds):
pass
@classmethod
@Workflow.transition('failed')
def fail(cls, refunds):
pass
@classmethod
def braintree_refund(cls, refunds=None):
"""Refund braintree transaction
The transaction is committed after each refund."""
if refunds is None:
refunds = cls.search([('state', '=', 'approved')])
for refund in refunds:
# User clear cache after a commit
refund = cls(refund.id)
if refund.braintree_transaction_id:
continue
refund.lock()
braintree_account = refund.payment.braintree_account
gateway = braintree_account.gateway()
try:
result = gateway.transaction.refund(
refund.payment.braintree_transaction_id,
refund._refund_parameters())
except TooManyRequestsError as e:
logger.warning(str(e))
continue
except BraintreeError as e:
refund.braintree_error_message = str(e)
refund.save()
cls.process([refund])
cls.fail([refund])
except Exception:
logger.error(
"Error when refund %d", refund.id,
exc_info=True)
continue
else:
cls.process([refund])
if result.is_success:
refund.braintree_transaction_id = result.transaction.id
refund.braintree_update(result.transaction)
if (braintree_account.environment == 'sandbox'
and (braintree_account
.sandbox_settle_transaction)):
gateway.testing.settle_transaction(
result.transaction.id)
else:
refund.braintree_error_message = result.message
refund.save()
cls.fail([refund])
Transaction().commit()
def _refund_parameters(self):
return {
'amount': self.amount,
}
@classmethod
def braintree_pull(cls, refunds=None):
"Update refund with braintree transactions"
if refunds is None:
refunds = cls.search([
('state', '=', 'processing'),
('braintree_transaction_id', '!=', None),
('payment.company',
'=', Transaction().context.get('company')),
])
for refund in refunds:
gateway = refund.payment.braintree_account.gateway()
try:
transaction = gateway.transaction.find(
refund.braintree_transaction_id)
except TooManyRequestsError as e:
logger.warning(str(e))
continue
except Exception:
logger.error(
"Error when pulling refund %d", refund.id,
exc_info=True)
continue
else:
refund.braintree_update(transaction)
cls.save(refunds)
for refund in refunds:
if refund.state in {'succeeded', 'failed'}:
payment = refund.payment
gateway = payment.braintree_account.gateway()
transaction = gateway.transaction.find(
payment.braintree_transaction_id)
payment.braintree_update(transaction)
def braintree_update(self, transaction):
"Update refund with braintree transaction"
self.amount = transaction.amount
self.save()
if transaction.status in SUCCEEDED_STATUSES:
self.__class__.succeed([self])
elif transaction.status in FAILED_STATUSES:
self.__class__.fail([self])
class PaymentBraintreeAccount(ModelSQL, ModelView):
__name__ = 'account.payment.braintree.account'
name = fields.Char("Name", required=True)
currency = fields.Many2One('currency.currency', "Currency", required=True)
environment = fields.Selection([
('sandbox', "Sandbox"),
('production', "Production"),
], "Environment", required=True)
merchant_id = fields.Char("Merchant ID", required=True, strip=False)
public_key = fields.Char("Public Key", required=True, strip=False)
private_key = fields.Char("Private Key", required=True, strip=False)
webhook_identifier = fields.Char(
"Webhook Identifier", readonly=True, strip=False)
webhook_endpoint = fields.Function(
fields.Char(
"Webhook Endpoint",
help="The URL to be called by Braintree."),
'on_change_with_webhook_endpoint')
sandbox_settle_transaction = fields.Boolean(
"Automatic Settlement",
states={
'invisible': Eval('environment') != 'sandbox',
})
@classmethod
def __setup__(cls):
super().__setup__()
cls._buttons.update({
'new_identifier': {
'icon': 'tryton-refresh',
},
})
@classmethod
def default_environment(cls):
return 'sandbox'
@classmethod
def default_sandbox_settle_transaction(cls):
return False
@fields.depends('webhook_identifier')
def on_change_with_webhook_endpoint(self, name=None):
if not self.webhook_identifier:
return
url_part = {
'identifier': self.webhook_identifier,
'database_name': Transaction().database.name,
}
return http_host() + (
urllib.parse.quote(
'/%(database_name)s/account_payment_braintree'
'/webhook/%(identifier)s' % url_part))
@property
def configuration(self):
return braintree.Configuration(
environment=braintree.Environment.All[self.environment],
merchant_id=self.merchant_id,
public_key=self.public_key,
private_key=self.private_key,
)
def gateway(self):
return braintree.BraintreeGateway(self.configuration)
@classmethod
@ModelView.button
def new_identifier(cls, accounts):
for account in accounts:
if account.webhook_identifier:
account.webhook_identifier = None
else:
account.webhook_identifier = uuid.uuid4().hex
cls.save(accounts)
def webhook(self, notification):
"""Handles Braintree webhook notification
The return values must be:
- None if the method could not handle the notification kind
- True if the notification has been handled
- False if the notification should be retried by Braintree
"""
if notification.kind in {
braintree.WebhookNotification.Kind.DisputeOpened,
braintree.WebhookNotification.Kind.DisputeLost,
braintree.WebhookNotification.Kind.DisputeWon,
braintree.WebhookNotification.Kind.DisputeAccepted,
braintree.WebhookNotification.Kind.DisputeExpired,
braintree.WebhookNotification.Kind.DisputeDisputed}:
return self.webhook_dispute(notification.dispute)
elif notification.kind in {
braintree.WebhookNotification.Kind.
TransactionSettlementDeclined,
braintree.WebhookNotification.Kind.TransactionSettled}:
return self.webhook_transaction(notification.transaction)
return None
def webhook_dispute(self, dispute):
pool = Pool()
Payment = pool.get('account.payment')
payments = Payment.search([
('braintree_transaction_id', '=', dispute.transaction.id),
])
if not payments:
logger.error(
"webhook dispute: No payment '%s'", dispute.transaction.id)
for payment in payments:
payment.braintree_dispute_reason = dispute.reason
payment.braintree_dispute_status = dispute.status
payment.save()
gateway = payment.braintree_account.gateway()
transaction = gateway.transaction.find(
payment.braintree_transaction_id)
payment.braintree_update(transaction)
return bool(payments)
def webhook_transaction(self, transaction):
pool = Pool()
Payment = pool.get('account.payment')
payments = Payment.search([
('braintree_transaction_id', '=', transaction.id),
])
if not payments:
logger.error(
"webhook transaction: No payment '%s'", transaction.id)
for payment in payments:
payment.braintree_update(transaction)
return bool(payments)
@classmethod
def check_modification(cls, mode, accounts, values=None, external=False):
pool = Pool()
Warning = pool.get('res.user.warning')
super().check_modification(
mode, accounts, values=values, external=external)
if (mode == 'write'
and external
and values.keys()
& {'environment', 'merchant_id', 'public_key', 'private_key'}):
warning_name = Warning.format('braintree_key', accounts)
if Warning.check(warning_name):
raise BraintreeAccountWarning(
warning_name,
gettext('account_payment_braintree'
'.msg_braintree_key_modified'))
class PaymentBraintreeCustomer(
CheckoutMixin, DeactivableMixin, ModelSQL, ModelView):
__name__ = 'account.payment.braintree.customer'
_history = True
_rec_name = 'braintree_customer_id'
party = fields.Many2One(
'party.party', "Party", required=True,
states={
'readonly': (
Eval('braintree_customer_id') | Eval('braintree_nonce')),
})
braintree_account = fields.Many2One(
'account.payment.braintree.account', "Account", required=True,
states={
'readonly': (
Eval('braintree_customer_id') | Eval('braintree_nonce')),
})
braintree_customer_id = fields.Char(
"Braintree Customer ID", strip=False,
states={
'readonly': Eval('braintree_customer_id') & (Eval('id', -1) >= 0),
})
braintree_error_message = fields.Text(
"Braintree Error Message", readonly=True,
states={
'invisible': ~Eval('braintree_error_message'),
})
identical_customers = fields.Many2Many(
'account.payment.braintree.customer.identical',
'source', 'target', "Identical Customers", readonly=True,
states={
'invisible': ~Eval('identical_customers'),
})
_payment_methods_cache = Cache(
'account_payment_braintree_customer.payment_methods',
duration=config.getint(
'account_payment_braintree', 'payment_methods_cache',
default=15 * 60),
context=False)
@classmethod
def __setup__(cls):
super().__setup__()
cls._buttons.update({
'braintree_checkout': {
'invisible': Bool(Eval('braintree_nonce')),
'depends': ['braintree_nonce'],
},
'braintree_update': {
'invisible': ~Eval('braintree_customer_id'),
'depends': ['braintree_customer_id'],
},
'delete_payment_method': {
'invisible': ~Eval('braintree_customer_id'),
'depends': ['braintree_customer_id'],
},
})
def get_rec_name(self, name):
name = super().get_rec_name(name)
return (
self.braintree_customer_id if self.braintree_customer_id else name)
@classmethod
def delete(cls, customers):
ids, on_delete = cls._before_delete(customers)
cls.write(customers, {
'active': False,
})
cls._after_delete(ids, on_delete)
@classmethod
def copy(cls, customers, default=None):
if default is None:
default = {}
else:
default = default.copy()
default.setdefault('braintree_customer_id', None)
return super().copy(customers, default=default)
@classmethod
def braintree_checkout(cls, customers):
for customer in customers:
if customer.braintree_client_token:
continue
gateway = customer.braintree_account.gateway()
client_token = gateway.client_token.generate(
customer._client_token_parameters())
customer.braintree_client_token = client_token
return super().braintree_checkout(customers)
def _client_token_parameters(self):
params = {}
if self.braintree_customer_id:
params['customer_id'] = self.braintree_customer_id
return params
def braintree_set_nonce(self, nonce, device_data=None):
super().braintree_set_nonce(nonce, device_data=device_data)
self.braintree_create(customers=[self])
@classmethod
def braintree_create(cls, customers=None):
"""Creates braintree customers
The transaction is committed after each customer."""
if not customers:
customers = cls.search([
('braintree_nonce', '!=', None),
])
for customer in customers:
# Use clear cache after commit
customer = cls(customer.id)
if not customer.braintree_nonce:
continue
customer.lock()
gateway = customer.braintree_account.gateway()
try:
if not customer.braintree_customer_id:
result = gateway.customer.create(
customer._customer_parameters())
else:
result = gateway.payment_method.create({
'customer_id': customer.braintree_customer_id,
'payment_method_nonce': customer.braintree_nonce,
})
except TooManyRequestsError as e:
logger.warning(str(e))
continue
except BraintreeError as e:
customer.braintree_error_message = str(e)
except Exception:
logger.error(
"Error when creating customer %d", customer.id,
exc_info=True)
continue
else:
if result.is_success:
if not customer.braintree_customer_id:
customer.braintree_customer_id = result.customer.id
customer.braintree_error_message = None
else:
customer.braintree_error_message = result.message
customer.braintree_client_token = None
customer.braintree_nonce = None
customer.save()
cls._payment_methods_cache.clear()
Transaction().commit()
def _customer_parameters(self):
params = {
'email': self.party.email,
'fax': self.party.fax[:255],
'last_name': self.party.name[:255],
'phone': self.party.phone[:255],
'website': self.party.website[:255],
}
if self.braintree_nonce:
params['payment_method_nonce'] = self.braintree_nonce
return params
@classmethod
@ModelView.button
def braintree_update(cls, customers):
for customer in customers:
if not customer.braintree_customer_id:
continue
gateway = customer.braintree_account.gateway()
try:
gateway.customer.update(
customer.braintree_customer_id,
customer._customer_parameters())
except TooManyRequestsError as e:
logger.warning(str(e))
raise
@classmethod
def braintree_delete(cls, customers=None):
"""Deletes braintree customers
The transaction is committed after each customer."""
if not customers:
customers = cls.search([
('active', '=', False),
('braintree_customer_id', '!=', None),
])
for customer in customers:
# Use clear cache after commit
customer = cls(customer.id)
assert not customer.active
customer.lock()
gateway = customer.braintree_account.gateway()
try:
result = gateway.customer.delete(
customer.braintree_customer_id)
except TooManyRequestsError as e:
logger.warning(str(e))
continue
except Exception:
logger.error(
"Error when deleting customer %d", customer.id,
exc_info=True)
continue
if result.is_success:
customer.braintree_customer_id = None
customer.save()
Transaction().commit()
def find(self):
if not self.braintree_customer_id:
return
gateway = self.braintree_account.gateway()
try:
return gateway.customer.find(self.braintree_customer_id)
except (TooManyRequestsError,
GatewayTimeoutError) as e:
logger.warning(str(e))
def payment_methods(self):
methods = self._payment_methods_cache.get(self.id)
if methods is not None:
return methods
methods = []
customer = self.find()
if customer:
for payment_method in customer.payment_methods:
name = self._payment_method_name(payment_method)
methods.append((payment_method.token, name))
self._payment_methods_cache.set(self.id, methods)
return methods
def _payment_method_name(cls, payment_method):
name = payment_method.token
if hasattr(payment_method, 'card_type'):
name = payment_method.card_type
if payment_method.last_4:
name += ' ****' + payment_method.last_4
if (payment_method.expiration_month
and payment_method.expiration_year):
name += ' %s/%s' % (
payment_method.expiration_month,
payment_method.expiration_year)
elif isinstance(payment_method, braintree.PayPalAccount):
name = "Paypal %s" % payment_method.email
elif isinstance(payment_method, braintree.VenmoAccount):
name = "Venmo %s" % payment_method.source_description
elif isinstance(payment_method, braintree.UsBankAccount):
name = "****" + payment_method.last_4
return name
@classmethod
@ModelView.button_action(
'account_payment_braintree.wizard_customer_payment_method_delete')
def delete_payment_method(cls, customers):
pass
def delete_payment_method_(self, payment_method):
gateway = self.braintree_account.gateway()
try:
result = gateway.payment_method.delete(payment_method)
except (TooManyRequestsError,
GatewayTimeoutError) as e:
logger.warning(str(e))
else:
if not result.is_success:
logger.error(result.message)
self._payment_methods_cache.clear()
class PaymentBraintreeCustomerIdentical(ModelSQL):
__name__ = 'account.payment.braintree.customer.identical'
source = fields.Many2One('account.payment.braintree.customer', "Source")
target = fields.Many2One('account.payment.braintree.customer', "Target")
@classmethod
def table_query(cls):
pool = Pool()
Customer = pool.get('account.payment.braintree.customer')
source = Customer.__table__()
target = Customer.__table__()
return (
source
.join(target, condition=(
source.braintree_customer_id
== target.braintree_customer_id))
.select(
sql_pairing(source.id, target.id).as_('id'),
source.id.as_('source'),
target.id.as_('target'),
where=source.id != target.id))
class PaymentBraintreeCustomerPaymentMethodDelete(Wizard):
__name__ = 'account.payment.braintree.customer.payment_method.delete'
start_state = 'ask'
ask = StateView(
'account.payment.braintree.customer.payment_method.delete.ask',
'account_payment_braintree.'
'customer_payment_method_delete_ask_view_form', [
Button("Cancel", 'end', 'tryton-cancel'),
Button("Delete", 'delete_', 'tryton-ok', default=True),
])
delete_ = StateTransition()
def default_ask(self, fields):
default = {}
if 'customer' in fields:
default['customer'] = self.record.id
return default
def transition_delete_(self):
self.record.delete_payment_method_(self.ask.payment_method)
return 'end'
class PaymentBraintreeCustomerPaymentMethodDeleteAsk(ModelView):
__name__ = 'account.payment.braintree.customer.payment_method.delete.ask'
customer = fields.Many2One(
'account.payment.braintree.customer', "Customer", readonly=True)
payment_method = fields.Selection(
'get_payment_methods', "Payment Method", required=True)
@fields.depends('customer')
def get_payment_methods(self):
methods = [('', '')]
if self.customer:
methods.extend(self.customer.payment_methods())
return methods
class PaymentBraintreeCheckoutPage(Report):
__name__ = 'account.payment.braintree.checkout'