909 lines
33 KiB
Python
909 lines
33 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 uuid
|
|
import logging
|
|
import urllib
|
|
from decimal import Decimal
|
|
from itertools import groupby
|
|
from operator import attrgetter
|
|
|
|
import stripe
|
|
|
|
from trytond.model import ModelSQL, ModelView, Workflow, fields
|
|
from trytond.pool import PoolMeta, Pool
|
|
from trytond.pyson import Eval, Bool
|
|
from trytond.report import Report
|
|
from trytond.rpc import RPC
|
|
from trytond.transaction import Transaction
|
|
from trytond.url import HOSTNAME
|
|
from trytond.wizard import Wizard, StateAction
|
|
|
|
__all__ = ['Journal', 'Group', 'Payment', 'Account', 'Customer',
|
|
'Checkout', 'CheckoutPage']
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class Journal:
|
|
__metaclass__ = PoolMeta
|
|
__name__ = 'account.payment.journal'
|
|
|
|
stripe_account = fields.Many2One(
|
|
'account.payment.stripe.account', "Account", ondelete='RESTRICT',
|
|
states={
|
|
'required': Eval('process_method') == 'stripe',
|
|
'invisible': Eval('process_method') != 'stripe',
|
|
},
|
|
depends=['process_method'])
|
|
|
|
@classmethod
|
|
def __setup__(cls):
|
|
super(Journal, cls).__setup__()
|
|
stripe_method = ('stripe', 'Stripe')
|
|
if stripe_method not in cls.process_method.selection:
|
|
cls.process_method.selection.append(stripe_method)
|
|
|
|
|
|
class Group:
|
|
__metaclass__ = PoolMeta
|
|
__name__ = 'account.payment.group'
|
|
|
|
@classmethod
|
|
def __setup__(cls):
|
|
super(Group, cls).__setup__()
|
|
cls._error_messages.update({
|
|
'no_stripe_token': ('No Stripe token found '
|
|
'for payment "%(payment)s".'),
|
|
})
|
|
|
|
def process_stripe(self):
|
|
pool = Pool()
|
|
Payment = pool.get('account.payment')
|
|
for payment in self.payments:
|
|
if not payment.stripe_token and not payment.stripe_customer:
|
|
account = payment.journal.stripe_account
|
|
for customer in payment.party.stripe_customers:
|
|
if (customer.stripe_account == account
|
|
and customer.stripe_customer_id):
|
|
payment.stripe_customer = customer
|
|
break
|
|
else:
|
|
self.raise_user_error('no_stripe_token', {
|
|
'payment': payment.rec_name,
|
|
})
|
|
Payment.save(self.payments)
|
|
|
|
|
|
class Payment:
|
|
__metaclass__ = PoolMeta
|
|
__name__ = 'account.payment'
|
|
|
|
stripe_journal = fields.Function(
|
|
fields.Boolean("Stripe Journal"), 'on_change_with_stripe_journal')
|
|
stripe_checkout_needed = fields.Function(
|
|
fields.Boolean("Stripe Checkout Needed"), 'get_stripe_checkout_needed')
|
|
stripe_checkout_id = fields.Char("Stripe Checkout ID", readonly=True)
|
|
stripe_charge_id = fields.Char(
|
|
"Stripe Charge ID", readonly=True,
|
|
states={
|
|
'invisible': ~Eval('stripe_journal') & ~Eval('stripe_charge_id'),
|
|
},
|
|
depends=['stripe_journal'])
|
|
stripe_capture = fields.Boolean(
|
|
"Stripe Capture",
|
|
states={
|
|
'invisible': ~Eval('stripe_journal'),
|
|
'readonly': Eval('state') != 'draft',
|
|
},
|
|
depends=['stripe_journal', 'state'])
|
|
stripe_captured = fields.Boolean(
|
|
"Stripe Captured", readonly=True)
|
|
stripe_capture_needed = fields.Function(
|
|
fields.Boolean("Stripe Capture Needed"),
|
|
'get_stripe_capture_needed')
|
|
stripe_token = fields.Char("Stripe Token",
|
|
states={
|
|
'invisible': (~Eval('stripe_journal')
|
|
| Eval('stripe_customer_source')),
|
|
'readonly': ~Eval('state').in_(['draft', 'approved']),
|
|
},
|
|
depends=['stripe_journal', 'stripe_customer_source', 'state'])
|
|
stripe_chargeable = fields.Boolean(
|
|
"Stripe Chargeable",
|
|
states={
|
|
'invisible': ~Eval('stripe_journal') | ~Eval('stripe_token'),
|
|
},
|
|
depends=['stripe_journal', 'stripe_token'])
|
|
stripe_error_message = fields.Char("Stripe Error Message", readonly=True,
|
|
states={
|
|
'invisible': ~Eval('stripe_error_message'),
|
|
})
|
|
stripe_error_code = fields.Char("Stripe Error Code", readonly=True,
|
|
states={
|
|
'invisible': ~Eval('stripe_error_code'),
|
|
})
|
|
stripe_error_param = fields.Char("Stripe Error Param", readonly=True,
|
|
states={
|
|
'invisible': ~Eval('stripe_error_param'),
|
|
})
|
|
stripe_dispute_reason = fields.Char("Stripe Dispute Reason", readonly=True,
|
|
states={
|
|
'invisible': ~Eval('stripe_dispute_reason'),
|
|
})
|
|
stripe_dispute_status = fields.Char("Stripe Dispute Status", readonly=True,
|
|
states={
|
|
'invisible': ~Eval('stripe_dispute_status'),
|
|
})
|
|
stripe_customer = fields.Many2One(
|
|
'account.payment.stripe.customer', "Stripe Customer",
|
|
domain=[
|
|
('party', '=', Eval('party', -1)),
|
|
('stripe_account', '=', Eval('stripe_account', -1)),
|
|
],
|
|
states={
|
|
'invisible': ~Eval('stripe_journal'),
|
|
'required': Bool(Eval('stripe_customer_source')),
|
|
'readonly': ~Eval('state').in_(['draft', 'approved']),
|
|
},
|
|
depends=['party', 'stripe_account', 'stripe_journal',
|
|
'stripe_customer_source', 'state'])
|
|
stripe_customer_source = fields.Char(
|
|
"Stripe Customer Source",
|
|
states={
|
|
'invisible': (~Eval('stripe_journal') | Eval('stripe_token')
|
|
| ~Eval('stripe_customer')),
|
|
'readonly': ~Eval('state').in_(['draft', 'approved']),
|
|
},
|
|
depends=['stripe_account', 'stripe_token', 'stripe_customer', 'state'])
|
|
# Use Function field with selection to avoid to query Stripe
|
|
# to validate the value
|
|
stripe_customer_source_selection = fields.Function(fields.Selection(
|
|
'get_stripe_customer_sources', "Stripe Customer Source",
|
|
states={
|
|
'invisible': (~Eval('stripe_journal') | Eval('stripe_token')
|
|
| ~Eval('stripe_customer')),
|
|
'readonly': ~Eval('state').in_(['draft', 'approved']),
|
|
},
|
|
depends=[
|
|
'stripe_account', 'stripe_token', 'stripe_customer', 'state']),
|
|
'get_stripe_customer_source')
|
|
stripe_account = fields.Function(fields.Many2One(
|
|
'account.payment.stripe.account', "Stripe Account"),
|
|
'on_change_with_stripe_account')
|
|
stripe_amount = fields.Function(
|
|
fields.Integer("Stripe Amount"),
|
|
'get_stripe_amount', setter='set_stripe_amount')
|
|
|
|
@classmethod
|
|
def __setup__(cls):
|
|
super(Payment, cls).__setup__()
|
|
cls.amount.states['readonly'] &= ~Eval('stripe_capture_needed')
|
|
cls.amount.depends.append('stripe_capture_needed')
|
|
cls.stripe_amount.states.update(cls.amount.states)
|
|
cls.stripe_amount.depends.extend(cls.amount.depends)
|
|
cls._error_messages.update({
|
|
'stripe_receivable': ('Stripe journal "%(journal)s" '
|
|
'can only be used for receivable payment "%(payment)s".'),
|
|
})
|
|
cls._buttons.update({
|
|
'stripe_checkout': {
|
|
'invisible': (~Eval('state', 'draft').in_(
|
|
['approved', 'processing'])
|
|
| ~Eval('stripe_checkout_needed', False)),
|
|
},
|
|
'stripe_capture_': {
|
|
'invisible': ((Eval('state', 'draft') != 'processing')
|
|
| ~Eval('stripe_capture_needed')),
|
|
},
|
|
})
|
|
# As there is not setter to avoid the cost of validation,
|
|
# the readonly attribute must be unset.
|
|
cls.stripe_customer_source_selection._field.readonly = False
|
|
|
|
@classmethod
|
|
def default_stripe_capture(cls):
|
|
return True
|
|
|
|
@classmethod
|
|
def default_stripe_captured(cls):
|
|
return False
|
|
|
|
@classmethod
|
|
def default_stripe_chargeable(cls):
|
|
return False
|
|
|
|
@fields.depends('journal')
|
|
def on_change_with_stripe_journal(self, name=None):
|
|
if self.journal:
|
|
return self.journal.process_method == 'stripe'
|
|
else:
|
|
return False
|
|
|
|
@fields.depends('party')
|
|
def on_change_party(self):
|
|
super(Payment, self).on_change_party()
|
|
self.stripe_customer = None
|
|
self.stripe_customer_source = None
|
|
self.stripe_customer_source_selection = None
|
|
|
|
@fields.depends('stripe_customer', 'stripe_customer_source')
|
|
def get_stripe_customer_sources(self):
|
|
sources = [('', '')]
|
|
if self.stripe_customer:
|
|
sources.extend(self.stripe_customer.sources())
|
|
if (self.stripe_customer_source
|
|
and self.stripe_customer_source not in dict(sources)):
|
|
sources.append(
|
|
(self.stripe_customer_source, self.stripe_customer_source))
|
|
return sources
|
|
|
|
@fields.depends(
|
|
'stripe_customer_source_selection',
|
|
'stripe_customer_source')
|
|
def on_change_stripe_customer_source_selection(self):
|
|
self.stripe_customer_source = self.stripe_customer_source_selection
|
|
|
|
def get_stripe_customer_source(self, name):
|
|
return self.stripe_customer_source
|
|
|
|
def get_stripe_checkout_needed(self, name):
|
|
return (self.journal.process_method == 'stripe'
|
|
and not self.stripe_token
|
|
and not self.stripe_customer)
|
|
|
|
def get_stripe_capture_needed(self, name):
|
|
return (self.journal.process_method == 'stripe'
|
|
and self.stripe_charge_id
|
|
and not self.stripe_captured)
|
|
|
|
@fields.depends('journal')
|
|
def on_change_with_stripe_account(self, name=None):
|
|
if self.journal and self.journal.process_method == 'stripe':
|
|
return self.journal.stripe_account.id
|
|
|
|
def get_stripe_amount(self, name):
|
|
return int(self.amount * 10 ** self.currency_digits)
|
|
|
|
@classmethod
|
|
def set_stripe_amount(cls, payments, name, value):
|
|
keyfunc = attrgetter('currency_digits')
|
|
payments = sorted(payments, key=keyfunc)
|
|
value = Decimal(value)
|
|
for digits, payments in groupby(payments, keyfunc):
|
|
digits = Decimal(digits)
|
|
cls.write(list(payments), {
|
|
'amount': value * 10 ** -digits,
|
|
})
|
|
|
|
@classmethod
|
|
def validate(cls, payments):
|
|
super(Payment, cls).validate(payments)
|
|
for payment in payments:
|
|
payment.check_stripe_journal()
|
|
|
|
def check_stripe_journal(self):
|
|
if (self.kind != 'receivable'
|
|
and self.journal.process_method == 'stripe'):
|
|
self.raise_user_error('stripe_receivable', {
|
|
'journal': self.journal.rec_name,
|
|
'payment': self.rec_name,
|
|
})
|
|
|
|
@classmethod
|
|
def copy(cls, payments, default=None):
|
|
if default is None:
|
|
default = {}
|
|
else:
|
|
default = default.copy()
|
|
default['stripe_checkout_id'] = None
|
|
default['stripe_charge_id'] = None
|
|
default['stripe_token'] = None
|
|
default.setdefault('stripe_error_message')
|
|
default.setdefault('stripe_error_code')
|
|
default.setdefault('stripe_error_param')
|
|
return super(Payment, cls).copy(payments, default=default)
|
|
|
|
@classmethod
|
|
@ModelView.button
|
|
@Workflow.transition('draft')
|
|
def draft(cls, payments):
|
|
super(Payment, cls).draft(payments)
|
|
for payment in payments:
|
|
if payment.stripe_token:
|
|
payment.stripe_token = None
|
|
cls.save(payments)
|
|
|
|
@classmethod
|
|
@ModelView.button_action('account_payment_stripe.wizard_checkout')
|
|
def stripe_checkout(cls, payments):
|
|
for payment in payments:
|
|
payment.stripe_checkout_id = uuid.uuid4().hex
|
|
cls.save(payments)
|
|
|
|
@classmethod
|
|
def stripe_charge(cls, payments=None):
|
|
"""Charge stripe payments
|
|
|
|
The transaction is committed after each payment charge.
|
|
"""
|
|
if not payments:
|
|
payments = cls.search([
|
|
('state', '=', 'processing'),
|
|
('journal.process_method', '=', 'stripe'),
|
|
['OR',
|
|
[
|
|
('stripe_token', '!=', None),
|
|
('stripe_chargeable', '=', True),
|
|
],
|
|
('stripe_customer.stripe_customer_id', '!=', None),
|
|
],
|
|
('stripe_charge_id', '=', None),
|
|
])
|
|
for payment in payments:
|
|
try:
|
|
charge = stripe.Charge.create(**payment._charge_parameters())
|
|
except (stripe.error.RateLimitError,
|
|
stripe.error.APIConnectionError) as e:
|
|
logger.warning(str(e))
|
|
continue
|
|
except stripe.error.StripeError as e:
|
|
payment.stripe_error_message = unicode(e)
|
|
if isinstance(e, stripe.error.CardError):
|
|
payment.stripe_error_code = e.code
|
|
payment.stripe_error_param = e.param
|
|
payment.save()
|
|
cls.fail([payment])
|
|
except Exception:
|
|
logger.error(
|
|
"Error when processing payment %d", payment.id,
|
|
exc_info=True)
|
|
continue
|
|
else:
|
|
payment.stripe_charge_id = charge.id
|
|
payment.stripe_captured = charge.captured
|
|
payment.save()
|
|
if charge.status == 'succeeded' and charge.captured:
|
|
cls.succeed([payment])
|
|
elif charge.status == 'failed':
|
|
cls.fail([payment])
|
|
Transaction().commit()
|
|
|
|
def _charge_parameters(self):
|
|
source, customer = None, None
|
|
if self.stripe_token:
|
|
source = self.stripe_token
|
|
elif self.stripe_customer_source:
|
|
source = self.stripe_customer_source
|
|
if self.stripe_customer:
|
|
customer = self.stripe_customer.stripe_customer_id
|
|
return {
|
|
'api_key': self.journal.stripe_account.secret_key,
|
|
'amount': self.stripe_amount,
|
|
'currency': self.currency.code,
|
|
'capture': bool(self.stripe_capture),
|
|
'description': self.description,
|
|
'customer': customer,
|
|
'source': source,
|
|
'idempotency_key': self.stripe_checkout_id,
|
|
}
|
|
|
|
@classmethod
|
|
@ModelView.button
|
|
def stripe_capture_(cls, payments):
|
|
"""Capture stripe payments
|
|
|
|
The transaction is committed after each payment capture.
|
|
"""
|
|
for payment in payments:
|
|
if (not payment.stripe_charge_id
|
|
or payment.stripe_captured
|
|
or payment.state != 'processing'):
|
|
continue
|
|
try:
|
|
charge = stripe.Charge.retrieve(
|
|
payment.stripe_charge_id,
|
|
api_key=payment.journal.stripe_account.secret_key)
|
|
charge.capture(**payment._capture_parameters())
|
|
except (stripe.error.RateLimitError,
|
|
stripe.error.APIConnectionError) as e:
|
|
logger.warning(str(e))
|
|
continue
|
|
except stripe.error.StripeError as e:
|
|
payment.stripe_error_message = unicode(e)
|
|
payment.save()
|
|
cls.fail([payment])
|
|
except Exception:
|
|
logger.error(
|
|
"Error when capturing payment %d", payment.id,
|
|
exc_info=True)
|
|
continue
|
|
else:
|
|
payment.stripe_charge_id = charge.id
|
|
payment.stripe_captured = charge.captured
|
|
payment.save()
|
|
if charge.status == 'succeeded' and charge.captured:
|
|
cls.succeed([payment])
|
|
elif charge.status == 'failed':
|
|
cls.fail([payment])
|
|
Transaction().commit()
|
|
|
|
def _capture_parameters(self):
|
|
return {
|
|
'amount': self.stripe_amount,
|
|
}
|
|
|
|
|
|
class Account(ModelSQL, ModelView):
|
|
"Stripe Account"
|
|
__name__ = 'account.payment.stripe.account'
|
|
|
|
name = fields.Char("Name", required=True)
|
|
secret_key = fields.Char("Secret Key", required=True)
|
|
publishable_key = fields.Char("Publishable Key", required=True)
|
|
webhook_identifier = fields.Char("Webhook Identifier", readonly=True)
|
|
webhook_endpoint = fields.Function(
|
|
fields.Char(
|
|
"Webhook Endpoint",
|
|
help="The URL to be called by Stripe."),
|
|
'on_change_with_webhook_endpoint')
|
|
webhook_signing_secret = fields.Char(
|
|
"Webhook Signing Secret",
|
|
states={
|
|
'invisible': ~Eval('webhook_identifier'),
|
|
},
|
|
depends=['webhook_identifier'],
|
|
help="The Stripe's signing secret of the webhook.")
|
|
zip_code = fields.Boolean("Zip Code", help="Verification on checkout")
|
|
|
|
@classmethod
|
|
def __setup__(cls):
|
|
super(Account, cls).__setup__()
|
|
cls._buttons.update({
|
|
'new_identifier': {
|
|
'icon': 'tryton-refresh',
|
|
},
|
|
})
|
|
if Pool().test:
|
|
cls.__rpc__['webhook'] = RPC(readonly=False, instantiate=0)
|
|
|
|
@classmethod
|
|
def default_zip_code(cls):
|
|
return True
|
|
|
|
@fields.depends('webhook_identifier')
|
|
def on_change_with_webhook_endpoint(self, name=None):
|
|
if not self.webhook_identifier:
|
|
return ''
|
|
# TODO add basic authentication support
|
|
url_part = {
|
|
'identifier': self.webhook_identifier,
|
|
'database_name': Transaction().database.name,
|
|
}
|
|
return 'https://' + HOSTNAME + (
|
|
urllib.quote(
|
|
'/%(database_name)s/account_payment_stripe'
|
|
'/webhook/%(identifier)s'
|
|
% url_part))
|
|
|
|
def webhook(self, payload):
|
|
"""This method handles stripe webhook callbacks
|
|
|
|
The return values are:
|
|
- None if the method could not handle payload['type']
|
|
- True if the payload has been handled
|
|
- False if the webhook should be retried by Stripe
|
|
"""
|
|
data = payload['data']
|
|
type_ = payload['type']
|
|
if type_ == 'charge.succeeded':
|
|
return self.webhook_charge_succeeded(data)
|
|
if type_ == 'charge.captured':
|
|
return self.webhook_charge_captured(data)
|
|
elif type_ == 'charge.failed':
|
|
return self.webhook_charge_failed(data)
|
|
elif type_ == 'charge.pending':
|
|
return self.webhook_charge_pending(data)
|
|
elif type_ == 'charge.refunded':
|
|
return self.webhook_charge_refunded(data)
|
|
elif type_ == 'charge.dispute.created':
|
|
return self.webhook_charge_dispute_created(data)
|
|
elif type_ == 'charge.dispute.closed':
|
|
return self.webhook_charge_dispute_closed(data)
|
|
elif type_ == 'source.chargeable':
|
|
return self.webhook_source_chargeable(data)
|
|
elif type_ == 'source.failed':
|
|
return self.webhook_source_failed(data)
|
|
elif type_ == 'source.canceled':
|
|
return self.webhook_source_canceled(data)
|
|
return None
|
|
|
|
def webhook_charge_succeeded(self, payload, _event='charge.succeeded'):
|
|
pool = Pool()
|
|
Payment = pool.get('account.payment')
|
|
|
|
charge = payload['object']
|
|
payments = Payment.search([
|
|
('stripe_charge_id', '=', charge['id']),
|
|
])
|
|
if not payments:
|
|
logger.error(": No payment '%s'", _event, charge['id'])
|
|
for payment in payments:
|
|
# TODO: remove when https://bugs.tryton.org/issue4080
|
|
with Transaction().set_context(company=payment.company.id):
|
|
if payment.state == 'succeeded':
|
|
Payment.fail([payment])
|
|
payment.stripe_captured = charge['captured']
|
|
payment.stripe_amount = (
|
|
charge['amount'] - charge['amount_refunded'])
|
|
payment.save()
|
|
if payment.amount:
|
|
if charge['status'] == 'succeeded' and charge['captured']:
|
|
Payment.succeed([payment])
|
|
else:
|
|
Payment.fail([payment])
|
|
return bool(payments)
|
|
|
|
def webhook_charge_captured(self, payload):
|
|
return self.webhook_charge_succeeded(payload, _event='charge.captured')
|
|
|
|
def webhook_charge_pending(self, payload):
|
|
return self.webhook_charge_succeeded(payload, _event='charge.pending')
|
|
|
|
def webhook_charge_refunded(self, payload):
|
|
return self.webhook_charge_succeeded(payload, _event='charge.pending')
|
|
|
|
def webhook_charge_failed(self, payload):
|
|
pool = Pool()
|
|
Payment = pool.get('account.payment')
|
|
|
|
charge = payload['object']
|
|
payments = Payment.search([
|
|
('stripe_charge_id', '=', charge['id']),
|
|
])
|
|
if not payments:
|
|
logger.error("charge.failed: No payment '%s'", charge['id'])
|
|
for payment in payments:
|
|
# TODO: remove when https://bugs.tryton.org/issue4080
|
|
with Transaction().set_context(company=payment.company.id):
|
|
payment.stripe_error_message = charge['failure_message']
|
|
payment.stripe_error_code = charge['failure_code']
|
|
payment.stripe_error_param = None
|
|
payment.save()
|
|
if charge['status'] == 'failed':
|
|
Payment.fail([payment])
|
|
return bool(payments)
|
|
|
|
def webhook_charge_dispute_created(self, payload):
|
|
pool = Pool()
|
|
Payment = pool.get('account.payment')
|
|
|
|
source = payload['object']
|
|
payments = Payment.search([
|
|
('stripe_charge_id', '=', source['charge']),
|
|
])
|
|
if not payments:
|
|
logger.error(
|
|
"charge.dispute.created: No payment '%s'", source['charge'])
|
|
for payment in payments:
|
|
# TODO: remove when https://bugs.tryton.org/issue4080
|
|
with Transaction().set_context(company=payment.company.id):
|
|
payment.stripe_dispute_reason = source['reason']
|
|
payment.stripe_dispute_status = source['status']
|
|
payment.save()
|
|
return bool(payments)
|
|
|
|
def webhook_charge_dispute_closed(self, payload):
|
|
pool = Pool()
|
|
Payment = pool.get('account.payment')
|
|
|
|
source = payload['object']
|
|
payments = Payment.search([
|
|
('stripe_charge_id', '=', source['charge']),
|
|
])
|
|
if not payments:
|
|
logger.error(
|
|
"charge.dispute.closed: No payment '%s'", source['charge'])
|
|
for payment in payments:
|
|
# TODO: remove when https://bugs.tryton.org/issue4080
|
|
with Transaction().set_context(company=payment.company.id):
|
|
payment.stripe_dispute_reason = source['reason']
|
|
payment.stripe_dispute_status = source['status']
|
|
payment.save()
|
|
if source['status'] == 'lost':
|
|
Payment.fail([payment])
|
|
if payment.stripe_amount != source['amount']:
|
|
payment.stripe_amount -= source['amount']
|
|
payment.save()
|
|
Payment.succeed([payment])
|
|
return bool(payments)
|
|
|
|
def webhook_source_chargeable(self, payload):
|
|
pool = Pool()
|
|
Payment = pool.get('account.payment')
|
|
|
|
source = payload['object']
|
|
payments = Payment.search([
|
|
('stripe_token', '=', source['id']),
|
|
])
|
|
if payments:
|
|
Payment.write(payments, {'stripe_chargeable': True})
|
|
return True
|
|
|
|
def webhook_source_failed(self, payload):
|
|
pool = Pool()
|
|
Payment = pool.get('account.payment')
|
|
|
|
source = payload['object']
|
|
payments = Payment.search([
|
|
('stripe_token', '=', source['id']),
|
|
])
|
|
for payment in payments:
|
|
# TODO: remove when https://bugs.tryton.org/issue4080
|
|
with Transaction().set_context(company=payment.company.id):
|
|
payment.stripe_error_message = source['failure_message']
|
|
payment.stripe_error_code = source['failure_code']
|
|
payment.stripe_error_param = None
|
|
payment.save()
|
|
Payment.fail([payment])
|
|
return True
|
|
|
|
def webhook_source_canceled(self, payload):
|
|
pool = Pool()
|
|
Payment = pool.get('account.payment')
|
|
|
|
source = payload['object']
|
|
payments = Payment.search([
|
|
('stripe_token', '=', source['id']),
|
|
])
|
|
for payment in payments:
|
|
# TODO: remove when https://bugs.tryton.org/issue4080
|
|
with Transaction().set_context(company=payment.company.id):
|
|
Payment.fail([payment])
|
|
return True
|
|
|
|
@classmethod
|
|
@ModelView.button
|
|
def new_identifier(cls, accounts):
|
|
for account in accounts:
|
|
account.webhook_identifier = uuid.uuid4().hex
|
|
cls.save(accounts)
|
|
|
|
|
|
class Customer(ModelSQL, ModelView):
|
|
"Stripe Customer"
|
|
__name__ = 'account.payment.stripe.customer'
|
|
_history = True
|
|
party = fields.Many2One('party.party', "Party", required=True, select=True,
|
|
states={
|
|
'readonly': Eval('stripe_customer_id') | Eval('stripe_token'),
|
|
},
|
|
depends=['stripe_customer_id', 'stripe_token'])
|
|
stripe_account = fields.Many2One(
|
|
'account.payment.stripe.account', "Account", required=True,
|
|
states={
|
|
'readonly': Eval('stripe_customer_id') | Eval('stripe_token'),
|
|
},
|
|
depends=['stripe_customer_id', 'stripe_token'])
|
|
stripe_checkout_needed = fields.Function(
|
|
fields.Boolean("Stripe Checkout Needed"), 'get_stripe_checkout_needed')
|
|
stripe_checkout_id = fields.Char("Stripe Checkout ID", readonly=True)
|
|
stripe_customer_id = fields.Char(
|
|
"Stripe Customer ID",
|
|
states={
|
|
'readonly': ((Eval('stripe_customer_id') | Eval('stripe_token'))
|
|
& (Eval('id', -1) >= 0)),
|
|
},
|
|
depends=['stripe_token'])
|
|
stripe_token = fields.Char("Stripe Token", readonly=True)
|
|
stripe_error_message = fields.Char("Stripe Error Message", readonly=True,
|
|
states={
|
|
'invisible': ~Eval('stripe_error_message'),
|
|
})
|
|
stripe_error_code = fields.Char("Stripe Error Code", readonly=True,
|
|
states={
|
|
'invisible': ~Eval('stripe_error_code'),
|
|
})
|
|
stripe_error_param = fields.Char("Stripe Error Param", readonly=True,
|
|
states={
|
|
'invisible': ~Eval('stripe_error_param'),
|
|
})
|
|
active = fields.Boolean("Active")
|
|
|
|
@classmethod
|
|
def __setup__(cls):
|
|
super(Customer, cls).__setup__()
|
|
cls._buttons.update({
|
|
'stripe_checkout': {
|
|
'invisible': ~Eval('stripe_checkout_needed', False),
|
|
},
|
|
})
|
|
|
|
@classmethod
|
|
def default_active(cls):
|
|
return True
|
|
|
|
def get_stripe_checkout_needed(self, name):
|
|
return not self.stripe_customer_id and not self.stripe_token
|
|
|
|
def get_rec_name(self, name):
|
|
name = super(Customer, self).get_rec_name(name)
|
|
return self.stripe_customer_id if self.stripe_customer_id else name
|
|
|
|
@classmethod
|
|
def delete(cls, customers):
|
|
cls.write(customers, {
|
|
'active': False,
|
|
})
|
|
|
|
@classmethod
|
|
def copy(cls, customers, default=None):
|
|
if default is None:
|
|
default = {}
|
|
else:
|
|
default = default.copy()
|
|
default['stripe_checkout_id'] = None
|
|
default['stripe_token'] = None
|
|
default['stripe_customer_id'] = None
|
|
return super(Customer, cls).copy(customers, default=default)
|
|
|
|
@classmethod
|
|
@ModelView.button_action('account_payment_stripe.wizard_checkout')
|
|
def stripe_checkout(cls, customers):
|
|
for customer in customers:
|
|
customer.stripe_checkout_id = uuid.uuid4().hex
|
|
cls.save(customers)
|
|
|
|
@classmethod
|
|
def stripe_create(cls, customers=None):
|
|
"""Create stripe customer
|
|
|
|
The transaction is committed after each customer.
|
|
"""
|
|
if not customers:
|
|
customers = cls.search([
|
|
('stripe_token', '!=', None),
|
|
['OR',
|
|
('stripe_customer_id', '=', None),
|
|
('stripe_customer_id', '=', ''),
|
|
],
|
|
])
|
|
for customer in customers:
|
|
assert not customer.stripe_customer_id
|
|
try:
|
|
cu = stripe.Customer.create(
|
|
api_key=customer.stripe_account.secret_key,
|
|
description=customer.rec_name,
|
|
email=customer.party.email,
|
|
source=customer.stripe_token)
|
|
except stripe.error.RateLimitError:
|
|
logger.warning("Rate limit error")
|
|
continue
|
|
except stripe.error.StripeError as e:
|
|
customer.stripe_error_message = unicode(e)
|
|
if isinstance(e, stripe.error.CardError):
|
|
customer.stripe_error_code = e.code
|
|
customer.stripe_error_param = e.param
|
|
customer.stripe_token = None
|
|
except Exception:
|
|
logger.error(
|
|
"Error when creating customer %d", customer.id,
|
|
exc_info=True)
|
|
continue
|
|
else:
|
|
customer.stripe_customer_id = cu.id
|
|
# TODO add card
|
|
customer.save()
|
|
Transaction().commit()
|
|
|
|
@classmethod
|
|
def stripe_delete(cls, customers=None):
|
|
"""Delete customer
|
|
|
|
The transaction is committed after each customer.
|
|
"""
|
|
if not customers:
|
|
customers = cls.search([
|
|
('active', '=', False),
|
|
('stripe_customer_id', '!=', None),
|
|
])
|
|
for customer in customers:
|
|
assert not customer.active
|
|
try:
|
|
cu = stripe.Customer.retrieve(
|
|
api_key=customer.stripe_account.secret_key,
|
|
id=customer.stripe_customer_id)
|
|
cu.delete()
|
|
except stripe.error.RateLimitError:
|
|
logger.warning("Rate limit error")
|
|
continue
|
|
except Exception:
|
|
logger.error(
|
|
"Error when deleting customer %d", customer.id,
|
|
exc_info=True)
|
|
continue
|
|
customer.stripe_token = None
|
|
customer.stripe_customer_id = None
|
|
customer.save()
|
|
Transaction().commit()
|
|
|
|
def retrieve(self):
|
|
if not self.stripe_customer_id:
|
|
return
|
|
try:
|
|
return stripe.Customer.retrieve(
|
|
api_key=self.stripe_account.secret_key,
|
|
id=self.stripe_customer_id)
|
|
except (stripe.error.RateLimitError,
|
|
stripe.error.APIConnectionError) as e:
|
|
logger.warning(str(e))
|
|
|
|
def sources(self):
|
|
sources = []
|
|
customer = self.retrieve()
|
|
if customer:
|
|
for source in customer.sources:
|
|
name = source.id
|
|
if source.object == 'card':
|
|
name = self._source_name(source)
|
|
elif source.object == 'source':
|
|
if source.usage != 'reusable':
|
|
continue
|
|
name = self._source_name(source)
|
|
else:
|
|
continue
|
|
sources.append((source.id, name))
|
|
return sources
|
|
|
|
def _source_name(cls, source):
|
|
def card_name(card):
|
|
name = card.brand
|
|
if card.last4 or card.dynamic_last4:
|
|
name += ' ****' + (card.last4 or card.dynamic_last4)
|
|
if card.exp_month and card.exp_year:
|
|
name += ' %s/%s' % (card.exp_month, card.exp_year)
|
|
return name
|
|
|
|
name = source.id
|
|
if source.object == 'card':
|
|
name = card_name(source)
|
|
elif source.object == 'source':
|
|
if source.type == 'card':
|
|
name = card_name(source.card)
|
|
elif source.type == 'sepa_debit':
|
|
name = '****' + source.sepa_debit.last4
|
|
return name
|
|
|
|
|
|
class Checkout(Wizard):
|
|
"Stripe Checkout"
|
|
__name__ = 'account.payment.stripe.checkout'
|
|
start_state = 'checkout'
|
|
checkout = StateAction('account_payment_stripe.url_checkout')
|
|
|
|
def do_checkout(self, action):
|
|
pool = Pool()
|
|
Payment = pool.get('account.payment')
|
|
Customer = pool.get('account.payment.stripe.customer')
|
|
context = Transaction().context
|
|
active_model = context['active_model']
|
|
active_id = context['active_id']
|
|
if active_model == Payment.__name__:
|
|
Model = Payment
|
|
elif active_model == Customer.__name__:
|
|
Model = Customer
|
|
else:
|
|
raise ValueError("Invalid active_model: %s" % active_model)
|
|
record = Model(active_id)
|
|
database = Transaction().database.name
|
|
action['url'] = action['url'] % {
|
|
'hostname': HOSTNAME,
|
|
'database': database,
|
|
'model': active_model,
|
|
'id': record.stripe_checkout_id,
|
|
}
|
|
return action, {}
|
|
|
|
|
|
class CheckoutPage(Report):
|
|
"Stripe Checkout"
|
|
__name__ = 'account.payment.stripe.checkout'
|