diff --git a/auth-api/poetry.lock b/auth-api/poetry.lock index b97ecf8ec..6dbd7cbde 100644 --- a/auth-api/poetry.lock +++ b/auth-api/poetry.lock @@ -3290,14 +3290,14 @@ zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] [[package]] name = "werkzeug" -version = "3.1.5" +version = "3.1.6" description = "The comprehensive WSGI web application library." optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "werkzeug-3.1.5-py3-none-any.whl", hash = "sha256:5111e36e91086ece91f93268bb39b4a35c1e6f1feac762c9c822ded0a4e322dc"}, - {file = "werkzeug-3.1.5.tar.gz", hash = "sha256:6a548b0e88955dd07ccb25539d7d0cc97417ee9e179677d22c7041c8f078ce67"}, + {file = "werkzeug-3.1.6-py3-none-any.whl", hash = "sha256:7ddf3357bb9564e407607f988f683d72038551200c704012bb9a4c523d42f131"}, + {file = "werkzeug-3.1.6.tar.gz", hash = "sha256:210c6bede5a420a913956b4791a7f4d6843a43b6fcee4dfa08a65e93007d0d25"}, ] [package.dependencies] diff --git a/auth-api/src/auth_api/services/products.py b/auth-api/src/auth_api/services/products.py index 25b2e3149..dd1a82a68 100644 --- a/auth-api/src/auth_api/services/products.py +++ b/auth-api/src/auth_api/services/products.py @@ -56,6 +56,7 @@ get_product_notification_data, get_product_notification_type, ) +from auth_api.utils.pay import get_account_fees from auth_api.utils.roles import CLIENT_ADMIN_ROLES, CLIENT_AUTH_ROLES, GOV_ORG_TYPES, STAFF from auth_api.utils.user_context import UserContext, user_context @@ -159,13 +160,14 @@ def resubmit_product_subscription(org_id, subscription_data: dict[str, Any], ski @staticmethod def _check_gov_org_add_product_previously_approved( org_id: int, - product_code: str + product_code: str, + account_fees: list[str] ) -> tuple[bool, Any]: """Check if GOV org's account fee product was previously approved (NEW_PRODUCT_FEE_REVIEW task).""" inactive_sub = ProductSubscriptionModel.find_by_org_id_product_code( org_id=org_id, product_code=product_code, valid_statuses=(ProductSubscriptionStatus.INACTIVE.value,) ) - if not inactive_sub: + if not inactive_sub or product_code not in account_fees: return False, None task_add_product = TaskModel.find_by_task_relationship_id( inactive_sub.id, TaskRelationshipType.PRODUCT.value, TaskStatus.COMPLETED.value @@ -233,6 +235,7 @@ def create_product_subscription( check_auth(one_of_roles=(*CLIENT_ADMIN_ROLES, STAFF), org_id=org_id) subscriptions_list = subscription_data.get("subscriptions") + account_fees = get_account_fees(org) for subscription in subscriptions_list: auto_approve_current = auto_approve product_code = subscription.get("productCode") @@ -246,7 +249,7 @@ def create_product_subscription( if org.access_type in GOV_ORG_TYPES and not staff_review_for_create_org: previously_approved, inactive_sub = Product._check_gov_org_add_product_previously_approved( - org.id, product_code + org.id, product_code, account_fees ) else: previously_approved, inactive_sub = Product._is_previously_approved(org_id, product_code) diff --git a/auth-api/src/auth_api/utils/pay.py b/auth-api/src/auth_api/utils/pay.py new file mode 100644 index 000000000..4673ed748 --- /dev/null +++ b/auth-api/src/auth_api/utils/pay.py @@ -0,0 +1,50 @@ +# 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. +"""Pay API utility functions.""" + +from flask import current_app + +from auth_api.exceptions.errors import Error +from auth_api.models import Org as OrgModel +from auth_api.services.rest_service import RestService +from auth_api.utils.roles import GOV_ORG_TYPES + + +def get_account_fees(org: OrgModel) -> list[str]: + """Fetch all account fees from pay-api and return a dict mapping product codes to fee existence.""" + if org.access_type not in GOV_ORG_TYPES: + return [] + pay_url = current_app.config.get("PAY_API_URL") + account_fees = [] + + try: + token = RestService.get_service_account_token() + response = RestService.get(endpoint=f"{pay_url}/accounts/{org.id}/fees", token=token, retry_on_failure=True) + + if response and response.status_code == 200: + response_data = response.json() + account_fees_obj = response_data.get("accountFees", []) + + for fee in account_fees_obj: + product_code = fee.get("product") + if product_code: + account_fees.append(product_code) + return account_fees + except Exception as e: # NOQA # pylint: disable=broad-except + # Log the error but don't fail the subscription creation + # Return empty dict so subscription can proceed without fee-based review logic + current_app.logger.info(f"{Error.ACCOUNT_FEES_FETCH_FAILED} for org {org.id}: {e}") + return [] + + return account_fees diff --git a/auth-api/tests/conftest.py b/auth-api/tests/conftest.py index a050cadb1..77cc19b55 100644 --- a/auth-api/tests/conftest.py +++ b/auth-api/tests/conftest.py @@ -177,6 +177,7 @@ def auth_mock(monkeypatch): monkeypatch.setattr("auth_api.services.affiliation_invitation.check_auth", lambda *args, **kwargs: None) # noqa: ARG005 + @pytest.fixture() def notify_mock(monkeypatch): """Mock send_email.""" diff --git a/auth-api/tests/unit/api/test_org.py b/auth-api/tests/unit/api/test_org.py index a37676b46..763695d04 100644 --- a/auth-api/tests/unit/api/test_org.py +++ b/auth-api/tests/unit/api/test_org.py @@ -79,6 +79,7 @@ factory_user_model, patch_pay_account_delete, patch_pay_account_delete_error, + patch_pay_account_fees, patch_pay_account_post, ) @@ -569,6 +570,7 @@ def test_create_govn_org_with_products_single_staff_review_task(client, jwt, ses after approval. """ patch_pay_account_post(monkeypatch) + patch_pay_account_fees(monkeypatch, [ProductCode.BUSINESS_SEARCH.value, ProductCode.PPR.value]) headers = factory_auth_header(jwt=jwt, claims=TestJwtClaims.public_user_role) client.post("/api/v1/users", headers=headers, content_type="application/json") diff --git a/auth-api/tests/unit/services/test_pay.py b/auth-api/tests/unit/services/test_pay.py new file mode 100644 index 000000000..7f3c5ec86 --- /dev/null +++ b/auth-api/tests/unit/services/test_pay.py @@ -0,0 +1,52 @@ +"""Tests for the pay utility functions.""" + +# 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. + +from unittest.mock import Mock + +from flask import current_app + +from auth_api.utils.enums import AccessType, ProductCode +from auth_api.utils.pay import get_account_fees +from tests.conftest import mock_token +from tests.utilities.factory_utils import factory_org_model + + +def test_get_account_fees_govm_org_success(monkeypatch, session): # pylint:disable=unused-argument + """Test that GOVM org with successful response returns list of product codes.""" + org = factory_org_model(org_info={"name": "Org 1", "accessType": AccessType.GOVM.value}) + + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "accountFees": [ + {"product": ProductCode.BUSINESS.value}, + {"product": ProductCode.VS.value}, + {"product": ProductCode.BCA.value}, + ] + } + + current_app.config["PAY_API_URL"] = "http://pay-api.test" + + monkeypatch.setattr("auth_api.utils.pay.RestService.get_service_account_token", mock_token) + monkeypatch.setattr("auth_api.utils.pay.RestService.get", lambda *args, **kwargs: mock_response) # noqa: ARG005 + + result = get_account_fees(org) + + assert result == [ + ProductCode.BUSINESS.value, + ProductCode.VS.value, + ProductCode.BCA.value, + ] diff --git a/auth-api/tests/utilities/factory_utils.py b/auth-api/tests/utilities/factory_utils.py index cff10bee0..92424cd59 100644 --- a/auth-api/tests/utilities/factory_utils.py +++ b/auth-api/tests/utilities/factory_utils.py @@ -534,3 +534,20 @@ def keycloak_delete_user_by_username(username): delete_user_url = f"{base_url}/auth/admin/realms/{realm}/users/{user.id}" response = requests.delete(delete_user_url, headers=headers, timeout=timeout) response.raise_for_status() + + +def patch_pay_account_fees(monkeypatch, product_codes: list[str]): + """Patch GET /accounts/{id}/fees to return given product codes.""" + class MockFeeResponse: + status_code = 200 + def json(self): + return {"accountFees": [{"product": c} for c in product_codes]} + + monkeypatch.setattr( + "auth_api.utils.pay.RestService.get", + lambda *_args, **_kwargs: MockFeeResponse(), + ) + monkeypatch.setattr( + "auth_api.utils.pay.RestService.get_service_account_token", + lambda *_args, **_kwargs: "mock-token", + ) diff --git a/queue_services/account-mailer/poetry.lock b/queue_services/account-mailer/poetry.lock index f16578da9..42bb40a3f 100644 --- a/queue_services/account-mailer/poetry.lock +++ b/queue_services/account-mailer/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. [[package]] name = "aiofiles" @@ -3241,14 +3241,14 @@ zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] [[package]] name = "werkzeug" -version = "3.1.5" +version = "3.1.6" description = "The comprehensive WSGI web application library." optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "werkzeug-3.1.5-py3-none-any.whl", hash = "sha256:5111e36e91086ece91f93268bb39b4a35c1e6f1feac762c9c822ded0a4e322dc"}, - {file = "werkzeug-3.1.5.tar.gz", hash = "sha256:6a548b0e88955dd07ccb25539d7d0cc97417ee9e179677d22c7041c8f078ce67"}, + {file = "werkzeug-3.1.6-py3-none-any.whl", hash = "sha256:7ddf3357bb9564e407607f988f683d72038551200c704012bb9a4c523d42f131"}, + {file = "werkzeug-3.1.6.tar.gz", hash = "sha256:210c6bede5a420a913956b4791a7f4d6843a43b6fcee4dfa08a65e93007d0d25"}, ] [package.dependencies]