import random import string from datetime import datetime as dt import hashlib import time from decimal import Decimal from django.utils.six.moves.urllib import parse as urllib from quixote import (redirect, get_publisher, get_request, get_session, get_response) from quixote.directory import Directory from quixote.html import TemplateIO, htmltext if not set: from sets import Set as set eopayment = None try: import eopayment except ImportError: pass from wcs.qommon import _, N_ from wcs.qommon import force_str from wcs.qommon import errors, get_logger, get_cfg, emails from wcs.qommon.storage import StorableObject from wcs.qommon.form import htmltext, StringWidget, TextWidget, SingleSelectWidget, \ WidgetDict from wcs.qommon.misc import simplify from wcs.formdef import FormDef from wcs.formdata import Evolution from wcs.workflows import WorkflowStatusItem, register_item_class, template_on_formdata def is_payment_supported(): if not eopayment: return False return get_cfg('aq-permissions', {}).get('payments', None) is not None class Regie(StorableObject): _names = 'regies' label = None description = None service = None service_options = None def get_payment_object(self): return eopayment.Payment(kind=self.service, options=self.service_options) class Invoice(StorableObject): _names = 'invoices' _hashed_indexes = ['user_id', 'regie_id'] _indexes = ['external_id'] user_id = None regie_id = None formdef_id = None formdata_id = None subject = None details = None amount = None date = None paid = False paid_date = None canceled = False canceled_date = None canceled_reason = None next_status = None external_id = None request_kwargs = {} def __init__(self, id=None, regie_id=None, formdef_id=None): self.id = id self.regie_id = regie_id self.formdef_id = formdef_id if get_publisher() and not self.id: self.id = self.get_new_id() def get_user(self): if self.user_id: return get_publisher().user_class.get(self.user_id, ignore_errors=True) return None @property def username(self): user = self.get_user() return user.name if user else '' def get_new_id(self, create=False): # format : date-regie-formdef-alea-check r = random.SystemRandom() self.fresh = True while True: id = '-'.join([ dt.now().strftime('%Y%m%d'), 'r%s' % (self.regie_id or 'x'), 'f%s' % (self.formdef_id or 'x'), ''.join([r.choice(string.digits) for x in range(5)]) ]) crc = '%0.2d' % (hashlib.md5(id.encode('utf-8')).digest()[0] % 100) id = id + '-' + crc if not self.has_key(id): return id def store(self, *args, **kwargs): if getattr(self, 'fresh', None) is True: del self.fresh notify_new_invoice(self) return super(Invoice, self).store(*args, **kwargs) def check_crc(cls, id): try: return int(id[-2:]) == (hashlib.md5(id[:-3].encode('utf-8')).digest()[0] % 100) except: return False check_crc = classmethod(check_crc) def pay(self): self.paid = True self.paid_date = dt.now() self.store() get_logger().info(_('invoice %s paid'), self.id) notify_paid_invoice(self) def unpay(self): self.paid = False self.paid_date = None self.store() get_logger().info(_('invoice %s unpaid'), self.id) def cancel(self, reason=None): self.canceled = True self.canceled_date = dt.now() if reason: self.canceled_reason = reason self.store() notify_canceled_invoice(self) get_logger().info(_('invoice %s canceled'), self.id) def payment_url(self): base_url = get_publisher().get_frontoffice_url() return '%s/invoices/%s' % (base_url, self.id) INVOICE_EVO_VIEW = { 'create': N_('Create Invoice %(id)s: %(subject)s - %(amount)s €'), 'pay': N_('Invoice %(id)s is paid with transaction number %(transaction_order_id)s'), 'cancel': N_('Cancel Invoice %(id)s'), 'try': N_('Try paying invoice %(id)s with transaction number %(transaction_order_id)s'), } class InvoiceEvolutionPart: action = None id = None subject = None amount = None transaction = None def __init__(self, action, invoice, transaction=None): self.action = action self.id = invoice.id self.subject = invoice.subject self.amount = invoice.amount self.transaction = transaction def view(self): vars = { 'url': '%s/invoices/%s' % (get_publisher().get_frontoffice_url(), self.id), 'id': self.id, 'subject': self.subject, 'amount': self.amount, } if not self.action: return '' if self.transaction: vars['transaction_order_id'] = self.transaction.order_id return htmltext('

' % self.action + \ _(INVOICE_EVO_VIEW[self.action]) % vars + '

