Skip to content

Commit

Permalink
Mpay tests (#23)
Browse files Browse the repository at this point in the history
* DEBUG log

* add additional logs

* changed namespace

* fixed error handler

* Sorted fields alphabetically

* fixed datetimes

* Added additional logging

* Added customer data

* Changed dt format, added payment split

* fixed organization

* Fixed confimorderpayment

* Fixed line id too long

* Actual fix

* Added request logging

* Changed account fields to optional

* Added bill payed at
  • Loading branch information
malinowskikam authored Apr 8, 2024
1 parent 1afed26 commit 9177cc7
Show file tree
Hide file tree
Showing 4 changed files with 106 additions and 58 deletions.
9 changes: 8 additions & 1 deletion msystems/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": "",
Expand Down
67 changes: 34 additions & 33 deletions msystems/soap/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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):
Expand All @@ -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))

Expand All @@ -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)
75 changes: 55 additions & 20 deletions msystems/views/mpay.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 = {
Expand All @@ -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
Expand All @@ -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')


Expand All @@ -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):
Expand All @@ -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,
Expand All @@ -128,25 +157,25 @@ 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
voucher.save(username=voucher.user_updated.username)

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()
Expand All @@ -162,15 +191,21 @@ 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,
in_protocol=Soap11(validator='lxml'),
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)


Expand Down
13 changes: 9 additions & 4 deletions msystems/xml_utils.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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')
Expand Down

0 comments on commit 9177cc7

Please sign in to comment.