1278 lines
46 KiB
Python
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'
|