From 0294d5afd60232a8aa9264e7b4857d18d5d410f9 Mon Sep 17 00:00:00 2001 From: Hrvoje Fekete Date: Wed, 14 Jan 2026 14:21:40 -0800 Subject: [PATCH 01/14] fix - Add dataclass-based dynamic attribute initialization --- api/namex/services/payment/models/__init__.py | 7 +++++++ services/namex-pay/src/namex_pay/resources/worker.py | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/api/namex/services/payment/models/__init__.py b/api/namex/services/payment/models/__init__.py index 391a956b5..c14302380 100644 --- a/api/namex/services/payment/models/__init__.py +++ b/api/namex/services/payment/models/__init__.py @@ -189,3 +189,10 @@ class ReceiptResponse(Serializable): paymentMethod: str = '' receiptNumber: str = '' routingSlipNumber: str = '' + + def __init__(self, **kwargs): + """Set the attributes only if the field is defined.""" + names = {f.name for f in dataclasses.fields(self)} + for k, v in kwargs.items(): + if k in names: + setattr(self, k, v) diff --git a/services/namex-pay/src/namex_pay/resources/worker.py b/services/namex-pay/src/namex_pay/resources/worker.py index 33ddd3b73..c338493c9 100644 --- a/services/namex-pay/src/namex_pay/resources/worker.py +++ b/services/namex-pay/src/namex_pay/resources/worker.py @@ -16,6 +16,7 @@ The entry-point is the **cb_subscription_handler** """ +import dataclasses import time from dataclasses import dataclass from datetime import timedelta @@ -104,6 +105,12 @@ class PaymentToken: filing_identifier: Optional[str] = None corp_type_code: Optional[str] = None + def __init__(self, **kwargs): + """Set the attributes only if the field is defined.""" + names = {f.name for f in dataclasses.fields(self)} + for k, v in kwargs.items(): + if k in names: + setattr(self, k, v) def get_payment_token(ce: SimpleCloudEvent): """Return a PaymentToken if enclosed in the cloud event.""" From 754373ad42291551a35f6f91abf2e4e43de07265 Mon Sep 17 00:00:00 2001 From: Hrvoje Fekete Date: Thu, 15 Jan 2026 09:04:47 -0800 Subject: [PATCH 02/14] test - Add unit tests for `PaymentToken` and `ReceiptResponse` dataclasses and refactor initialization logic --- api/namex/services/payment/models/__init__.py | 12 +- api/namex/services/payment/receipts.py | 2 +- .../python/models/test_receipt_response.py | 96 ++++++++++++++++ .../src/namex_pay/resources/worker.py | 14 +-- .../tests/unit/test_payment_token.py | 107 ++++++++++++++++++ 5 files changed, 217 insertions(+), 14 deletions(-) create mode 100644 api/tests/python/models/test_receipt_response.py create mode 100644 services/namex-pay/tests/unit/test_payment_token.py diff --git a/api/namex/services/payment/models/__init__.py b/api/namex/services/payment/models/__init__.py index c14302380..23a7433e8 100644 --- a/api/namex/services/payment/models/__init__.py +++ b/api/namex/services/payment/models/__init__.py @@ -190,9 +190,9 @@ class ReceiptResponse(Serializable): receiptNumber: str = '' routingSlipNumber: str = '' - def __init__(self, **kwargs): - """Set the attributes only if the field is defined.""" - names = {f.name for f in dataclasses.fields(self)} - for k, v in kwargs.items(): - if k in names: - setattr(self, k, v) + @classmethod + def from_dict(cls, data: dict) -> 'ReceiptResponse': + """Create instance from dict, ignoring unknown fields.""" + valid_fields = {f.name for f in dataclasses.fields(cls)} + filtered = {k: v for k, v in data.items() if k in valid_fields} + return cls(**filtered) diff --git a/api/namex/services/payment/receipts.py b/api/namex/services/payment/receipts.py index 87fe04d5b..2993238a3 100644 --- a/api/namex/services/payment/receipts.py +++ b/api/namex/services/payment/receipts.py @@ -32,7 +32,7 @@ def get_receipt(payment_identifier): api_response = api_instance.get_receipt(payment_identifier) current_app.logger.debug(api_response) - return ReceiptResponse(**api_response) + return ReceiptResponse.from_dict(api_response) except Exception as err: raise SBCPaymentException(err) diff --git a/api/tests/python/models/test_receipt_response.py b/api/tests/python/models/test_receipt_response.py new file mode 100644 index 000000000..8cc034c8a --- /dev/null +++ b/api/tests/python/models/test_receipt_response.py @@ -0,0 +1,96 @@ +# Copyright © 2026 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Tests for ReceiptResponse dataclass.""" + +import pytest +from namex.services.payment.models import ReceiptResponse, PaymentInvoice + +def test_init_with_valid_fields(): + """Assert that valid fields are set correctly.""" + data = { + 'bcOnlineAccountNumber': 'BC12345', + 'filingIdentifier': 'FIL-001', + 'invoiceNumber': 'INV-100', + 'paymentMethod': 'CC', + 'receiptNumber': 'REC-999', + 'routingSlipNumber': 'RS-001' + } + response = ReceiptResponse.from_dict(data) + + assert response.bcOnlineAccountNumber == 'BC12345' + assert response.filingIdentifier == 'FIL-001' + assert response.invoiceNumber == 'INV-100' + assert response.paymentMethod == 'CC' + assert response.receiptNumber == 'REC-999' + assert response.routingSlipNumber == 'RS-001' + +def test_init_ignores_invalid_fields(): + """Assert that unknown fields are ignored.""" + data = { + 'receiptNumber': 'REC-123', + 'unknownField': 'should-be-ignored', + 'anotherInvalid': 12345 + } + response = ReceiptResponse.from_dict(data) + + assert response.receiptNumber == 'REC-123' + assert not hasattr(response, 'unknownField') + assert not hasattr(response, 'anotherInvalid') + +def test_init_with_empty_kwargs(): + """Assert that empty kwargs results in default values.""" + response = ReceiptResponse() + + assert response.bcOnlineAccountNumber is None + assert response.filingIdentifier is None + assert response.invoiceNumber == '' + assert response.paymentMethod == '' + assert response.receiptNumber == '' + assert response.routingSlipNumber == '' + +def test_init_with_partial_data(): + """Assert that partial data sets only provided fields.""" + data = { + 'receiptNumber': 'REC-PARTIAL', + 'paymentMethod': 'DIRECT_PAY' + } + response = ReceiptResponse.from_dict(data) + + assert response.receiptNumber == 'REC-PARTIAL' + assert response.paymentMethod == 'DIRECT_PAY' + assert response.bcOnlineAccountNumber is None + assert response.invoiceNumber == '' + +def test_init_with_invoice_dict(): + """Assert that invoice field accepts dict data.""" + invoice_data = {'id': 100, 'total': 30.0, 'paid': 30.0} + data = { + 'receiptNumber': 'REC-INV', + 'invoice': invoice_data + } + response = ReceiptResponse.from_dict(data) + + assert response.receiptNumber == 'REC-INV' + assert response.invoice == invoice_data + +def test_init_overwrites_none_defaults(): + """Assert that None defaults can be overwritten.""" + data = { + 'bcOnlineAccountNumber': 'BCOL-NEW', + 'filingIdentifier': 'FIL-NEW' + } + response = ReceiptResponse.from_dict(data) + + assert response.bcOnlineAccountNumber == 'BCOL-NEW' + assert response.filingIdentifier == 'FIL-NEW' diff --git a/services/namex-pay/src/namex_pay/resources/worker.py b/services/namex-pay/src/namex_pay/resources/worker.py index c338493c9..31d19220f 100644 --- a/services/namex-pay/src/namex_pay/resources/worker.py +++ b/services/namex-pay/src/namex_pay/resources/worker.py @@ -105,12 +105,12 @@ class PaymentToken: filing_identifier: Optional[str] = None corp_type_code: Optional[str] = None - def __init__(self, **kwargs): - """Set the attributes only if the field is defined.""" - names = {f.name for f in dataclasses.fields(self)} - for k, v in kwargs.items(): - if k in names: - setattr(self, k, v) + @classmethod + def from_dict(cls, data: dict) -> 'PaymentToken': + """Create instance from dict, ignoring unknown fields.""" + valid_fields = {f.name for f in dataclasses.fields(cls)} + filtered = {k: v for k, v in data.items() if k in valid_fields} + return cls(**filtered) def get_payment_token(ce: SimpleCloudEvent): """Return a PaymentToken if enclosed in the cloud event.""" @@ -120,7 +120,7 @@ def get_payment_token(ce: SimpleCloudEvent): and isinstance(data, dict) ): converted = humps.decamelize(data) - pt = PaymentToken(**converted) + pt = PaymentToken.from_dict(converted) return pt return None diff --git a/services/namex-pay/tests/unit/test_payment_token.py b/services/namex-pay/tests/unit/test_payment_token.py new file mode 100644 index 000000000..3ee0a1ae3 --- /dev/null +++ b/services/namex-pay/tests/unit/test_payment_token.py @@ -0,0 +1,107 @@ +# Copyright © 2026 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Tests for PaymentToken dataclass.""" + +import pytest +from namex_pay.resources.worker import PaymentToken + +def test_init_with_all_fields(): + """Assert that all valid fields are set correctly.""" + data = { + 'id': 'PAY-12345', + 'status_code': 'COMPLETED', + 'filing_identifier': 'FIL-001', + 'corp_type_code': 'BC' + } + token = PaymentToken.from_dict(data) + + assert token.id == 'PAY-12345' + assert token.status_code == 'COMPLETED' + assert token.filing_identifier == 'FIL-001' + assert token.corp_type_code == 'BC' + +def test_init_with_partial_fields(): + """Assert that partial data sets only provided fields.""" + data = { + 'id': 'PAY-999', + 'status_code': 'PENDING' + } + token = PaymentToken.from_dict(data) + + assert token.id == 'PAY-999' + assert token.status_code == 'PENDING' + assert token.filing_identifier is None + assert token.corp_type_code is None + +def test_init_ignores_invalid_fields(): + """Assert that unknown fields are ignored.""" + data = { + 'id': 'PAY-123', + 'status_code': 'COMPLETED', + 'unknown_field': 'should-be-ignored', + 'another_invalid': 12345 + } + token = PaymentToken.from_dict(data) + + assert token.id == 'PAY-123' + assert token.status_code == 'COMPLETED' + assert not hasattr(token, 'unknown_field') + assert not hasattr(token, 'another_invalid') + +def test_init_with_empty_kwargs(): + """Assert that empty kwargs results in default None values.""" + token = PaymentToken() + + assert token.id is None + assert token.status_code is None + assert token.filing_identifier is None + assert token.corp_type_code is None + +def test_init_with_none_values(): + """Assert that explicit None values are set correctly.""" + data = { + 'id': 'PAY-001', + 'status_code': None, + 'filing_identifier': None, + 'corp_type_code': 'NRO' + } + token = PaymentToken.from_dict(data) + + assert token.id == 'PAY-001' + assert token.status_code is None + assert token.filing_identifier is None + assert token.corp_type_code == 'NRO' + +@pytest.mark.parametrize('status_code', [ + 'COMPLETED', + 'APPROVED', + 'PENDING', + 'TRANSACTION_FAILED', +]) +def test_init_with_various_status_codes(status_code): + """Assert that different status codes are accepted.""" + token = PaymentToken(id='PAY-001', status_code=status_code) + + assert token.status_code == status_code + +def test_init_with_integer_id(): + """Assert that integer id values are accepted.""" + data = { + 'id': 29590, + 'status_code': 'COMPLETED' + } + token = PaymentToken.from_dict(data) + + assert token.id == 29590 + assert token.status_code == 'COMPLETED' From 8a26afbccb6190fa7ef1275af70eff538ed65288 Mon Sep 17 00:00:00 2001 From: Hrvoje Fekete Date: Thu, 15 Jan 2026 09:13:55 -0800 Subject: [PATCH 03/14] ruff fixes --- api/tests/python/models/test_receipt_response.py | 4 +++- services/namex-pay/tests/unit/test_payment_token.py | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/api/tests/python/models/test_receipt_response.py b/api/tests/python/models/test_receipt_response.py index 8cc034c8a..dc8c7b425 100644 --- a/api/tests/python/models/test_receipt_response.py +++ b/api/tests/python/models/test_receipt_response.py @@ -14,7 +14,9 @@ """Tests for ReceiptResponse dataclass.""" import pytest -from namex.services.payment.models import ReceiptResponse, PaymentInvoice + +from namex.services.payment.models import PaymentInvoice, ReceiptResponse + def test_init_with_valid_fields(): """Assert that valid fields are set correctly.""" diff --git a/services/namex-pay/tests/unit/test_payment_token.py b/services/namex-pay/tests/unit/test_payment_token.py index 3ee0a1ae3..2eb8498c5 100644 --- a/services/namex-pay/tests/unit/test_payment_token.py +++ b/services/namex-pay/tests/unit/test_payment_token.py @@ -14,8 +14,10 @@ """Tests for PaymentToken dataclass.""" import pytest + from namex_pay.resources.worker import PaymentToken + def test_init_with_all_fields(): """Assert that all valid fields are set correctly.""" data = { From 728982b239960459b950559f37a64b8380e519f4 Mon Sep 17 00:00:00 2001 From: Hrvoje Fekete Date: Thu, 15 Jan 2026 13:02:42 -0800 Subject: [PATCH 04/14] refactor - Replace `from_dict` methods with Pydantic-based dataclass initialization across `PaymentToken` and `ReceiptResponse` for consistent and simplified validation --- api/namex/services/payment/models/__init__.py | 16 ++++++------- api/namex/services/payment/receipts.py | 2 +- .../python/models/test_receipt_response.py | 24 +++++++++++++++---- .../src/namex_pay/resources/worker.py | 18 +++++++------- .../tests/unit/test_payment_token.py | 10 ++++---- 5 files changed, 41 insertions(+), 29 deletions(-) diff --git a/api/namex/services/payment/models/__init__.py b/api/namex/services/payment/models/__init__.py index 23a7433e8..143351b20 100644 --- a/api/namex/services/payment/models/__init__.py +++ b/api/namex/services/payment/models/__init__.py @@ -1,5 +1,7 @@ import dataclasses + from dataclasses import dataclass, field +from pydantic.dataclasses import dataclasses as pydantic_dataclass from datetime import date from .abstract import Serializable @@ -180,7 +182,12 @@ class Receipt(Serializable): receiptNumber: str = '' -@dataclass +class PydanticConfig: + """Pydantic config to ignore extra fields.""" + extra = 'ignore' + + +@pydantic_dataclass(config=PydanticConfig) class ReceiptResponse(Serializable): bcOnlineAccountNumber: str = None filingIdentifier: str = None @@ -189,10 +196,3 @@ class ReceiptResponse(Serializable): paymentMethod: str = '' receiptNumber: str = '' routingSlipNumber: str = '' - - @classmethod - def from_dict(cls, data: dict) -> 'ReceiptResponse': - """Create instance from dict, ignoring unknown fields.""" - valid_fields = {f.name for f in dataclasses.fields(cls)} - filtered = {k: v for k, v in data.items() if k in valid_fields} - return cls(**filtered) diff --git a/api/namex/services/payment/receipts.py b/api/namex/services/payment/receipts.py index 2993238a3..87fe04d5b 100644 --- a/api/namex/services/payment/receipts.py +++ b/api/namex/services/payment/receipts.py @@ -32,7 +32,7 @@ def get_receipt(payment_identifier): api_response = api_instance.get_receipt(payment_identifier) current_app.logger.debug(api_response) - return ReceiptResponse.from_dict(api_response) + return ReceiptResponse(**api_response) except Exception as err: raise SBCPaymentException(err) diff --git a/api/tests/python/models/test_receipt_response.py b/api/tests/python/models/test_receipt_response.py index dc8c7b425..9b9cad299 100644 --- a/api/tests/python/models/test_receipt_response.py +++ b/api/tests/python/models/test_receipt_response.py @@ -28,7 +28,7 @@ def test_init_with_valid_fields(): 'receiptNumber': 'REC-999', 'routingSlipNumber': 'RS-001' } - response = ReceiptResponse.from_dict(data) + response = ReceiptResponse(**data) assert response.bcOnlineAccountNumber == 'BC12345' assert response.filingIdentifier == 'FIL-001' @@ -44,7 +44,7 @@ def test_init_ignores_invalid_fields(): 'unknownField': 'should-be-ignored', 'anotherInvalid': 12345 } - response = ReceiptResponse.from_dict(data) + response = ReceiptResponse(**data) assert response.receiptNumber == 'REC-123' assert not hasattr(response, 'unknownField') @@ -67,7 +67,7 @@ def test_init_with_partial_data(): 'receiptNumber': 'REC-PARTIAL', 'paymentMethod': 'DIRECT_PAY' } - response = ReceiptResponse.from_dict(data) + response = ReceiptResponse(**data) assert response.receiptNumber == 'REC-PARTIAL' assert response.paymentMethod == 'DIRECT_PAY' @@ -81,7 +81,7 @@ def test_init_with_invoice_dict(): 'receiptNumber': 'REC-INV', 'invoice': invoice_data } - response = ReceiptResponse.from_dict(data) + response = ReceiptResponse(**data) assert response.receiptNumber == 'REC-INV' assert response.invoice == invoice_data @@ -92,7 +92,21 @@ def test_init_overwrites_none_defaults(): 'bcOnlineAccountNumber': 'BCOL-NEW', 'filingIdentifier': 'FIL-NEW' } - response = ReceiptResponse.from_dict(data) + response = ReceiptResponse(**data) assert response.bcOnlineAccountNumber == 'BCOL-NEW' assert response.filingIdentifier == 'FIL-NEW' + +def test_init_with_missing_filing_identifier(): + """Assert that missing filingIdentifier results in None default.""" + data = { + 'receiptNumber': 'REC-NO-FILING', + 'paymentMethod': 'CC', + 'invoiceNumber': 'INV-200' + } + response = ReceiptResponse(**data) + + assert response.receiptNumber == 'REC-NO-FILING' + assert response.paymentMethod == 'CC' + assert response.invoiceNumber == 'INV-200' + assert response.filingIdentifier is None diff --git a/services/namex-pay/src/namex_pay/resources/worker.py b/services/namex-pay/src/namex_pay/resources/worker.py index 31d19220f..68a00ac5d 100644 --- a/services/namex-pay/src/namex_pay/resources/worker.py +++ b/services/namex-pay/src/namex_pay/resources/worker.py @@ -16,12 +16,11 @@ The entry-point is the **cb_subscription_handler** """ -import dataclasses import time -from dataclasses import dataclass from datetime import timedelta from enum import Enum from http import HTTPStatus +from pydantic.dataclasses import dataclasses as pydantic_dataclass from typing import Optional import humps @@ -96,7 +95,12 @@ def worker(): return ret # noqa: B012 -@dataclass +class PydanticConfig: + """Pydantic config to ignore extra fields.""" + extra = 'ignore' + + +@pydantic_dataclass(config=PydanticConfig) class PaymentToken: """Payment Token class""" @@ -105,12 +109,6 @@ class PaymentToken: filing_identifier: Optional[str] = None corp_type_code: Optional[str] = None - @classmethod - def from_dict(cls, data: dict) -> 'PaymentToken': - """Create instance from dict, ignoring unknown fields.""" - valid_fields = {f.name for f in dataclasses.fields(cls)} - filtered = {k: v for k, v in data.items() if k in valid_fields} - return cls(**filtered) def get_payment_token(ce: SimpleCloudEvent): """Return a PaymentToken if enclosed in the cloud event.""" @@ -120,7 +118,7 @@ def get_payment_token(ce: SimpleCloudEvent): and isinstance(data, dict) ): converted = humps.decamelize(data) - pt = PaymentToken.from_dict(converted) + pt = PaymentToken(**converted) return pt return None diff --git a/services/namex-pay/tests/unit/test_payment_token.py b/services/namex-pay/tests/unit/test_payment_token.py index 2eb8498c5..091579aad 100644 --- a/services/namex-pay/tests/unit/test_payment_token.py +++ b/services/namex-pay/tests/unit/test_payment_token.py @@ -26,7 +26,7 @@ def test_init_with_all_fields(): 'filing_identifier': 'FIL-001', 'corp_type_code': 'BC' } - token = PaymentToken.from_dict(data) + token = PaymentToken(**data) assert token.id == 'PAY-12345' assert token.status_code == 'COMPLETED' @@ -39,7 +39,7 @@ def test_init_with_partial_fields(): 'id': 'PAY-999', 'status_code': 'PENDING' } - token = PaymentToken.from_dict(data) + token = PaymentToken(**data) assert token.id == 'PAY-999' assert token.status_code == 'PENDING' @@ -54,7 +54,7 @@ def test_init_ignores_invalid_fields(): 'unknown_field': 'should-be-ignored', 'another_invalid': 12345 } - token = PaymentToken.from_dict(data) + token = PaymentToken(**data) assert token.id == 'PAY-123' assert token.status_code == 'COMPLETED' @@ -78,7 +78,7 @@ def test_init_with_none_values(): 'filing_identifier': None, 'corp_type_code': 'NRO' } - token = PaymentToken.from_dict(data) + token = PaymentToken(**data) assert token.id == 'PAY-001' assert token.status_code is None @@ -103,7 +103,7 @@ def test_init_with_integer_id(): 'id': 29590, 'status_code': 'COMPLETED' } - token = PaymentToken.from_dict(data) + token = PaymentToken(**data) assert token.id == 29590 assert token.status_code == 'COMPLETED' From 823cec2f798eb3484f2916dc7c768bb2cd28a8f4 Mon Sep 17 00:00:00 2001 From: Hrvoje Fekete Date: Thu, 15 Jan 2026 13:21:55 -0800 Subject: [PATCH 05/14] test - Add unit tests for `PaymentInvoice` dataclass and refactor to use Pydantic initialization --- api/namex/services/payment/models/__init__.py | 38 ++- .../python/models/test_payment_invoice.py | 237 ++++++++++++++++++ .../src/namex_pay/resources/worker.py | 2 +- 3 files changed, 253 insertions(+), 24 deletions(-) create mode 100644 api/tests/python/models/test_payment_invoice.py diff --git a/api/namex/services/payment/models/__init__.py b/api/namex/services/payment/models/__init__.py index 143351b20..994e3a39d 100644 --- a/api/namex/services/payment/models/__init__.py +++ b/api/namex/services/payment/models/__init__.py @@ -1,7 +1,6 @@ -import dataclasses - from dataclasses import dataclass, field -from pydantic.dataclasses import dataclasses as pydantic_dataclass +from pydantic.dataclasses import dataclass as pydantic_dataclass +from pydantic import Field from datetime import date from .abstract import Serializable @@ -120,7 +119,13 @@ class PaymentRequest(Serializable): details: list = field(default_factory=PaymentDetailItem) -@dataclass +class PydanticConfig: + """Pydantic config to ignore extra fields.""" + extra = 'ignore' + underscore_attrs_are_private = False + + +@pydantic_dataclass(config=PydanticConfig) class PaymentInvoice(Serializable): id: int serviceFees: float @@ -142,25 +147,12 @@ class PaymentInvoice(Serializable): routingSlip: str = '' datNumber: str = '' folioNumber: str = '' - lineItems: list = field(default_factory=list) - receipts: list = field(default_factory=list) - references: list = field(default_factory=list) - details: list = field(default_factory=list) - _links: list = field(default_factory=list) - paymentAccount: dict = field(default_factory=dict) - - def __init__(self, **kwargs): - """Set the attributes only if the field is defined.""" - self.lineItems = [] - self.receipts = [] - self.references = [] - self.details = [] - self._links = [] - self.paymentAccount = {} - names = {f.name for f in dataclasses.fields(self)} - for k, v in kwargs.items(): - if k in names: - setattr(self, k, v) + lineItems: list = Field(default_factory=list) + receipts: list = Field(default_factory=list) + references: list = Field(default_factory=list) + details: list = Field(default_factory=list) + _links: list = Field(default_factory=list) + paymentAccount: dict = Field(default_factory=dict) @dataclass diff --git a/api/tests/python/models/test_payment_invoice.py b/api/tests/python/models/test_payment_invoice.py new file mode 100644 index 000000000..8f07973b9 --- /dev/null +++ b/api/tests/python/models/test_payment_invoice.py @@ -0,0 +1,237 @@ +# Copyright © 2026 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Tests for PaymentInvoice dataclass.""" + +import pytest + +from namex.services.payment.models import PaymentInvoice + + +def test_init_with_valid_fields(): + """Assert that valid fields are set correctly.""" + data = { + 'id': 12345, + 'serviceFees': 1.50, + 'paid': 30.0, + 'refund': 0.0, + 'total': 31.50, + 'statusCode': 'COMPLETED', + 'paymentMethod': 'CC', + 'businessIdentifier': 'NR L000001' + } + invoice = PaymentInvoice(**data) + + assert invoice.id == 12345 + assert invoice.serviceFees == 1.50 + assert invoice.paid == 30.0 + assert invoice.refund == 0.0 + assert invoice.total == 31.50 + assert invoice.statusCode == 'COMPLETED' + assert invoice.paymentMethod == 'CC' + assert invoice.businessIdentifier == 'NR L000001' + + +def test_init_ignores_invalid_fields(): + """Assert that unknown fields are ignored.""" + data = { + 'id': 100, + 'serviceFees': 1.0, + 'paid': 10.0, + 'refund': 0.0, + 'total': 11.0, + 'unknownField': 'should-be-ignored', + 'anotherInvalid': 12345 + } + invoice = PaymentInvoice(**data) + + assert invoice.id == 100 + assert not hasattr(invoice, 'unknownField') + assert not hasattr(invoice, 'anotherInvalid') + + +def test_init_with_default_list_fields(): + """Assert that list fields default to empty lists.""" + data = { + 'id': 200, + 'serviceFees': 1.0, + 'paid': 20.0, + 'refund': 0.0, + 'total': 21.0 + } + invoice = PaymentInvoice(**data) + + assert invoice.lineItems == [] + assert invoice.receipts == [] + assert invoice.references == [] + assert invoice.details == [] + assert invoice._links == [] + + +def test_init_with_default_dict_field(): + """Assert that paymentAccount defaults to empty dict.""" + data = { + 'id': 300, + 'serviceFees': 1.0, + 'paid': 30.0, + 'refund': 0.0, + 'total': 31.0 + } + invoice = PaymentInvoice(**data) + + assert invoice.paymentAccount == {} + + +def test_init_with_payment_account_data(): + """Assert that paymentAccount field accepts dict data.""" + data = { + 'id': 400, + 'serviceFees': 1.5, + 'paid': 40.0, + 'refund': 0.0, + 'total': 41.5, + 'paymentAccount': {'accountId': '2698', 'accountName': 'online banking'} + } + invoice = PaymentInvoice(**data) + + assert invoice.paymentAccount == {'accountId': '2698', 'accountName': 'online banking'} + + +def test_init_with_partial_data(): + """Assert that partial data sets only provided fields.""" + data = { + 'id': 500, + 'serviceFees': 1.0, + 'paid': 50.0, + 'refund': 0.0, + 'total': 51.0, + 'statusCode': 'CREATED' + } + invoice = PaymentInvoice(**data) + + assert invoice.id == 500 + assert invoice.statusCode == 'CREATED' + assert invoice.paymentMethod == '' + assert invoice.businessIdentifier == '' + assert invoice.bcolAccount is None + + +def test_init_with_bcol_account(): + """Assert that bcolAccount field is set correctly.""" + data = { + 'id': 600, + 'serviceFees': 1.0, + 'paid': 60.0, + 'refund': 0.0, + 'total': 61.0, + 'bcolAccount': 123456 + } + invoice = PaymentInvoice(**data) + + assert invoice.bcolAccount == 123456 + + +def test_init_with_is_payment_action_required(): + """Assert that isPaymentActionRequired field defaults correctly.""" + data = { + 'id': 700, + 'serviceFees': 1.0, + 'paid': 70.0, + 'refund': 0.0, + 'total': 71.0 + } + invoice = PaymentInvoice(**data) + + assert invoice.isPaymentActionRequired is False + + +def test_init_with_is_payment_action_required_true(): + """Assert that isPaymentActionRequired can be set to True.""" + data = { + 'id': 800, + 'serviceFees': 1.0, + 'paid': 0.0, + 'refund': 0.0, + 'total': 81.0, + 'isPaymentActionRequired': True + } + invoice = PaymentInvoice(**data) + + assert invoice.isPaymentActionRequired is True + + +def test_init_with_line_items(): + """Assert that lineItems field accepts list data.""" + line_items = [{'description': 'Name Request', 'amount': 30.0}] + data = { + 'id': 900, + 'serviceFees': 1.0, + 'paid': 31.0, + 'refund': 0.0, + 'total': 31.0, + 'lineItems': line_items + } + invoice = PaymentInvoice(**data) + + assert invoice.lineItems == line_items + + +def test_init_with_all_string_fields(): + """Assert that all string fields are set correctly.""" + data = { + 'id': 1000, + 'serviceFees': 1.0, + 'paid': 100.0, + 'refund': 0.0, + 'total': 101.0, + 'statusCode': 'COMPLETED', + 'createdBy': 'user1', + 'createdName': 'Test User', + 'createdOn': '2026-01-15T10:00:00', + 'updatedBy': 'user2', + 'updatedName': 'Update User', + 'updatedOn': '2026-01-15T11:00:00', + 'paymentMethod': 'ONLINE_BANKING', + 'businessIdentifier': 'NR L000002', + 'corpTypeCode': 'NRO', + 'routingSlip': 'RS-001', + 'datNumber': 'DAT-001', + 'folioNumber': 'FOL-001' + } + invoice = PaymentInvoice(**data) + + assert invoice.statusCode == 'COMPLETED' + assert invoice.createdBy == 'user1' + assert invoice.createdName == 'Test User' + assert invoice.createdOn == '2026-01-15T10:00:00' + assert invoice.updatedBy == 'user2' + assert invoice.updatedName == 'Update User' + assert invoice.updatedOn == '2026-01-15T11:00:00' + assert invoice.paymentMethod == 'ONLINE_BANKING' + assert invoice.businessIdentifier == 'NR L000002' + assert invoice.corpTypeCode == 'NRO' + assert invoice.routingSlip == 'RS-001' + assert invoice.datNumber == 'DAT-001' + assert invoice.folioNumber == 'FOL-001' + + +def test_init_missing_mandatory_fields(): + """Assert that missing mandatory fields raises validation error.""" + data = { + 'statusCode': 'COMPLETED', + 'paymentMethod': 'CC' + } + + with pytest.raises(Exception): + PaymentInvoice(**data) + diff --git a/services/namex-pay/src/namex_pay/resources/worker.py b/services/namex-pay/src/namex_pay/resources/worker.py index 68a00ac5d..0f6a9a27a 100644 --- a/services/namex-pay/src/namex_pay/resources/worker.py +++ b/services/namex-pay/src/namex_pay/resources/worker.py @@ -20,7 +20,7 @@ from datetime import timedelta from enum import Enum from http import HTTPStatus -from pydantic.dataclasses import dataclasses as pydantic_dataclass +from pydantic.dataclasses import dataclass as pydantic_dataclass from typing import Optional import humps From 936e997127c42da9a5bd3f1a32df4dcb76615f49 Mon Sep 17 00:00:00 2001 From: Hrvoje Fekete Date: Thu, 15 Jan 2026 14:27:22 -0800 Subject: [PATCH 06/14] refactor - Update `links` field in Pydantic model to use alias "_links" for consistency --- api/namex/services/payment/models/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/namex/services/payment/models/__init__.py b/api/namex/services/payment/models/__init__.py index 994e3a39d..44cd97af2 100644 --- a/api/namex/services/payment/models/__init__.py +++ b/api/namex/services/payment/models/__init__.py @@ -151,7 +151,7 @@ class PaymentInvoice(Serializable): receipts: list = Field(default_factory=list) references: list = Field(default_factory=list) details: list = Field(default_factory=list) - _links: list = Field(default_factory=list) + links: list = Field(default_factory=list, alias="_links") paymentAccount: dict = Field(default_factory=dict) From 18ee73efb0e2d6a6979b6a73ff2615a7e7903d45 Mon Sep 17 00:00:00 2001 From: Hrvoje Fekete Date: Thu, 15 Jan 2026 14:58:11 -0800 Subject: [PATCH 07/14] ruff fixes --- api/namex/services/payment/models/__init__.py | 2 +- services/namex-pay/src/namex_pay/resources/worker.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/api/namex/services/payment/models/__init__.py b/api/namex/services/payment/models/__init__.py index 44cd97af2..aff89b247 100644 --- a/api/namex/services/payment/models/__init__.py +++ b/api/namex/services/payment/models/__init__.py @@ -151,7 +151,7 @@ class PaymentInvoice(Serializable): receipts: list = Field(default_factory=list) references: list = Field(default_factory=list) details: list = Field(default_factory=list) - links: list = Field(default_factory=list, alias="_links") + links: list = Field(default_factory=list, alias='_links') paymentAccount: dict = Field(default_factory=dict) diff --git a/services/namex-pay/src/namex_pay/resources/worker.py b/services/namex-pay/src/namex_pay/resources/worker.py index 0f6a9a27a..ddffaf91f 100644 --- a/services/namex-pay/src/namex_pay/resources/worker.py +++ b/services/namex-pay/src/namex_pay/resources/worker.py @@ -20,7 +20,6 @@ from datetime import timedelta from enum import Enum from http import HTTPStatus -from pydantic.dataclasses import dataclass as pydantic_dataclass from typing import Optional import humps @@ -29,6 +28,7 @@ from namex.models import Request as RequestDAO # noqa:I001; import orders from namex.services import EventRecorder, queue # noqa:I005; from namex.services.name_request.name_request_state import is_reapplication_eligible +from pydantic.dataclasses import dataclass as pydantic_dataclass from sbc_common_components.utils.enums import QueueMessageTypes from simple_cloudevent import SimpleCloudEvent from sqlalchemy.exc import OperationalError From 27bd39bf085896669a3f7cbe9a7d1dc07feda54c Mon Sep 17 00:00:00 2001 From: Hrvoje Fekete Date: Thu, 15 Jan 2026 15:40:32 -0800 Subject: [PATCH 08/14] test/refactor - Add `invoice` field support in `ReceiptResponse` with tests and update `links` alias logic --- api/namex/services/payment/models/__init__.py | 23 +++++++++++-------- .../python/models/test_receipt_response.py | 3 +++ 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/api/namex/services/payment/models/__init__.py b/api/namex/services/payment/models/__init__.py index 44cd97af2..b0b8a75de 100644 --- a/api/namex/services/payment/models/__init__.py +++ b/api/namex/services/payment/models/__init__.py @@ -1,4 +1,6 @@ from dataclasses import dataclass, field +from typing import Optional, Union + from pydantic.dataclasses import dataclass as pydantic_dataclass from pydantic import Field from datetime import date @@ -151,9 +153,17 @@ class PaymentInvoice(Serializable): receipts: list = Field(default_factory=list) references: list = Field(default_factory=list) details: list = Field(default_factory=list) - links: list = Field(default_factory=list, alias="_links") + links: list = Field(default_factory=list) paymentAccount: dict = Field(default_factory=dict) + @property + def _links(self) -> list: + return self.links + + @_links.setter + def _links(self, value: list) -> None: + self.links = value + @dataclass class ReceiptRequest(Serializable): @@ -174,16 +184,11 @@ class Receipt(Serializable): receiptNumber: str = '' -class PydanticConfig: - """Pydantic config to ignore extra fields.""" - extra = 'ignore' - - @pydantic_dataclass(config=PydanticConfig) class ReceiptResponse(Serializable): - bcOnlineAccountNumber: str = None - filingIdentifier: str = None - invoice: PaymentInvoice = field(default=PaymentInvoice) + bcOnlineAccountNumber: Optional[str] = None + filingIdentifier: Optional[str] = None + invoice: Optional[Union[PaymentInvoice, dict]] = None invoiceNumber: str = '' paymentMethod: str = '' receiptNumber: str = '' diff --git a/api/tests/python/models/test_receipt_response.py b/api/tests/python/models/test_receipt_response.py index 9b9cad299..bfa9955cd 100644 --- a/api/tests/python/models/test_receipt_response.py +++ b/api/tests/python/models/test_receipt_response.py @@ -17,12 +17,14 @@ from namex.services.payment.models import PaymentInvoice, ReceiptResponse +invoice = {'id': 100, 'total': 30.0, 'paid': 30.0, 'refund': 0.0, 'serviceFees': 1.5} def test_init_with_valid_fields(): """Assert that valid fields are set correctly.""" data = { 'bcOnlineAccountNumber': 'BC12345', 'filingIdentifier': 'FIL-001', + 'invoice': invoice, 'invoiceNumber': 'INV-100', 'paymentMethod': 'CC', 'receiptNumber': 'REC-999', @@ -40,6 +42,7 @@ def test_init_with_valid_fields(): def test_init_ignores_invalid_fields(): """Assert that unknown fields are ignored.""" data = { + 'invoice': invoice, 'receiptNumber': 'REC-123', 'unknownField': 'should-be-ignored', 'anotherInvalid': 12345 From 8c138814a0c02b258f6fdef85d52b51c3cb3b10e Mon Sep 17 00:00:00 2001 From: Hrvoje Fekete Date: Fri, 16 Jan 2026 09:57:55 -0800 Subject: [PATCH 09/14] refactor/tests - Update DB connection logic, add refund field, and improve type handling in tests --- api/config.py | 5 ++++- api/tests/conftest.py | 9 +++++++++ .../end_points/payments/test_payments.py | 20 +++++++++++++++++++ .../python/services/payment/test_models.py | 1 + services/namex-pay/config.py | 6 +++++- .../tests/unit/test_payment_token.py | 2 +- services/namex-pay/tests/unit/test_worker.py | 2 +- 7 files changed, 41 insertions(+), 4 deletions(-) diff --git a/api/config.py b/api/config.py index c3b52dee3..40f38d066 100644 --- a/api/config.py +++ b/api/config.py @@ -142,7 +142,10 @@ class TestConfig(Config): # pylint: disable=too-few-public-methods DB_NAME = os.getenv('DATABASE_TEST_NAME', 'unittesting') DB_HOST = os.getenv('DATABASE_TEST_HOST', 'localhost') DB_PORT = os.getenv('DATABASE_TEST_PORT', '54345') - SQLALCHEMY_DATABASE_URI = f'postgresql+pg8000://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{int(DB_PORT)}/{DB_NAME}' + if os.getenv('TEST_USE_LOCAL_DB', False): + SQLALCHEMY_DATABASE_URI = f'postgresql+psycopg2://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{int(DB_PORT)}/{DB_NAME}' + else: + SQLALCHEMY_DATABASE_URI = f'postgresql+pg8000://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{int(DB_PORT)}/{DB_NAME}' # Ensure SQLAlchemy is properly configured for Flask-Marshmallow compatibility SQLALCHEMY_TRACK_MODIFICATIONS = False diff --git a/api/tests/conftest.py b/api/tests/conftest.py index e4988246e..5e21e5b17 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -70,6 +70,15 @@ def client_ctx(app): yield c +def test_print_db_connection_string(app): + """Debug test to print the database connection string.""" + with app.app_context(): + from namex.models import db + print(f"\n\nDatabase URI: {db.engine.url}\n") + # Or from config: + print(f"Config SQLALCHEMY_DATABASE_URI: {app.config.get('SQLALCHEMY_DATABASE_URI')}\n\n") + assert True # Just to make it a valid test + @pytest.fixture(scope='session') def db(app, request): """ diff --git a/api/tests/python/end_points/payments/test_payments.py b/api/tests/python/end_points/payments/test_payments.py index f0fab4fc9..2f6caa7a8 100644 --- a/api/tests/python/end_points/payments/test_payments.py +++ b/api/tests/python/end_points/payments/test_payments.py @@ -589,6 +589,26 @@ def mock_publish(topic: str, payload: bytes): 'total': 31.5, }, ) + + # Mock the get_payment API call that happens during payment completion + mocker.patch.object( + SBCPaymentClient, + 'get_payment', + return_value={ + 'id': 1, + 'serviceFees': 1.5, + 'paid': 31.5, + 'refund': 0.0, + 'total': 31.5, + 'isPaymentActionRequired': False, + 'statusCode': 'CREATED', + 'businessIdentifier': 'NR L000001', + 'createdOn': '2021-01-14T23:52:05.531317+00:00', + 'lineItems': [{'filingTypeCode': 'NM620', 'priority': False, 'waiveFees': False}], + 'references': [], + }, + ) + payment = execute_payment(client, jwt, create_payment_request, action) assert payment['action'] == action if complete_payment: diff --git a/api/tests/python/services/payment/test_models.py b/api/tests/python/services/payment/test_models.py index adc8c2225..9ef88c255 100644 --- a/api/tests/python/services/payment/test_models.py +++ b/api/tests/python/services/payment/test_models.py @@ -21,6 +21,7 @@ def test_payment_invoice_model(session): 'corpTypeCode': 'NRO', 'id': 11801, 'paid': 0.0, + 'refund': 0.0, 'paymentAccount': {'accountId': '2698', 'accountName': 'online banking 13.1'}, 'paymentMethod': 'ONLINE_BANKING', 'serviceFees': 1.5, diff --git a/services/namex-pay/config.py b/services/namex-pay/config.py index 846e4ce9d..a724efc26 100644 --- a/services/namex-pay/config.py +++ b/services/namex-pay/config.py @@ -109,7 +109,11 @@ class TestConfig(Config): # pylint: disable=too-few-public-methods DB_NAME = os.getenv('DATABASE_TEST_NAME', 'unittesting') DB_HOST = os.getenv('DATABASE_TEST_HOST', 'localhost') DB_PORT = os.getenv('DATABASE_TEST_PORT', '5432') - SQLALCHEMY_DATABASE_URI = f'postgresql+pg8000://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{int(DB_PORT)}/{DB_NAME}' + if os.getenv('TEST_USE_LOCAL_DB', False): + SQLALCHEMY_DATABASE_URI = f'postgresql+psycopg://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{int(DB_PORT)}/{DB_NAME}' + else: + SQLALCHEMY_DATABASE_URI = f'postgresql+pg8000://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{int(DB_PORT)}/{DB_NAME}' + EMAILER_TOPIC = os.getenv('NAMEX_MAILER_TOPIC', '') NAMEX_NR_STATE_TOPIC = os.getenv('NAMEX_NR_STATE_TOPIC', '') diff --git a/services/namex-pay/tests/unit/test_payment_token.py b/services/namex-pay/tests/unit/test_payment_token.py index 091579aad..7b7c60ebe 100644 --- a/services/namex-pay/tests/unit/test_payment_token.py +++ b/services/namex-pay/tests/unit/test_payment_token.py @@ -105,5 +105,5 @@ def test_init_with_integer_id(): } token = PaymentToken(**data) - assert token.id == 29590 + assert token.id == "29590" assert token.status_code == 'COMPLETED' diff --git a/services/namex-pay/tests/unit/test_worker.py b/services/namex-pay/tests/unit/test_worker.py index 070f23c7f..ec6bfe5ef 100644 --- a/services/namex-pay/tests/unit/test_worker.py +++ b/services/namex-pay/tests/unit/test_worker.py @@ -87,7 +87,7 @@ def test_get_payment_token(): ce = SimpleCloudEvent(**ce_dict) payment_token = get_payment_token(ce) assert payment_token - assert payment_token.id == ce_dict['data']['id'] + assert payment_token.id == str(ce_dict['data']['id']) # wrong type ce_dict = deepcopy(CLOUD_EVENT_TEMPLATE) From 7842c845ff124e0d44694d678c18da694f5f0800 Mon Sep 17 00:00:00 2001 From: Hrvoje Fekete Date: Fri, 16 Jan 2026 10:02:38 -0800 Subject: [PATCH 10/14] ruff cleanup --- api/tests/conftest.py | 10 ---------- api/tests/python/end_points/payments/test_payments.py | 4 ++-- services/namex-pay/tests/unit/test_payment_token.py | 2 +- 3 files changed, 3 insertions(+), 13 deletions(-) diff --git a/api/tests/conftest.py b/api/tests/conftest.py index 5e21e5b17..1726baa6b 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -69,16 +69,6 @@ def client_ctx(app): with app.test_client() as c: yield c - -def test_print_db_connection_string(app): - """Debug test to print the database connection string.""" - with app.app_context(): - from namex.models import db - print(f"\n\nDatabase URI: {db.engine.url}\n") - # Or from config: - print(f"Config SQLALCHEMY_DATABASE_URI: {app.config.get('SQLALCHEMY_DATABASE_URI')}\n\n") - assert True # Just to make it a valid test - @pytest.fixture(scope='session') def db(app, request): """ diff --git a/api/tests/python/end_points/payments/test_payments.py b/api/tests/python/end_points/payments/test_payments.py index 2f6caa7a8..d5ebd4b41 100644 --- a/api/tests/python/end_points/payments/test_payments.py +++ b/api/tests/python/end_points/payments/test_payments.py @@ -589,7 +589,7 @@ def mock_publish(topic: str, payload: bytes): 'total': 31.5, }, ) - + # Mock the get_payment API call that happens during payment completion mocker.patch.object( SBCPaymentClient, @@ -608,7 +608,7 @@ def mock_publish(topic: str, payload: bytes): 'references': [], }, ) - + payment = execute_payment(client, jwt, create_payment_request, action) assert payment['action'] == action if complete_payment: diff --git a/services/namex-pay/tests/unit/test_payment_token.py b/services/namex-pay/tests/unit/test_payment_token.py index 7b7c60ebe..dd1ac9982 100644 --- a/services/namex-pay/tests/unit/test_payment_token.py +++ b/services/namex-pay/tests/unit/test_payment_token.py @@ -105,5 +105,5 @@ def test_init_with_integer_id(): } token = PaymentToken(**data) - assert token.id == "29590" + assert token.id == '29590' assert token.status_code == 'COMPLETED' From a5090853974a560a954df20519df27603f932e8b Mon Sep 17 00:00:00 2001 From: Hrvoje Fekete Date: Fri, 16 Jan 2026 10:15:15 -0800 Subject: [PATCH 11/14] test - Enhance `PaymentInvoice` tests to validate numeric types and use `pytest.approx` for floating-point assertions --- api/tests/python/models/test_payment_invoice.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/api/tests/python/models/test_payment_invoice.py b/api/tests/python/models/test_payment_invoice.py index 8f07973b9..4ebd9a286 100644 --- a/api/tests/python/models/test_payment_invoice.py +++ b/api/tests/python/models/test_payment_invoice.py @@ -14,6 +14,7 @@ """Tests for PaymentInvoice dataclass.""" import pytest +from pytest import approx from namex.services.payment.models import PaymentInvoice @@ -33,10 +34,14 @@ def test_init_with_valid_fields(): invoice = PaymentInvoice(**data) assert invoice.id == 12345 - assert invoice.serviceFees == 1.50 - assert invoice.paid == 30.0 - assert invoice.refund == 0.0 - assert invoice.total == 31.50 + assert isinstance(invoice.serviceFees, (int, float)) + assert isinstance(invoice.paid, (int, float)) + assert isinstance(invoice.refund, (int, float)) + assert isinstance(invoice.total, (int, float)) + assert invoice.serviceFees == approx(1.50) + assert invoice.paid == approx(30.0) + assert invoice.refund == approx(0.0) + assert invoice.total == approx(31.50) assert invoice.statusCode == 'COMPLETED' assert invoice.paymentMethod == 'CC' assert invoice.businessIdentifier == 'NR L000001' From c702f9e8d355d2d3f2378590d0b0ef32396e85ae Mon Sep 17 00:00:00 2001 From: Hrvoje Fekete Date: Fri, 16 Jan 2026 10:18:14 -0800 Subject: [PATCH 12/14] test/refactor - Replace hardcoded assertions in `test_payment_invoice` with dynamic checks using `data` dictionary --- api/tests/python/models/test_payment_invoice.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/api/tests/python/models/test_payment_invoice.py b/api/tests/python/models/test_payment_invoice.py index 8f07973b9..8e5687a44 100644 --- a/api/tests/python/models/test_payment_invoice.py +++ b/api/tests/python/models/test_payment_invoice.py @@ -32,14 +32,14 @@ def test_init_with_valid_fields(): } invoice = PaymentInvoice(**data) - assert invoice.id == 12345 - assert invoice.serviceFees == 1.50 - assert invoice.paid == 30.0 - assert invoice.refund == 0.0 - assert invoice.total == 31.50 - assert invoice.statusCode == 'COMPLETED' - assert invoice.paymentMethod == 'CC' - assert invoice.businessIdentifier == 'NR L000001' + assert invoice.id == data['id'] + assert invoice.serviceFees == data['serviceFees'] + assert invoice.paid == data['paid'] + assert invoice.refund == data['refund'] + assert invoice.total == data['total'] + assert invoice.statusCode == data['statusCode'] + assert invoice.paymentMethod == data['paymentMethod'] + assert invoice.businessIdentifier == data['businessIdentifier'] def test_init_ignores_invalid_fields(): From ea5a73cdeb0cdf7e047202e1966d5f4cd582b7d6 Mon Sep 17 00:00:00 2001 From: Hrvoje Fekete Date: Mon, 19 Jan 2026 13:58:51 -0800 Subject: [PATCH 13/14] test - Add `PaymentRefundInvoice` dataclass, update `refund_payment` logic, and add unit tests --- api/namex/services/payment/models/__init__.py | 9 ++ api/namex/services/payment/payments.py | 10 +- .../services/payment/test_refund_payment.py | 105 ++++++++++++++++++ 3 files changed, 121 insertions(+), 3 deletions(-) create mode 100644 api/tests/python/services/payment/test_refund_payment.py diff --git a/api/namex/services/payment/models/__init__.py b/api/namex/services/payment/models/__init__.py index b0b8a75de..efacaf86f 100644 --- a/api/namex/services/payment/models/__init__.py +++ b/api/namex/services/payment/models/__init__.py @@ -1,5 +1,6 @@ from dataclasses import dataclass, field from typing import Optional, Union +from decimal import Decimal from pydantic.dataclasses import dataclass as pydantic_dataclass from pydantic import Field @@ -127,6 +128,14 @@ class PydanticConfig: underscore_attrs_are_private = False +@pydantic_dataclass(config=PydanticConfig) +class PaymentRefundInvoice: + refundId: int + refundAmount: Decimal + message: str + isPartialRefund: bool + + @pydantic_dataclass(config=PydanticConfig) class PaymentInvoice(Serializable): id: int diff --git a/api/namex/services/payment/payments.py b/api/namex/services/payment/payments.py index b85a1a36e..075313b65 100644 --- a/api/namex/services/payment/payments.py +++ b/api/namex/services/payment/payments.py @@ -4,7 +4,7 @@ from .client import SBCPaymentClient from .exceptions import SBCPaymentException -from .models import PaymentInvoice +from .models import PaymentInvoice, PaymentRefundInvoice def get_payment(payment_identifier): @@ -38,8 +38,12 @@ def refund_payment(payment_identifier, model=None): data = model api_instance = SBCPaymentClient() api_response = api_instance.refund_payment(payment_identifier, data) - current_app.logger.debug(api_response) - return PaymentInvoice(**api_response) if api_response else None + current_app.logger.debug( + "services refund_payment response", + payment_identifier=payment_identifier, + api_response=api_response, + ) + return PaymentRefundInvoice(**api_response) if api_response else None except Exception as err: raise SBCPaymentException(err) diff --git a/api/tests/python/services/payment/test_refund_payment.py b/api/tests/python/services/payment/test_refund_payment.py new file mode 100644 index 000000000..419d5bcc9 --- /dev/null +++ b/api/tests/python/services/payment/test_refund_payment.py @@ -0,0 +1,105 @@ +# Copyright © 2026 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Tests for PaymentRefundInvoice dataclass.""" + +from decimal import Decimal +from unittest.mock import patch, MagicMock + +import pytest +from namex.services.payment.exceptions import SBCPaymentException +from namex.services.payment.models import PaymentRefundInvoice +from namex.services.payment.payments import refund_payment + + +@pytest.fixture +def mock_client(): + with patch('namex.services.payment.payments.SBCPaymentClient') as mock_client: + yield mock_client + + +@pytest.fixture +def valid_response_data(): + return { + "refundId": 1234, + "refundAmount": Decimal("100.00"), + "message": "Refund processed successfully.", + "isPartialRefund": False + } + + +def test_refund_payment_success(mock_client, valid_response_data): + mock_instance = MagicMock() + mock_client.return_value = mock_instance + mock_instance.refund_payment.return_value = valid_response_data + + payment_identifier = "valid-payment-id" + model = {"reason": "Customer request"} + + response = refund_payment(payment_identifier, model) + + expected_response = PaymentRefundInvoice(**valid_response_data) + assert response == expected_response + mock_instance.refund_payment.assert_called_once_with(payment_identifier, model) + + +def test_refund_payment_no_response(mock_client): + mock_instance = MagicMock() + mock_client.return_value = mock_instance + mock_instance.refund_payment.return_value = None + + payment_identifier = "valid-payment-id" + model = {"reason": "Customer request"} + + response = refund_payment(payment_identifier, model) + + assert response is None + mock_instance.refund_payment.assert_called_once_with(payment_identifier, model) + + +def test_refund_payment_raises_exception(mock_client): + mock_instance = MagicMock() + mock_client.return_value = mock_instance + mock_instance.refund_payment.side_effect = Exception("SBC Pay API exception.") + + payment_identifier = "invalid-payment-id" + model = {"reason": "Invalid request"} + + with pytest.raises(SBCPaymentException, match="SBC Pay API exception."): + refund_payment(payment_identifier, model) + mock_instance.refund_payment.assert_called_once_with(payment_identifier, model) + + +def test_refund_payment_with_extra_response_data(mock_client, valid_response_data): + mock_instance = MagicMock() + mock_client.return_value = mock_instance + + response_with_extra_fields = { + **valid_response_data, + "extraField": "should-be-ignored", + "anotherUnknownField": 9999, + "nestedExtra": {"key": "value"} + } + mock_instance.refund_payment.return_value = response_with_extra_fields + + payment_identifier = "valid-payment-id" + model = {"reason": "Customer request"} + + response = refund_payment(payment_identifier, model) + + expected_response = PaymentRefundInvoice(**valid_response_data) + assert response == expected_response + assert not hasattr(response, 'extraField') + assert not hasattr(response, 'anotherUnknownField') + assert not hasattr(response, 'nestedExtra') + mock_instance.refund_payment.assert_called_once_with(payment_identifier, model) From 46db31752f1b4a858a569fb5791ae359dc530e40 Mon Sep 17 00:00:00 2001 From: Hrvoje Fekete Date: Wed, 28 Jan 2026 14:22:46 -0800 Subject: [PATCH 14/14] refactor - Comment out replacements for 'british columbia' variations in Solr analytics processing --- api/namex/analytics/solr.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/api/namex/analytics/solr.py b/api/namex/analytics/solr.py index 1d31990b0..95fc4862d 100644 --- a/api/namex/analytics/solr.py +++ b/api/namex/analytics/solr.py @@ -1279,14 +1279,14 @@ def name_pre_processing(cls, name): .replace('$', 's') .replace(' ¢ ', 'cent') .replace('¢', 'c') - .replace('britishcolumbia', 'bc') - .replace('britishcolumbias', 'bc') - .replace('britishcolumbian', 'bc') - .replace('britishcolumbians', 'bc') - .replace('british columbia', 'bc') - .replace('british columbias', 'bc') - .replace('british columbian', 'bc') - .replace('british columbians', 'bc') + # .replace('britishcolumbia', 'bc') + # .replace('britishcolumbias', 'bc') + # .replace('britishcolumbian', 'bc') + # .replace('britishcolumbians', 'bc') + # .replace('british columbia', 'bc') + # .replace('british columbias', 'bc') + # .replace('british columbian', 'bc') + # .replace('british columbians', 'bc') ) return processed_name.strip()