') class Transaction(StorableObject): _names = 'transactions' _hashed_indexes = ['invoice_ids'] _indexes = ['order_id'] invoice_ids = None order_id = None start = None end = None bank_data = None def __init__(self, *args, **kwargs): self.invoice_ids = list() StorableObject.__init__(self, *args, **kwargs) def get_new_id(cls, create=False): r = random.SystemRandom() while True: id = ''.join([r.choice(string.digits) for x in range(16)]) if not cls.has_key(id): return id get_new_id = classmethod(get_new_id) class PaymentWorkflowStatusItem(WorkflowStatusItem): description = N_('Payment Creation') key = 'payment' endpoint = False category = 'interaction' support_substitution_variables = True subject = None details = None amount = None regie_id = None next_status = None request_kwargs = {} def is_available(self, workflow=None): return is_payment_supported() is_available = classmethod(is_available) def render_as_line(self): if self.regie_id: try: return _('Payable to %s' % Regie.get(self.regie_id).label) except KeyError: return _('Payable (not completed)') else: return _('Payable (not completed)') def get_parameters(self): return ('subject', 'details', 'amount', 'regie_id', 'next_status', 'request_kwargs') def add_parameters_widgets(self, form, parameters, prefix='', formdef=None): if 'subject' in parameters: form.add(StringWidget, '%ssubject' % prefix, title=_('Subject'), value=self.subject, size=40) if 'details' in parameters: form.add(TextWidget, '%sdetails' % prefix, title=_('Details'), value=self.details, cols=80, rows=10) if 'amount' in parameters: form.add(StringWidget, '%samount' % prefix, title=_('Amount'), value=self.amount) if 'regie_id' in parameters: form.add(SingleSelectWidget, '%sregie_id' % prefix, title=_('Regie'), value=self.regie_id, options = [(None, '---')] + [(x.id, x.label) for x in Regie.select()]) if 'next_status' in parameters: form.add(SingleSelectWidget, '%snext_status' % prefix, title=_('Status after validation'), value = self.next_status, hint=_('Used only if the current status of the form does not contain any "Payment Validation" item'), options = [(None, '---')] + [(x.id, x.name) for x in self.parent.parent.possible_status]) if 'request_kwargs' in parameters: keys = ['name', 'email', 'address', 'phone', 'info1', 'info2', 'info3'] hint = '' hint +=_('If the value starts by = it will be ' 'interpreted as a Python expression.') hint += ' ' hint += _('Standard keys are: %s.') % (', '.join(keys)) form.add(WidgetDict, 'request_kwargs', title=_('Parameters for the payment system'), hint=hint, value = self.request_kwargs) def perform(self, formdata): invoice = Invoice(regie_id=self.regie_id, formdef_id=formdata.formdef.id) invoice.user_id = formdata.user_id invoice.formdata_id = formdata.id invoice.next_status = self.next_status if self.subject: invoice.subject = template_on_formdata(formdata, self.compute(self.subject)) else: invoice.subject = _('%(form_name)s #%(formdata_id)s') % { 'form_name': formdata.formdef.name, 'formdata_id': formdata.id } invoice.details = template_on_formdata(formdata, self.compute(self.details)) invoice.amount = Decimal(self.compute(self.amount)) invoice.date = dt.now() invoice.request_kwargs = {} if self.request_kwargs: for key, value in self.request_kwargs.items(): invoice.request_kwargs[key] = self.compute(value) invoice.store() # add a message in formdata.evolution evo = Evolution() evo.time = time.localtime() evo.status = formdata.status evo.add_part(InvoiceEvolutionPart('create', invoice)) if not formdata.evolution: formdata.evolution = [] formdata.evolution.append(evo) formdata.store() # redirect the user to "my invoices" return get_publisher().get_frontoffice_url() + '/myspace/invoices/' register_item_class(PaymentWorkflowStatusItem) class PaymentCancelWorkflowStatusItem(WorkflowStatusItem): description = N_('Payment Cancel') key = 'payment-cancel' endpoint = False category = 'interaction' reason = None regie_id = None def is_available(self, workflow=None): return is_payment_supported() is_available = classmethod(is_available) def render_as_line(self): if self.regie_id: if self.regie_id == '_all': return _('Cancel all Payments') else: try: return _('Cancel Payments for %s' % Regie.get(self.regie_id).label) except KeyError: return _('Cancel Payments (non completed)') else: return _('Cancel Payments (non completed)') def get_parameters(self): return ('reason', 'regie_id') def add_parameters_widgets(self, form, parameters, prefix='', formdef=None): if 'reason' in parameters: form.add(StringWidget, '%sreason' % prefix, title=_('Reason'), value=self.reason, size=40) if 'regie_id' in parameters: form.add(SingleSelectWidget, '%sregie_id' % prefix, title=_('Regie'), value=self.regie_id, options = [(None, '---'), ('_all', _('All Regies'))] + \ [(x.id, x.label) for x in Regie.select()]) def perform(self, formdata): invoices_id = [] # get all invoices for the formdata and the selected regie for evo in [evo for evo in formdata.evolution if evo.parts]: for part in [part for part in evo.parts if isinstance(part, InvoiceEvolutionPart)]: if part.action == 'create': invoices_id.append(part.id) elif part.id in invoices_id: invoices_id.remove(part.id) invoices = [Invoice.get(id) for id in invoices_id] # select invoices for the selected regie (if not "all regies") if self.regie_id != '_all': invoices = [i for i in invoices if i.regie_id == self.regie_id] # security filter: check user invoices = [i for i in invoices if i.user_id == formdata.user_id] # security filter: check formdata & formdef invoices = [i for i in invoices if (i.formdata_id == formdata.id) \ and (i.formdef_id == formdata.formdef.id)] evo = Evolution() evo.time = time.localtime() for invoice in invoices: if not (invoice.paid or invoice.canceled): invoice.cancel(self.reason) evo.add_part(InvoiceEvolutionPart('cancel', invoice)) if not formdata.evolution: formdata.evolution = [] formdata.evolution.append(evo) formdata.store() return get_publisher().get_frontoffice_url() + '/myspace/invoices/' register_item_class(PaymentCancelWorkflowStatusItem) def request_payment(invoice_ids, url, add_regie=True): for invoice_id in invoice_ids: if not Invoice.check_crc(invoice_id): raise errors.QueryError() invoices = [ Invoice.get(invoice_id) for invoice_id in invoice_ids ] invoices = [ i for i in invoices if not (i.paid or i.canceled) ] regie_ids = set([invoice.regie_id for invoice in invoices]) # Do not apply if more than one regie is used or no invoice is not paid or canceled if len(invoices) == 0 or len(regie_ids) != 1: url = get_publisher().get_frontoffice_url() if get_session().user: # FIXME: add error messages url += '/myspace/invoices/' return redirect(url) if add_regie: url = '%s%s' % (url, list(regie_ids)[0]) transaction = Transaction() transaction.store() transaction.invoice_ids = invoice_ids transaction.start = dt.now() amount = Decimal(0) for invoice in invoices: amount += Decimal(invoice.amount) regie = Regie.get(invoice.regie_id) payment = regie.get_payment_object() # initialize request_kwargs using informations from the first invoice # and update using current user informations request_kwargs = getattr(invoices[0], 'request_kwargs', {}) request = get_request() if request.user and request.user.email: request_kwargs['email'] = request.user.email if request.user and request.user.display_name: request_kwargs['name'] = simplify(request.user.display_name) (order_id, kind, data) = payment.request(amount, next_url=url, **request_kwargs) transaction.order_id = order_id transaction.store() for invoice in invoices: if invoice.formdef_id and invoice.formdata_id: formdef = FormDef.get(invoice.formdef_id) formdata = formdef.data_class().get(invoice.formdata_id) evo = Evolution() evo.time = time.localtime() evo.status = formdata.status evo.add_part(InvoiceEvolutionPart('try', invoice, transaction=transaction)) if not formdata.evolution: formdata.evolution = [] formdata.evolution.append(evo) formdata.store() if kind == eopayment.URL: return redirect(force_str(data)) elif kind == eopayment.FORM: return return_eopayment_form(data) else: raise NotImplementedError() def return_eopayment_form(form): r = TemplateIO(html=True) r += htmltext('') r += htmltext('
') % (force_str(form.url), force_str(form.method)) for field in form.fields: r += htmltext('') % ( force_str(field['type']), force_str(field['name']), force_str(field['value'])) r += htmltext('') % _('Pay') r += htmltext('') return r.getvalue() class PaymentValidationWorkflowStatusItem(WorkflowStatusItem): description = N_('Payment Validation') key = 'payment-validation' endpoint = False category = 'interaction' next_status = None def is_available(self, workflow=None): return is_payment_supported() is_available = classmethod(is_available) def render_as_line(self): return _('Wait for payment validation') def get_parameters(self): return ('next_status',) def add_parameters_widgets(self, form, parameters, prefix='', formdef=None): if 'next_status' in parameters: form.add(SingleSelectWidget, '%snext_status' % prefix, title=_('Status once validated'), value = self.next_status, options = [(None, '---')] + [(x.id, x.name) for x in self.parent.parent.possible_status]) register_item_class(PaymentValidationWorkflowStatusItem) class PublicPaymentRegieBackDirectory(Directory): def __init__(self, asynchronous): self.asynchronous = asynchronous def _q_lookup(self, component): logger = get_logger() request = get_request() query_string = get_request().get_query() if request.get_method() == 'POST' and query_string == '': query_string = urllib.urlencode(request.form) try: regie = Regie.get(component) except KeyError: raise errors.TraversalError() if self.asynchronous: logger.debug('received asynchronous notification %r' % query_string) payment = regie.get_payment_object() payment_response = payment.response(query_string) logger.debug('payment response %r', payment_response) order_id = payment_response.order_id bank_data = payment_response.bank_data transaction = Transaction.get_on_index(order_id, 'order_id', ignore_errors=True) if transaction is None: raise errors.TraversalError() commit = False if not transaction.end: commit = True transaction.end = dt.now() transaction.bank_data = bank_data transaction.store() if payment_response.signed and payment_response.is_paid() and commit: logger.info('transaction %s successful, bankd_id:%s bank_data:%s' % ( order_id, payment_response.transaction_id, bank_data)) for invoice_id in transaction.invoice_ids: # all invoices are now paid invoice = Invoice.get(invoice_id) invoice.pay() # workflow for each related formdata if invoice.formdef_id and invoice.formdata_id: next_status = invoice.next_status formdef = FormDef.get(invoice.formdef_id) formdata = formdef.data_class().get(invoice.formdata_id) wf_status = formdata.get_status() for item in wf_status.items: if isinstance(item, PaymentValidationWorkflowStatusItem): next_status = item.next_status break if next_status is not None: formdata.status = 'wf-%s' % next_status evo = Evolution() evo.time = time.localtime() evo.status = formdata.status evo.add_part(InvoiceEvolutionPart('pay', invoice, transaction=transaction)) if not formdata.evolution: formdata.evolution = [] formdata.evolution.append(evo) formdata.store() # performs the items of the new status formdata.perform_workflow() elif payment_response.is_error() and commit: logger.info('transaction %s finished with failure, bank_data:%s' % ( order_id, bank_data)) elif commit: logger.info('transaction %s is in intermediate state, bank_data:%s' % ( order_id, bank_data)) if payment_response.return_content != None and self.asynchronous: get_response().set_content_type('text/plain') return payment_response.return_content else: if payment_response.is_error(): # TODO: here return failure message get_session().message = ('info', _('Payment failed')) else: # TODO: Here return success message get_session().message = ('error', _('Payment succeeded')) url = get_publisher().get_frontoffice_url() if get_session().user: url += '/myspace/invoices/' return redirect(url) class PublicPaymentDirectory(Directory): _q_exports = ['init', 'back', 'back_asynchronous'] back = PublicPaymentRegieBackDirectory(False) back_asynchronous = PublicPaymentRegieBackDirectory(True) def init(self): if 'invoice_ids' not in get_request().form: raise errors.QueryError() invoice_ids = get_request().form.get('invoice_ids').split(' ') for invoice_id in invoice_ids: if not Invoice.check_crc(invoice_id): raise errors.QueryError() url = get_publisher().get_frontoffice_url() + '/payment/back/' return request_payment(invoice_ids, url) def notify_new_invoice(invoice): notify_invoice(invoice, 'payment-new-invoice-email') def notify_paid_invoice(invoice): notify_invoice(invoice, 'payment-invoice-paid-email') def notify_canceled_invoice(invoice): notify_invoice(invoice, 'payment-invoice-canceled-email') def notify_invoice(invoice, template): user = invoice.get_user() assert user is not None regie = Regie.get(id=invoice.regie_id) emails.custom_template_email(template, { 'user': user, 'invoice': invoice, 'regie': regie, 'invoice_url': invoice.payment_url() }, user.email, fire_and_forget = True)