diff --git a/msystems/apps.py b/msystems/apps.py index 385bbfc..3bdc5db 100644 --- a/msystems/apps.py +++ b/msystems/apps.py @@ -63,7 +63,14 @@ # Mpay certificate, PEM string format "mpay_certificate": "", # Default account info for voucher payments - "mpay_destination_account": { + "mpay_split": "0.5", + "mpay_destination_account_1": { + "BankCode": "", + "BankFiscalCode": "", + "BankAccount": "", + "BeneficiaryName": "" + }, + "mpay_destination_account_2": { "BankCode": "", "BankFiscalCode": "", "BankAccount": "", diff --git a/msystems/soap/models.py b/msystems/soap/models.py index 954e07e..3380594 100644 --- a/msystems/soap/models.py +++ b/msystems/soap/models.py @@ -2,10 +2,10 @@ from spyne.model.complex import ComplexModel, Array from spyne.model.enum import Enum -namespace = 'https://zilieri.gov.md' +namespace = 'https://mpay.gov.md' -Currency = Enum('MDL', 'EUR', 'USD', type_name='Currency') -CustomerType = Enum('Unspecified', 'Person', 'Organisation', type_name='CustomerType') +CurrencyCode = Enum('MDL', 'EUR', 'USD', type_name='Currency') +CustomerType = Enum('Unspecified', 'Person', 'Organization', type_name='CustomerType') OrderStatus = Enum('Active', 'PartiallyPaid', 'Paid', 'Completed', 'Expired', 'Canceled', 'Refunding', 'Refunded', type_name='OrderStatus') PropertyType = Enum('string', 'idn', 'tc', type_name='Type') @@ -15,69 +15,71 @@ class OrderProperty(ComplexModel): namespace = namespace __type_name__ = 'OrderProperty' - Name = Unicode.customize(min_occurs=1, max_occurs=1, max_len=36, nillable=False) DisplayName = Unicode.customize(min_occurs=0, max_occurs=1, max_len=36, nillable=False) - Value = Unicode.customize(min_occurs=1, max_occurs=1, max_len=255, nillable=False) Modifiable = Boolean.customize(min_occurs=0, max_occurs=1, nillable=False, default=False) + Name = Unicode.customize(min_occurs=1, max_occurs=1, max_len=36, nillable=False) Required = Boolean.customize(min_occurs=0, max_occurs=1, nillable=False, default=False) Type = PropertyType.customize(min_occurs=0, max_occurs=1, nillable=False, default=PropertyType.string) + Value = Unicode.customize(min_occurs=1, max_occurs=1, max_len=255, nillable=False) class PaymentAccount(ComplexModel): __namespace__ = namespace __type_name__ = 'PaymentAccount' + # These fields should be required but mpay does not respect that + BankAccount = Unicode.customize(min_occurs=0, max_occurs=1, max_len=24, nillable=False) + BankCode = Unicode.customize(min_occurs=0, max_occurs=1, max_len=20, nillable=False) + BankFiscalCode = Unicode.customize(min_occurs=0, max_occurs=1, max_len=20, nillable=False) + BeneficiaryName = Unicode.customize(min_occurs=0, max_occurs=1, max_len=60, nillable=False) + # end of required fields ConfigurationCode = Unicode.customize(min_occurs=0, max_occurs=1, max_len=36, nillable=False) - BankCode = Unicode.customize(min_occurs=1, max_occurs=1, max_len=20, nillable=False) - BankFiscalCode = Unicode.customize(min_occurs=1, max_occurs=1, max_len=20, nillable=False) - BankAccount = Unicode.customize(min_occurs=1, max_occurs=1, max_len=24, nillable=False) - BeneficiaryName = Unicode.customize(min_occurs=1, max_occurs=1, max_len=60, nillable=False) class OrderDetailsQuery(ComplexModel): __namespace__ = namespace __type_name__ = 'OrderDetailsQuery' + Language = Unicode.customize(min_occurs=0, max_occurs=1, max_len=2, nillable=False, default='ro') OrderKey = Unicode.customize(min_occurs=1, max_occurs=1, max_len=36, nillable=False) ServiceID = Unicode.customize(min_occurs=1, max_occurs=1, max_len=36, nillable=False) - Language = Unicode.customize(min_occurs=0, max_occurs=1, max_len=2, nillable=False) class OrderLine(ComplexModel): __namespace__ = namespace __type_name__ = 'OrderLine' - LineID = Unicode.customize(min_occurs=1, max_occurs=1, max_len=36, nillable=False) - Reason = Unicode.customize(min_occurs=1, max_occurs=1, max_len=50, nillable=False) - AmountDue = Decimal.customize(min_occurs=0, max_occurs=1, nillable=False) - AllowPartialPayment = Boolean.customize(min_occurs=0, max_occurs=1, nillable=False, default=False) AllowAdvancePayment = Boolean.customize(min_occurs=0, max_occurs=1, nillable=False, default=False) + AllowPartialPayment = Boolean.customize(min_occurs=0, max_occurs=1, nillable=False, default=False) + AmountDue = Decimal.customize(min_occurs=0, max_occurs=1, nillable=False) DestinationAccount = PaymentAccount.customize(min_occurs=1, max_occurs=1, nillable=False) + LineID = Unicode.customize(min_occurs=1, max_occurs=1, max_len=36, nillable=False) Properties = (Array(OrderProperty.customize(min_occurs=0, max_occurs="unbounded", nillable=False)) .customize(min_occurs=0, max_occurs=1, nillable=False)) + Reason = Unicode.customize(min_occurs=1, max_occurs=1, max_len=50, nillable=False) class OrderDetails(ComplexModel): __namespace__ = namespace __type_name__ = 'OrderDetails' - ServiceID = Unicode.customize(min_occurs=1, max_occurs=1, max_len=36, nillable=False) - OrderKey = Unicode.customize(min_occurs=1, max_occurs=1, max_len=36, nillable=False) - Reason = Unicode.customize(min_occurs=1, max_occurs=1, max_len=50, nillable=False) - Status = OrderStatus.customize(min_occurs=1, max_occurs=1, nillable=False) - IssuedAt = DateTime.customize(min_occurs=0, max_occurs=1, nillable=False) - DueDate = DateTime.customize(min_occurs=0, max_occurs=1, nillable=False) - TotalAmountDue = Decimal.customize(min_occurs=0, max_occurs=1, nillable=False) - Currency = Currency.customize(min_occurs=1, max_occurs=1, nillable=False) - AllowPartialPayment = Boolean.customize(min_occurs=0, max_occurs=1, nillable=False, default=False) AllowAdvancePayment = Boolean.customize(min_occurs=0, max_occurs=1, nillable=False, default=False) - CustomerType = CustomerType.customize(min_occurs=0, max_occurs=1, nillable=False, default=CustomerType.Unspecified) + AllowPartialPayment = Boolean.customize(min_occurs=0, max_occurs=1, nillable=False, default=False) + Currency = CurrencyCode.customize(min_occurs=1, max_occurs=1, nillable=False) CustomerID = Unicode.customize(min_occurs=0, max_occurs=1, max_len=13, nillable=False) - CustomerName = Unicode.customize(min_occurs=0, max_occurs=1, max_len=60, nillable=False) + CustomerName = Unicode.customize(min_occurs=1, max_occurs=1, max_len=60, nillable=False) + CustomerType = CustomerType.customize(min_occurs=1, max_occurs=1, nillable=False) + DueDate = DateTime.customize(min_occurs=0, max_occurs=1, nillable=False) + IssuedAt = DateTime.customize(min_occurs=0, max_occurs=1, nillable=False) Lines = (Array(OrderLine.customize(min_occurs=1, max_occurs="unbounded", nillable=False)) .customize(min_occurs=1, max_occurs=1, nillable=False)) + OrderKey = Unicode.customize(min_occurs=1, max_occurs=1, max_len=36, nillable=False) Properties = (Array(OrderProperty.customize(min_occurs=0, max_occurs="unbounded", nillable=False)) .customize(min_occurs=0, max_occurs=1, nillable=False)) + Reason = Unicode.customize(min_occurs=1, max_occurs=1, max_len=50, nillable=False) + ServiceID = Unicode.customize(min_occurs=1, max_occurs=1, max_len=36, nillable=False) + Status = OrderStatus.customize(min_occurs=1, max_occurs=1, nillable=False) + TotalAmountDue = Decimal.customize(min_occurs=0, max_occurs=1, nillable=False) class GetOrderDetailsResult(ComplexModel): @@ -99,9 +101,9 @@ class PaymentConfirmationLine(ComplexModel): __namespace__ = namespace __type_name__ = 'PaymentConfirmationLine' - LineID = Unicode.customize(min_occurs=1, max_occurs=1, max_len=36, nillable=False) Amount = Decimal.customize(min_occurs=1, max_occurs=1, nillable=False) DestinationAccount = PaymentAccount.customize(min_occurs=1, max_occurs=1, nillable=False) + LineID = Unicode.customize(min_occurs=1, max_occurs=1, max_len=36, nillable=False) Properties = (Array(PaymentProperty.customize(min_occurs=0, max_occurs="unbounded", nillable=False)) .customize(min_occurs=0, max_occurs=1, nillable=False)) @@ -110,14 +112,13 @@ class PaymentConfirmation(ComplexModel): __namespace__ = namespace __type_name__ = 'PaymentConfirmation' - OrderKey = Unicode.customize(min_occurs=1, max_occurs=1, max_len=36, nillable=False) - ServiceID = Unicode.customize(min_occurs=1, max_occurs=1, max_len=36, nillable=False) + Currency = CurrencyCode.customize(min_occurs=1, max_occurs=1, nillable=False) InvoiceID = Unicode.customize(min_occurs=0, max_occurs=1, max_len=36, nillable=False) - PaymentID = Unicode.customize(min_occurs=1, max_occurs=1, max_len=36, nillable=False) - PaidAt = DateTime.customize(min_occurs=1, max_occurs=1, nillable=False) - TotalAmount = Decimal.customize(min_occurs=1, max_occurs=1, nillable=False) - Currency = Currency.customize(min_occurs=1, max_occurs=1, nillable=False) - Lines = Array(PaymentConfirmationLine.customize(min_occurs=1, max_occurs="unbounded", nillable=False)) + OrderKey = Unicode.customize(min_occurs=1, max_occurs=1, max_len=36, nillable=False) + PaidAt = DateTime.customize(min_occurs=1, max_occurs=1, nillable=False) + PaymentID = Unicode.customize(min_occurs=1, max_occurs=1, max_len=36, nillable=False) Properties = (Array(PaymentProperty.customize(min_occurs=0, max_occurs="unbounded", nillable=False)) .customize(min_occurs=0, max_occurs=1, nillable=False)) + ServiceID = Unicode.customize(min_occurs=1, max_occurs=1, max_len=36, nillable=False) + TotalAmount = Decimal.customize(min_occurs=1, max_occurs=1, nillable=False) diff --git a/msystems/views/mpay.py b/msystems/views/mpay.py index 575ada7..34c6d24 100644 --- a/msystems/views/mpay.py +++ b/msystems/views/mpay.py @@ -6,6 +6,7 @@ from django.views.decorators.http import require_GET from django.http import HttpResponseNotFound from django.shortcuts import redirect +from django.contrib.contenttypes.models import ContentType from rest_framework.decorators import api_view from spyne.application import Application from spyne.decorator import rpc @@ -20,12 +21,13 @@ from invoice.models import Bill, BillPayment from msystems.apps import MsystemsConfig from msystems.soap.models import OrderDetailsQuery, GetOrderDetailsResult, OrderLine, OrderDetails, \ - PaymentConfirmation, PaymentAccount, OrderStatus + PaymentConfirmation, PaymentAccount, OrderStatus, CustomerType from msystems.xml_utils import add_signature, verify_signature, verify_timestamp, add_timestamp +from policyholder.models import PolicyHolder from worker_voucher.models import WorkerVoucher from worker_voucher.services import worker_voucher_bill_user_filter -namespace = 'https://zilieri.gov.md' +namespace = 'https://mpay.gov.md' logger = logging.getLogger(__name__) _order_status_map = { @@ -46,7 +48,8 @@ def _check_service_id(service_id): def _get_order(order_key): - bill = Bill.objects.filter(code__iexact=order_key).first() + bill = Bill.objects.filter(code__iexact=order_key, + subject_type=ContentType.objects.get_for_model(PolicyHolder)).first() if not bill: raise Fault(faultcode='InvalidParameter', faultstring=f'OrderKey "{order_key}" is unknown') return bill @@ -73,17 +76,29 @@ def _get_voucher(bill_item): return voucher +def _log_rpc_call(ctx): + input = ctx.transport.req.get('wsgi.input') + action = ctx.transport.req.get('HTTP_SOAPACTION') + if input: + input.seek(0) + data = input.read().decode("utf-8") + logger.info(f"Method {action} called with:\n{data}\n") + input.seek(0) + + def _validate_envelope(ctx): root = ctx.in_document try: verify_timestamp(root) except ValueError as e: + logger.error(f"Timestamp verification failed", exc_info=e) raise Fault(faultcode='InvalidRequest', faultstring=str(e)) try: verify_signature(root, MsystemsConfig.mpay_config['mpay_certificate']) - except SignatureVerificationFailed: + except SignatureVerificationFailed as e: + logger.error("Envelope signature verification failed", exc_info=e) raise Fault(faultcode='InvalidRequest', faultstring=f'Envelope signature verification failed') @@ -94,7 +109,9 @@ def _add_envelope_header(ctx): add_signature(root, MsystemsConfig.mpay_config['service_private_key'], MsystemsConfig.mpay_config['service_certificate']) - ctx.out_string = [etree.tostring(ctx.out_document)] + envelope = etree.tostring(ctx.out_document, pretty_print=True) + logger.info(envelope.decode('utf-8')) + ctx.out_string = [envelope] class MpayService(ServiceBase): @@ -104,21 +121,33 @@ def GetOrderDetails(ctx, query: OrderDetailsQuery) -> GetOrderDetailsResult: _check_service_id(query.ServiceID) bill = _get_order(query.OrderKey) - account = PaymentAccount(**MsystemsConfig.mpay_config['mpay_destination_account']) - - order_lines = [ - OrderLine( - AmountDue=str(bill_item.amount_total), - LineID=bill_item.code, - Reason="Voucher Acquirement", - DestinationAccount=account) - for bill_item in bill.line_items_bill.filter(is_deleted=False) - ] + split = decimal.Decimal(MsystemsConfig.mpay_config['mpay_split']) + account1 = PaymentAccount(**MsystemsConfig.mpay_config['mpay_destination_account_1']) + account2 = PaymentAccount(**MsystemsConfig.mpay_config['mpay_destination_account_2']) + + order_lines = [] + for bill_item in bill.line_items_bill.filter(is_deleted=False): + amount1 = round(bill_item.amount_total * split, 2) + # Split the amount into two lines + # Use only first 2 sections of the code (uuid),max char limit is 36, full len code is 38 + # The line should be easily identifiable in context of OrderId (bill code) + order_lines.append(OrderLine(AmountDue=str(amount1), + LineID=bill_item.code[:13] + "_1", + Reason="Voucher Acquirement", + DestinationAccount=account1)) + amount2 = round(bill_item.amount_total - amount1, 2) + order_lines.append(OrderLine(AmountDue=amount2, + LineID=bill_item.code[:13] + "_2", + Reason="Voucher Acquirement", + DestinationAccount=account2)) if not order_lines: raise Fault(faultcode='InvalidParameter', faultstring=f'OrderKey "{query.OrderKey}" has no line items') order_details = OrderDetails( + CustomerID=bill.subject.code, + CustomerType=CustomerType.Organization, + CustomerName=bill.subject.trade_name, Currency=InvoiceConfig.default_currency_code, Lines=order_lines, OrderKey=bill.code, @@ -128,18 +157,17 @@ def GetOrderDetails(ctx, query: OrderDetailsQuery) -> GetOrderDetailsResult: TotalAmountDue=str(bill.amount_total) ) - return GetOrderDetailsResult(OrderDetails=order_details) + ret = GetOrderDetailsResult(OrderDetails=order_details) + return ret @rpc(PaymentConfirmation.customize(min_occurs=1, max_occurs=1, nillable=False)) def ConfirmOrderPayment(ctx, confirmation: PaymentConfirmation) -> None: _check_service_id(confirmation.ServiceID) bill = _get_order(confirmation.OrderKey) + _check_amount_due(bill, decimal.Decimal(confirmation.TotalAmount)) with transaction.atomic(): - for line in confirmation.Lines: - line_amount = decimal.Decimal(line.Amount) - bill_item = _get_order_line(bill, line.LineID) - _check_amount_due(bill_item, line_amount) + for bill_item in bill.line_items_bill.filter(is_deleted=False): voucher = _get_voucher(bill_item) if voucher.status != WorkerVoucher.Status.ASSIGNED: voucher.status = WorkerVoucher.Status.ASSIGNED @@ -147,6 +175,7 @@ def ConfirmOrderPayment(ctx, confirmation: PaymentConfirmation) -> None: if bill.status != Bill.Status.PAID: bill.status = Bill.Status.PAID + bill.date_payed = confirmation.PaidAt bill.save(username=bill.user_updated.username) payment = BillPayment.objects.filter(bill=bill, code_tp=confirmation.PaymentID).first() @@ -162,6 +191,10 @@ def ConfirmOrderPayment(ctx, confirmation: PaymentConfirmation) -> None: payment.save(username=bill.user_updated.username) +def _error_handler_function(ctx, *args, **kwargs): + logger.error("Spyne error", exc_info=ctx.in_error) + + _application = Application( [MpayService], tns=namespace, @@ -169,8 +202,10 @@ def ConfirmOrderPayment(ctx, confirmation: PaymentConfirmation) -> None: out_protocol=Soap11(), ) _application.event_manager.add_listener('method_call', _validate_envelope) +_application.event_manager.add_listener('method_exception_object', _error_handler_function) mpay_app = DjangoApplication(_application) +mpay_app.event_manager.add_listener('wsgi_call', _log_rpc_call) mpay_app.event_manager.add_listener('wsgi_return', _add_envelope_header) diff --git a/msystems/xml_utils.py b/msystems/xml_utils.py index 412b7ca..8478ba3 100644 --- a/msystems/xml_utils.py +++ b/msystems/xml_utils.py @@ -1,3 +1,4 @@ +import re import datetime as py_datetime from zeep.wsse.signature import _make_sign_key, _sign_envelope_with_key, _make_verify_key, _verify_envelope_with_key from lxml import etree @@ -30,22 +31,26 @@ def add_timestamp(root): security = etree.SubElement(header, etree.QName(ns_wss_s, "Security")) timestamp = etree.SubElement(security, etree.QName(ns_wss_util, "Timestamp")) created = etree.SubElement(timestamp, etree.QName(ns_wss_util, "Created")) - created.text = dt_now.isoformat() + created.text = dt_now.strftime("%Y-%m-%dT%H:%M:%SZ") expires = etree.SubElement(timestamp, etree.QName(ns_wss_util, "Expires")) - expires.text = dt_expires.isoformat() + expires.text = dt_expires.strftime("%Y-%m-%dT%H:%M:%SZ") +def replace_utc_timezone_with_offset(dt_str): + # Python 3.9 does not support Z timezone in datetime strings + return re.sub(r'Z$', '+00:00', dt_str) + def verify_timestamp(root): dt_now = datetime.datetime.from_ad_datetime(py_datetime.datetime.now(tz=py_datetime.timezone.utc)) created, expires = root.find(created_xpath), root.find(expires_xpath) if created is None: raise ValueError('Created timestamp not found') - dt_created = datetime.datetime.fromisoformat(created.text) + dt_created = datetime.datetime.fromisoformat(replace_utc_timezone_with_offset(created.text)) if expires is None: raise ValueError('Expires timestamp not found') - dt_expires = datetime.datetime.fromisoformat(expires.text) + dt_expires = datetime.datetime.fromisoformat(replace_utc_timezone_with_offset(expires.text)) if dt_created > dt_now: raise ValueError('Created timestamp is in the future')