# -*- coding: utf-8 -*-
'''
Payment Gateway Transaction
:copyright: (c) 2013-2014 by Openlabs Technologies & Consulting (P) Ltd.
:license: BSD, see LICENSE for more details
'''
from uuid import uuid4
from decimal import Decimal
from datetime import datetime
import yaml
from trytond.pool import Pool, PoolMeta
from trytond.pyson import Eval, If, Bool
from trytond.wizard import Wizard, StateView, StateTransition, \
Button
from trytond.transaction import Transaction
from trytond.exceptions import UserError
from trytond.model import ModelSQL, ModelView, Workflow, fields
__all__ = [
'PaymentGateway', 'PaymentGatewaySelf', 'PaymentTransaction',
'TransactionLog', 'PaymentProfile', 'AddPaymentProfileView',
'AddPaymentProfile', 'BaseCreditCardViewMixin', 'Party',
'TransactionUseCardView', 'TransactionUseCard',
]
__metaclass__ = PoolMeta
READONLY_IF_NOT_DRAFT = {'readonly': Eval('state') != 'draft'}
[docs]class PaymentGateway(ModelSQL, ModelView):
"""
Payment Gateway
Payment gateway record is a specific configuration for a `provider`
"""
__name__ = 'payment_gateway.gateway'
name = fields.Char('Name', required=True, select=True)
journal = fields.Many2One('account.journal', 'Journal', required=True)
provider = fields.Selection('get_providers', 'Provider', required=True)
method = fields.Selection(
'get_methods', 'Method', required=True,
selection_change_with=['provider']
)
test = fields.Boolean('Test Account')
@staticmethod
def default_provider():
return 'self'
@classmethod
[docs] def get_providers(cls):
"""
Downstream modules can add to the list
"""
return []
def get_methods(self):
"""
Downstream modules can override the method and add entries to this
"""
return []
class PaymentGatewaySelf:
"COD, Cheque and Bank Transfer Implementation"
__name__ = 'payment_gateway.gateway'
@classmethod
def get_providers(cls, values=None):
"""
Downstream modules can add to the list
"""
rv = super(PaymentGatewaySelf, cls).get_providers()
self_record = ('self', 'Self')
if self_record not in rv:
rv.append(self_record)
return rv
def get_methods(self):
if self.provider == 'self':
return [
('cod', 'Cash On Delivery'),
('cheque', 'Cheque'),
]
return super(PaymentGatewaySelf, self).get_methods()
[docs]class PaymentTransaction(Workflow, ModelSQL, ModelView):
'''Gateway Transaction'''
__name__ = 'payment_gateway.transaction'
uuid = fields.Char('UUID', required=True, readonly=True)
provider_reference = fields.Char(
'Provider Reference', readonly=True, states={
'invisible': Eval('state') == 'draft'
}, depends=['state']
)
date = fields.Date(
'Date', required=True,
states=READONLY_IF_NOT_DRAFT,
depends=['state']
)
company = fields.Many2One(
'company.company', 'Company', required=True,
states=READONLY_IF_NOT_DRAFT, select=True,
domain=[
('id', If(Eval('context', {}).contains('company'), '=', '!='),
Eval('context', {}).get('company', -1)),
], depends=['state']
)
party = fields.Many2One(
'party.party', 'Party', required=True,
on_change=['party'], ondelete='RESTRICT',
depends=['state'], states=READONLY_IF_NOT_DRAFT,
)
payment_profile = fields.Many2One(
'party.payment_profile', 'Payment Profile',
domain=[
('party', '=', Eval('party')),
('gateway', '=', Eval('gateway')),
],
on_change=['payment_profile'],
ondelete='RESTRICT',
depends=['state', 'party', 'gateway'],
states=READONLY_IF_NOT_DRAFT,
)
address = fields.Many2One(
'party.address', 'Address', required=True,
domain=[('party', '=', Eval('party'))],
depends=['state', 'party'], states=READONLY_IF_NOT_DRAFT,
ondelete='RESTRICT'
)
amount = fields.Numeric(
'Amount', digits=(16, Eval('currency_digits', 2)),
required=True, depends=['state', 'currency_digits'],
states=READONLY_IF_NOT_DRAFT,
)
currency = fields.Many2One(
'currency.currency', 'Currency',
required=True,
depends=['state'], states=READONLY_IF_NOT_DRAFT,
)
currency_digits = fields.Function(
fields.Integer(
'Currency Digits', on_change_with=['currency']
), 'on_change_with_currency_digits'
)
gateway = fields.Many2One(
'payment_gateway.gateway', 'Gateway', required=True,
states=READONLY_IF_NOT_DRAFT, depends=['state'],
ondelete='RESTRICT', on_change=['gateway']
)
provider = fields.Function(
fields.Char('Provider'), 'get_provider'
)
method = fields.Function(
fields.Char('Payment Gateway Method'), 'get_method'
)
move = fields.Many2One(
'account.move', 'Move', readonly=True, ondelete='RESTRICT'
)
logs = fields.One2Many(
'payment_gateway.transaction.log', 'transaction',
'Logs', depends=['state'], states={
'readonly': Eval('state') in ('done', 'cancel')
}
)
state = fields.Selection([
('draft', 'Draft'),
('in-progress', 'In Progress'),
('failed', 'Failed'),
('authorized', 'Authorized'),
('completed', 'Completed'),
('posted', 'Posted'),
('cancel', 'Canceled'),
], 'State', readonly=True)
def get_rec_name(self, name=None):
"""
Return the most meaningful rec_name
"""
if self.state == 'draft':
return self.uuid
if not self.payment_profile:
return '%s/%s' % (self.gateway.name, self.provider_reference)
return '%s/%s' % (
self.payment_profile.rec_name, self.provider_reference
)
@classmethod
def __setup__(cls):
super(PaymentTransaction, cls).__setup__()
cls._order.insert(0, ('date', 'DESC'))
cls._error_messages.update({
'feature_not_available': 'The feature %s is not avaialable '
'for provider %s',
})
cls._transitions |= set((
('draft', 'in-progress'),
('draft', 'authorized'),
('in-progress', 'failed'),
('in-progress', 'authorized'),
('in-progress', 'completed'),
('in-progress', 'cancel'),
('failed', 'in-progress'),
('authorized', 'cancel'),
('authorized', 'completed'),
('completed', 'posted'),
))
cls._buttons.update({
'cancel': {
'invisible': ~Eval('state').in_(['in-progress', 'authorized']),
},
'authorize': {
'invisible': ~(
(Eval('state') == 'draft') &
Eval('payment_profile', True)
),
},
'settle': {
'invisible': ~(Eval('state') == 'authorized'),
},
'retry': {
'invisible': ~(Eval('state') == 'failed'),
},
'capture': {
'invisible': ~(
(Eval('state') == 'draft') & Eval('payment_profile', True)
),
},
'post': {
'invisible': ~(Eval('state') == 'completed'),
},
'use_card': {
'invisible': ~(
(Eval('state') == 'draft') &
~Bool(Eval('payment_profile')) &
(Eval('method') == 'credit_card')
),
},
'update_status': {
'invisible': ~Eval('state').in_(['in-progress'])
}
})
@staticmethod
def default_uuid():
return unicode(uuid4())
@staticmethod
def default_date():
Date = Pool().get('ir.date')
return Date.today()
@staticmethod
def default_company():
return Transaction().context.get('company')
@staticmethod
def default_currency():
Company = Pool().get('company.company')
if Transaction().context.get('company'):
company = Company(Transaction().context['company'])
return company.currency.id
@staticmethod
def default_state():
return 'draft'
@classmethod
def copy(cls, records, default=None):
if default is None:
default = {}
default.update({
'uuid': cls.default_uuid(),
'provider_reference': None,
'move': None,
'logs': None,
})
return super(PaymentTransaction, cls).copy(records, default)
def on_change_with_currency_digits(self, name=None):
if self.currency:
return self.currency.digits
return 2
def on_change_party(self):
res = {
'address': None,
}
if self.party:
try:
address = self.party.address_get(type='invoice')
except AttributeError:
# account_invoice module is not installed
pass
else:
res['address'] = address.id
res['address.rec_name'] = address.rec_name
return res
def on_change_payment_profile(self):
res = {}
if self.payment_profile:
res['address'] = self.payment_profile.address.id
res['address.rec_name'] = self.payment_profile.address.rec_name
return res
def get_provider(self):
"""
Return the gateway provider based on the gateway
"""
return self.gateway.provider
def get_method(self, name=None):
"""
Return the method based on the gateway
"""
return self.gateway.method
def on_change_gateway(self):
if self.gateway:
return {
'provider': self.gateway.provider,
'method': self.gateway.method,
}
def on_change_with_provider(self):
return self.get_provider()
@classmethod
@ModelView.button
@Workflow.transition('cancel')
def cancel(cls, transactions):
for transaction in transactions:
method_name = 'cancel_%s' % transaction.gateway.provider
if not hasattr(transaction, method_name):
cls.raise_user_error(
'feature_not_available',
('cancellation', transaction.gateway.provider),
)
getattr(transaction, method_name)()
@classmethod
@ModelView.button
@Workflow.transition('in-progress')
def authorize(cls, transactions):
for transaction in transactions:
method_name = 'authorize_%s' % transaction.gateway.provider
if not hasattr(transaction, method_name):
cls.raise_user_error(
'feature_not_available',
('authorization', transaction.gateway.provider),
)
getattr(transaction, method_name)()
@classmethod
@ModelView.button
@Workflow.transition('in-progress')
def retry(cls, transactions):
for transaction in transactions:
method_name = 'retry_%s' % transaction.gateway.provider
if not hasattr(transaction, method_name):
cls.raise_user_error(
'feature_not_available',
('retry', transaction.gateway.provider)
)
getattr(transaction, method_name)()
@classmethod
@ModelView.button
@Workflow.transition('completed')
def settle(cls, transactions):
for transaction in transactions:
method_name = 'settle_%s' % transaction.gateway.provider
if not hasattr(transaction, method_name):
cls.raise_user_error(
'feature_not_available',
('settle', transaction.gateway.provider)
)
getattr(transaction, method_name)()
@classmethod
@ModelView.button
@Workflow.transition('in-progress')
def capture(cls, transactions):
for transaction in transactions:
method_name = 'capture_%s' % transaction.gateway.provider
if not hasattr(transaction, method_name):
cls.raise_user_error(
'feature_not_available',
('capture', transaction.gateway.provider)
)
getattr(transaction, method_name)()
@classmethod
@ModelView.button
@Workflow.transition('posted')
def post(cls, transactions):
"""
Complete the transactions by creating account moves and post them.
This method is likely to end in failure if the initial configuration
of the journal and fiscal periods have not been done. You could
alternatively use the safe_post instance method to try to post the
record, but ignore the error silently.
"""
for transaction in transactions:
if not transaction.move:
transaction.create_move()
@classmethod
@ModelView.button
def update_status(cls, transactions):
"""
Check the status with the payment gateway provider and update the
status of this transaction accordingly.
"""
for transaction in transactions:
method_name = 'update_%s' % transaction.gateway.provider
if not hasattr(transaction, method_name):
cls.raise_user_error(
'feature_not_available'
('update status', transaction.gateway.provider)
)
getattr(transaction, method_name)()
[docs] def safe_post(self):
"""
If the initial configuration including defining a period and
journal is not completed, marking as done could fail. In
such cases, just mark as in-progress and let the user to
manually mark as done.
Failing would otherwise rollback transaction but its
not possible to rollback the payment
"""
try:
self.post([self])
except UserError, exc:
log = 'Could not mark as done\n'
log += unicode(exc)
TransactionLog.create([{
'transaction': self,
'log': log
}])
def create_move(self, date=None):
"""
Create the account move for the payment
:param date: Optional date for the account move
:return: Active record of the created move
"""
Currency = Pool().get('currency.currency')
Period = Pool().get('account.period')
Move = Pool().get('account.move')
journal = self.gateway.journal
date = date or self.date
if not journal.debit_account:
self.raise_user_error('missing_debit_account', (journal.rec_name,))
period_id = Period.find(self.company.id, date=date)
amount_second_currency = second_currency = None
amount = self.amount
if self.currency != self.company.currency:
amount = Currency.compute(
self.currency, self.amount, self.company.currency
)
amount_second_currency = self.amount
second_currency = self.currency
lines = [{
'description': self.rec_name,
'account': self.party.account_receivable.id,
'party': self.party.id,
'debit': Decimal('0.0'),
'credit': amount,
'amount_second_currency': amount_second_currency,
'second_currency': second_currency,
}, {
'description': self.rec_name,
'account': journal.debit_account.id,
'party': self.party.id,
'debit': amount,
'credit': Decimal('0.0'),
'amount_second_currency': amount_second_currency,
'second_currency': second_currency,
}]
move, = Move.create([{
'journal': journal.id,
'period': period_id,
'date': date,
'lines': [('create', lines)],
}])
Move.post([move])
# Set the move as the move of this transaction
self.move = move
self.save()
return move
@classmethod
@ModelView.button_action('payment_gateway.wizard_transaction_use_card')
def use_card(cls, transactions):
pass
[docs]class TransactionLog(ModelSQL, ModelView):
"Transaction Log"
__name__ = 'payment_gateway.transaction.log'
timestamp = fields.DateTime('Event Timestamp', readonly=True)
transaction = fields.Many2One(
'payment_gateway.transaction', 'Transaction',
required=True, readonly=True,
)
is_system_generated = fields.Boolean('Is System Generated')
log = fields.Text(
'Log', required=True, depends=['is_system_generated'],
states={'readonly': Eval('is_system_generated', True)}
)
@staticmethod
def default_is_system_generated():
return False
@staticmethod
def default_timestamp():
return datetime.utcnow()
@classmethod
[docs] def serialize_and_create(cls, transaction, data):
"""
Serialise a given object and then save it as a log
:param transaction: The transaction against which the log needs to be
saved
:param data: The data object that needs to be saved
"""
return cls.create([{
'transaction': transaction,
'log': yaml.dump(data, default_flow_style=False),
}])[0]
class BaseCreditCardViewMixin(object):
"""
A Reusable Mixin class to get Credit Card view
"""
owner = fields.Char('Card Owner', required=True)
number = fields.Char('Card Number', required=True)
expiry_month = fields.Selection([
('01', '01-January'),
('02', '02-February'),
('03', '03-March'),
('04', '04-April'),
('05', '05-May'),
('06', '06-June'),
('07', '07-July'),
('08', '08-August'),
('09', '09-September'),
('10', '10-October'),
('11', '11-November'),
('12', '12-December'),
], 'Expiry Month', required=True)
expiry_year = fields.Integer('Expiry Year', required=True)
csc = fields.Integer('Card Security Code (CVV/CVD)', help='CVD/CVV/CVN')
@staticmethod
def default_owner():
"""
If a party is provided in the context fill up this instantly
"""
Party = Pool().get('party.party')
party_id = Transaction().context.get('party')
if party_id:
return Party(party_id).name
class Party:
__name__ = 'party.party'
payment_profiles = fields.One2Many(
'party.payment_profile', 'party', 'Payment Profiles'
)
@classmethod
def __setup__(cls):
super(Party, cls).__setup__()
cls._buttons.update({
'add_payment_profile': {}
})
@classmethod
@ModelView.button_action('payment_gateway.wizard_add_payment_profile')
def add_payment_profile(cls, parties):
pass
[docs]class PaymentProfile(ModelSQL, ModelView):
"""
Secure Payment Profile
Several payment gateway service providers offer a secure way to store
confidential customer credit card insformation on their server.
Transactions can then be processed against these profiles without the need
to recollect payment information from the customer, and without the need
to store confidential credit card information in Tryton.
This model represents a profile thus stored with any of the third party
providers.
"""
__name__ = 'party.payment_profile'
party = fields.Many2One('party.party', 'Party', required=True)
address = fields.Many2One(
'party.address', 'Address', required=True,
domain=[('party', '=', Eval('party'))], depends=['party']
)
gateway = fields.Many2One(
'payment_gateway.gateway', 'Gateway', required=True,
ondelete='RESTRICT', readonly=True,
)
provider_reference = fields.Char(
'Provider Reference', required=True, readonly=True
)
last_4_digits = fields.Char('Last 4 digits', readonly=True)
expiry_month = fields.Selection([
('01', '01-January'),
('02', '02-February'),
('03', '03-March'),
('04', '04-April'),
('05', '05-May'),
('06', '06-June'),
('07', '07-July'),
('08', '08-August'),
('09', '09-September'),
('10', '10-October'),
('11', '11-November'),
('12', '12-December'),
], 'Expiry Month', required=True)
expiry_year = fields.Integer('Expiry Year', required=True)
def get_rec_name(self, name=None):
if self.last_4_digits:
return self.gateway.name + ('xxxx ' * 3) + self.last_4_digits
return 'Incomplete Card'
class AddPaymentProfileView(BaseCreditCardViewMixin, ModelView):
"""
View for adding a payment profile
"""
__name__ = 'party.payment_profile.add_view'
party = fields.Many2One(
'party.party', 'Party', required=True,
states={'invisible': Eval('party_invisible', False)}
)
address = fields.Many2One(
'party.address', 'Address', required=True,
domain=[('party', '=', Eval('party'))],
depends=['party']
)
provider = fields.Selection('get_providers', 'Provider', required=True)
gateway = fields.Many2One(
'payment_gateway.gateway', 'Gateway', required=True,
domain=[('provider', '=', Eval('provider'))],
depends=['provider']
)
@classmethod
def get_providers(cls):
"""
Return the list of providers who support credit card profiles.
"""
return []
[docs]class AddPaymentProfile(Wizard):
"""
Add a payment profile
"""
__name__ = 'party.party.payment_profile.add'
start_state = 'card_info'
card_info = StateView(
'party.payment_profile.add_view',
'payment_gateway.payment_profile_add_view_form',
[
Button('Cancel', 'end', 'tryton-cancel'),
Button('Add', 'add', 'tryton-ok', default=True)
]
)
add = StateTransition()
def default_card_info(self, fields):
Party = Pool().get('party.party')
party = Party(Transaction().context.get('active_id'))
res = {'party': party.id}
try:
address = self.party.address_get(type='invoice')
except AttributeError:
# account_invoice module is not installed
pass
else:
res['address'] = address.id
return res
[docs] def create_profile(self, provider_reference):
"""
A helper function that creates a profile from the card information
that was entered into the View of the wizard. This helper could be
called by the method which implement the API and wants to create the
profile with provider_reference.
:param provider_reference: Value for the provider_reference field.
:return: Active record of the created profile
"""
Profile = Pool().get('party.payment_profile')
profile = Profile(
party=self.card_info.party.id,
address=self.card_info.address.id,
gateway=self.card_info.gateway.id,
last_4_digits=self.card_info.number[-4:],
expiry_month=self.card_info.expiry_month,
expiry_year=self.card_info.expiry_year,
provider_reference=provider_reference,
)
profile.save()
return profile
def transition_add(self):
"""
Downstream module implementing the functionality should check for the
provider type and handle it accordingly.
To handle, name your method transition_add_<provider_name>. For example
if your proivder internal name is paypal, then the method name
should be `transition_add_paypal`
Once validated, the payment profile must be created by the method.
A helper function is provided in this class itself which fills in most
of the information automatically and the only additional information
required is the reference from the payment provider.
"""
method_name = 'transition_add_%s' % self.card_info.provider
return getattr(self, method_name)()
class TransactionUseCardView(BaseCreditCardViewMixin, ModelView):
"""
View for putting in credit card information
"""
__name__ = 'payment_gateway.transaction.use_card.view'
class TransactionUseCard(Wizard):
"""
Add a payment profile
"""
__name__ = 'payment_gateway.transaction.use_card'
start_state = 'card_info'
card_info = StateView(
'payment_gateway.transaction.use_card.view',
'payment_gateway.transaction_use_card_view_form',
[
Button('Cancel', 'end', 'tryton-cancel'),
Button('Authorize', 'authorize', 'tryton-go-next'),
Button('Capture', 'capture', 'tryton-ok', default=True),
]
)
capture = StateTransition()
authorize = StateTransition()
def transition_capture(self):
"""
Delegates to the capture method for the provider in
payment_gateway.transaction
"""
PaymentTransaction = Pool().get('payment_gateway.transaction')
transaction = PaymentTransaction(
Transaction().context.get('active_id')
)
getattr(transaction, 'capture_%s' % transaction.gateway.provider)(
self.card_info
)
return 'end'
def transition_authorize(self):
"""
Delegates to the authorize method for the provider in
payment_gateway.transaction
"""
PaymentTransaction = Pool().get('payment_gateway.transaction')
transaction = PaymentTransaction(
Transaction().context.get('active_id')
)
getattr(transaction, 'authorize_%s' % transaction.gateway.provider)(
self.card_info
)
return 'end'