Skip to content

Modules

Top-level package for napas-pay-qr.

QRDecodeError

Bases: ValueError

Raised when a QR payload cannot be parsed as valid EMVCo TLV.

Source code in qr_pay/decode.py
59
60
class QRDecodeError(ValueError):
    """Raised when a QR payload cannot be parsed as valid EMVCo TLV."""

QRPay

Source code in qr_pay/qr_pay.py
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
class QRPay:
    def __init__(
        self,
        bin_id: str,
        consumer_id: str,
        transaction_amount: Optional[int] = None,
        purpose_of_transaction: Optional[str] = None,
        payload_format_indicator: Optional[str] = None,
        point_of_initiation_method: Optional[Literal["STATIC", "DYNAMIC"]] = "DYNAMIC",
        glocal_uuid: Optional[str] = None,
        service_code: Optional[Literal["PAYMENT", "CASH_WITHDRAWL", "CARD", "ACCOUNT"]] = "ACCOUNT",
        merchant_category_code: Optional[str] = None,
        transaction_currency: Optional[str] = None,
        tip_or_convenience_indicator: Optional[str] = None,
        convenience_fee_fixed: Optional[str] = None,
        convenience_fee_percentage: Optional[str] = None,
        country_code: Optional[str] = None,
        merchant_name: Optional[str] = None,
        merchant_city: Optional[str] = None,
        postal_code: Optional[str] = None,
        bill_number: Optional[str] = None,
        mobile_number: Optional[str] = None,
        store_label: Optional[str] = None,
        loyalty_number: Optional[str] = None,
        reference_label: Optional[str] = None,
        customer_label: Optional[str] = None,
        terminal_label: Optional[str] = None,
        additional_consumer_data_request: Optional[str] = None,
        language_preference: Optional[str] = None,
        merchant_name_alt: Optional[str] = None,
        merchant_city_alt: Optional[str] = None,
        *args,
        **kwargs,
    ):
        self.bin_id = bin_id
        self.consumer_id = consumer_id
        self.transaction_amount = transaction_amount
        self.additional_consumer_data_request = additional_consumer_data_request
        self.payload_format_indicator = payload_format_indicator
        self.point_of_initiation_method = point_of_initiation_method
        self.global_uuid = glocal_uuid
        self.service_code = service_code
        self.merchant_category_code = merchant_category_code
        self.transaction_currency = transaction_currency
        self.tip_or_convenience_indicator = tip_or_convenience_indicator
        self.convenience_fee_fixed = convenience_fee_fixed
        self.convenience_fee_percentage = convenience_fee_percentage
        self.country_code = country_code
        self.merchant_name = merchant_name
        self.merchant_city = merchant_city
        self.postal_code = postal_code
        self.bill_number = bill_number
        self.mobile_number = mobile_number
        self.store_label = store_label
        self.loyalty_number = loyalty_number
        self.reference_label = reference_label
        self.customer_label = customer_label
        self.terminal_label = terminal_label
        self.purpose_of_transaction = purpose_of_transaction
        self.language_preference = language_preference
        self.merchant_name_alt = merchant_name_alt
        self.merchant_city_alt = merchant_city_alt
        for key, value in kwargs.items():
            setattr(self, key, value)

    @classmethod
    def decode(cls, code: str, verify_crc: bool = True) -> "QRPay":
        """Build a :class:`QRPay` from a raw VietQR payload string.

        Set ``verify_crc=False`` to skip CRC validation (e.g. when decoding
        a payload known to have a non-standard checksum). Raises
        ``qr_pay.decode.QRDecodeError`` on malformed input.
        """
        data: Dict[str, Any] = {k: v for k, v in _decode_payload(code, verify_crc=verify_crc).items() if v is not None}
        return cls(**data)

    @staticmethod
    def parse(code: str, verify_crc: bool = True):
        """Parse a raw payload into nested EMVCo TLV objects (no field mapping)."""
        return _parse_payload(code, verify_crc=verify_crc)

    def get_class_name_by_key(self, key: str) -> str:
        words = key.split('_')
        class_name = ''.join(word.capitalize() for word in words)
        return class_name

    def get_code_by_key(self, key: str, value=None) -> str:
        class_name = self.get_class_name_by_key(key)
        if value is None:
            value = getattr(self, key)
        cls = getattr(fields, class_name)
        if value:
            return cls(value).code
        return cls().code

    def get_value_by_list_key(self, list_key: List[str]) -> str:
        return "".join(list(map(self.get_code_by_key, list_key)))

    def _merchant_account_information_code(self) -> str:
        payment_network_specific_value = self.get_value_by_list_key(["bin_id", "consumer_id"])
        payment_network_specific = self.get_code_by_key("payment_network_specific", payment_network_specific_value)
        glocal_uuid = self.get_code_by_key("global_uuid")
        service_code = self.get_code_by_key("service_code")
        account_information_value = glocal_uuid + payment_network_specific + service_code
        return self.get_code_by_key("consumer_account_information", account_information_value)

    def _additional_data_code(self) -> str:
        additional_data_keys = [
            "bill_number",
            "mobile_number",
            "store_label",
            "loyalty_number",
            "reference_label",
            "customer_label",
            "terminal_label",
            "purpose_of_transaction",
            "additional_consumer_data_request",
        ]
        additional_data_value = self.get_value_by_list_key(additional_data_keys)
        return self.get_code_by_key("additional_data_field_templates", additional_data_value)

    def _merchant_language_code(self) -> str:
        language_keys = ["language_preference", "merchant_name_alt", "merchant_city_alt"]
        language_value = self.get_value_by_list_key(language_keys)
        return self.get_code_by_key("merchant_information_language_template", language_value)

    @property
    def code(self) -> str:
        # Root data objects in ascending ID order (spec §6.1 examples), CRC (63) last.
        code_list = [
            self.get_code_by_key("payload_format_indicator"),  # 00
            self.get_code_by_key("point_of_initiation_method"),  # 01
            self._merchant_account_information_code(),  # 38
            self.get_code_by_key("merchant_category_code"),  # 52
            self.get_code_by_key("transaction_currency"),  # 53
            self.get_code_by_key("transaction_amount"),  # 54
            self.get_code_by_key("tip_or_convenience_indicator"),  # 55
            self.get_code_by_key("convenience_fee_fixed"),  # 56
            self.get_code_by_key("convenience_fee_percentage"),  # 57
            self.get_code_by_key("country_code"),  # 58
            self.get_code_by_key("merchant_name"),  # 59
            self.get_code_by_key("merchant_city"),  # 60
            self.get_code_by_key("postal_code"),  # 61
            self._additional_data_code(),  # 62
            self._merchant_language_code(),  # 64
            self.get_code_by_key("crc", ""),  # 63 (id + length, value excluded)
        ]

        code = "".join(code_list)
        checksum = calculate_checksum(code)
        code += checksum
        return code

    def generate_qr_code_image(self, code: str, dist: Optional[str] = None, styles: Optional[dict] = {}):
        img = segno.make_qr(code)
        dist = dist or "qr_code.png"
        segno_style = styles or {}
        img.save(dist, **segno_style)
        return img

    def generate_qr_pay(self, dist: Optional[str] = None, styles: Optional[dict] = {}) -> None:
        self.generate_qr_code_image(self.code, dist, styles)

decode(code, verify_crc=True) classmethod

Build a :class:QRPay from a raw VietQR payload string.

Set verify_crc=False to skip CRC validation (e.g. when decoding a payload known to have a non-standard checksum). Raises qr_pay.decode.QRDecodeError on malformed input.

Source code in qr_pay/qr_pay.py
76
77
78
79
80
81
82
83
84
85
@classmethod
def decode(cls, code: str, verify_crc: bool = True) -> "QRPay":
    """Build a :class:`QRPay` from a raw VietQR payload string.

    Set ``verify_crc=False`` to skip CRC validation (e.g. when decoding
    a payload known to have a non-standard checksum). Raises
    ``qr_pay.decode.QRDecodeError`` on malformed input.
    """
    data: Dict[str, Any] = {k: v for k, v in _decode_payload(code, verify_crc=verify_crc).items() if v is not None}
    return cls(**data)

parse(code, verify_crc=True) staticmethod

Parse a raw payload into nested EMVCo TLV objects (no field mapping).

Source code in qr_pay/qr_pay.py
87
88
89
90
@staticmethod
def parse(code: str, verify_crc: bool = True):
    """Parse a raw payload into nested EMVCo TLV objects (no field mapping)."""
    return _parse_payload(code, verify_crc=verify_crc)

parse(code, verify_crc=True)

Parse a full VietQR payload into nested :class:TLVObject templates.

When verify_crc is True (default) the trailing CRC is validated and :class:QRDecodeError is raised on mismatch.

Source code in qr_pay/decode.py
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
def parse(code: str, verify_crc: bool = True) -> Dict[str, TLVObject]:
    """Parse a full VietQR payload into nested :class:`TLVObject` templates.

    When ``verify_crc`` is True (default) the trailing CRC is validated and
    :class:`QRDecodeError` is raised on mismatch.
    """
    if not code:
        raise QRDecodeError("Empty QR payload")
    if verify_crc and not verify_checksum(code):
        raise QRDecodeError("CRC checksum mismatch — payload is corrupt or altered")

    root = parse_tlv(code)
    for id in _NESTED_ROOT_IDS:
        obj = root.get(id)
        if obj is not None:
            obj.children = _parse_nested(obj.value)
    # Beneficiary (ID 01) inside merchant account information nests one level deeper.
    merchant = root.get(ID_MERCHANT_ACCOUNT_INFORMATION)
    if merchant is not None:
        beneficiary = merchant.children.get(ID_BENEFICIARY)
        if beneficiary is not None:
            beneficiary.children = _parse_nested(beneficiary.value)
    return root

verify_checksum(code)

Verify the trailing CRC of a full VietQR payload.

The checksum (ISO/IEC 13239) is computed over every byte up to and including the CRC id + length ("6304"), but excluding the CRC value itself, i.e. everything except the final four hex characters.

Source code in qr_pay/crc.py
18
19
20
21
22
23
24
25
26
27
def verify_checksum(code: str) -> bool:
    """Verify the trailing CRC of a full VietQR payload.

    The checksum (ISO/IEC 13239) is computed over every byte up to and
    including the CRC id + length ("6304"), but excluding the CRC value
    itself, i.e. everything except the final four hex characters.
    """
    if len(code) < 8 or code[-8:-4] != "6304":
        return False
    return calculate_checksum(code[:-4]).upper() == code[-4:].upper()