diff --git a/auth-api/src/auth_api/exceptions/errors.py b/auth-api/src/auth_api/exceptions/errors.py index 6c460615b..f6a6f83ef 100644 --- a/auth-api/src/auth_api/exceptions/errors.py +++ b/auth-api/src/auth_api/exceptions/errors.py @@ -122,6 +122,7 @@ class Error(Enum): ) BCEID_USERS_CANT_BE_OWNERS = "BCEID Users cant be owners", HTTPStatus.BAD_REQUEST PAYMENT_ACCOUNT_UPSERT_FAILED = "Account upsert failed in Pay", HTTPStatus.INTERNAL_SERVER_ERROR + ACCOUNT_FEES_FETCH_FAILED = "Failed to fetch account fees from Pay API", HTTPStatus.INTERNAL_SERVER_ERROR GOVM_ACCOUNT_DATA_MISSING = ( "GOVM account creation needs payment info , gl code and mailing address", HTTPStatus.BAD_REQUEST, diff --git a/auth-api/src/auth_api/models/dataclass.py b/auth-api/src/auth_api/models/dataclass.py index 92a7bffaf..ab3e08ab7 100644 --- a/auth-api/src/auth_api/models/dataclass.py +++ b/auth-api/src/auth_api/models/dataclass.py @@ -182,6 +182,7 @@ class TaskSearch: # pylint: disable=too-many-instance-attributes submitted_sort_order: str = "asc" page: int = 1 limit: int = 10 + action: str = "" @dataclass @@ -200,6 +201,7 @@ class ProductReviewTask: org_id: str org_name: str + org_access_type: str product_code: str product_description: str product_subscription_id: int diff --git a/auth-api/src/auth_api/models/task.py b/auth-api/src/auth_api/models/task.py index 23bd4c8a4..61de8d1fa 100644 --- a/auth-api/src/auth_api/models/task.py +++ b/auth-api/src/auth_api/models/task.py @@ -66,6 +66,8 @@ def fetch_tasks(cls, task_search: TaskSearch): query = query.filter(Task.type == task_search.type) if task_search.status: query = query.filter(Task.status.in_(task_search.status)) + if task_search.action: + query = query.filter(Task.action.in_(task_search.action)) start_date = None end_date = None if task_search.start_date: diff --git a/auth-api/src/auth_api/resources/v1/task.py b/auth-api/src/auth_api/resources/v1/task.py index b47738c68..da2212834 100644 --- a/auth-api/src/auth_api/resources/v1/task.py +++ b/auth-api/src/auth_api/resources/v1/task.py @@ -46,6 +46,7 @@ def get_tasks(): relationship_status=request.args.get("relationshipStatus", None), type=request.args.get("type", None), status=request.args.getlist("status", None), + action=request.args.getlist("action", None), modified_by=request.args.get("modifiedBy", None), submitted_sort_order=request.args.get("submittedSortOrder", None), page=int(request.args.get("page", 1)), diff --git a/auth-api/src/auth_api/services/products.py b/auth-api/src/auth_api/services/products.py index bf5844e08..7543ef5c3 100644 --- a/auth-api/src/auth_api/services/products.py +++ b/auth-api/src/auth_api/services/products.py @@ -56,7 +56,8 @@ get_product_notification_data, get_product_notification_type, ) -from auth_api.utils.roles import CLIENT_ADMIN_ROLES, CLIENT_AUTH_ROLES, STAFF +from auth_api.utils.pay import get_account_fees_dict +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 from .activity_log_publisher import ActivityLogPublisher @@ -196,6 +197,8 @@ def create_product_subscription( if not skip_auth: check_auth(one_of_roles=(*CLIENT_ADMIN_ROLES, STAFF), org_id=org_id) + account_fees_dict = get_account_fees_dict(org) + subscriptions_list = subscription_data.get("subscriptions") for subscription in subscriptions_list: product_code = subscription.get("productCode") @@ -210,7 +213,9 @@ def create_product_subscription( if previously_approved: auto_approve = True - subscription_status = Product.find_subscription_status(org, product_model, auto_approve) + subscription_status = Product.find_subscription_status( + org, product_model, account_fees_dict, auto_approve + ) product_subscription = Product._subscribe_and_publish_activity( SubscriptionRequest( org_id=org_id, @@ -245,6 +250,7 @@ def create_product_subscription( ProductReviewTask( org_id=org.id, org_name=org.name, + org_access_type=org.access_type, product_code=product_subscription.product_code, product_description=product_model.description, product_subscription_id=product_subscription.id, @@ -371,11 +377,14 @@ def _reset_subscription_and_review_task( @staticmethod def _create_review_task(review_task: ProductReviewTask): task_type = review_task.product_description - action_type = ( - TaskAction.QUALIFIED_SUPPLIER_REVIEW.value - if review_task.product_code in QUALIFIED_SUPPLIER_PRODUCT_CODES - else TaskAction.PRODUCT_REVIEW.value - ) + + required_review_types = {AccessType.GOVM.value, AccessType.GOVN.value} + if review_task.product_code in QUALIFIED_SUPPLIER_PRODUCT_CODES: + action_type = TaskAction.QUALIFIED_SUPPLIER_REVIEW.value + elif review_task.org_access_type in required_review_types: + action_type = TaskAction.NEW_PRODUCT_FEE_REVIEW.value + else: + action_type = TaskAction.PRODUCT_REVIEW.value task_info = { "name": review_task.org_name, @@ -393,16 +402,17 @@ def _create_review_task(review_task: ProductReviewTask): TaskService.create_task(task_info, False) @staticmethod - def find_subscription_status(org, product_model, auto_approve=False): + def find_subscription_status(org, product_model, account_fees_dict, auto_approve=False): """Return the subscriptions status based on org type.""" - # GOVM accounts has default active subscriptions - skip_review_types = [AccessType.GOVM.value] - if product_model.need_review and auto_approve is False: - return ( - ProductSubscriptionStatus.ACTIVE.value - if (org.access_type in skip_review_types) - else ProductSubscriptionStatus.PENDING_STAFF_REVIEW.value - ) + required_review_types = GOV_ORG_TYPES + + needs_review = ( + org.access_type in required_review_types and account_fees_dict.get(product_model.code, False) + ) or (product_model.need_review and not auto_approve) + + if needs_review: + return ProductSubscriptionStatus.PENDING_STAFF_REVIEW.value + return ProductSubscriptionStatus.ACTIVE.value @staticmethod @@ -479,7 +489,7 @@ def update_product_subscription(product_sub_info: ProductSubscriptionInfo, is_ne is_approved = product_sub_info.is_approved is_hold = product_sub_info.is_hold org_id = product_sub_info.org_id - + org_name = product_sub_info.org_name # Approve/Reject Product subscription product_subscription: ProductSubscriptionModel = ProductSubscriptionModel.find_by_id(product_subscription_id) @@ -508,6 +518,8 @@ def update_product_subscription(product_sub_info: ProductSubscriptionInfo, is_ne product_sub_model=product_subscription, is_reapproved=is_reapproved, remarks=product_sub_info.task_remarks, + org_id=org_id, + org_name=org_name, ) ) diff --git a/auth-api/src/auth_api/services/task.py b/auth-api/src/auth_api/services/task.py index 55c8a330d..a46bd6123 100644 --- a/auth-api/src/auth_api/services/task.py +++ b/auth-api/src/auth_api/services/task.py @@ -137,6 +137,7 @@ def _update_relationship(self, origin_url: str = None): # Update Product relationship product_subscription_id = task_model.relationship_id account_id = task_model.account_id + org: OrgModel = OrgModel.find_by_org_id(account_id) self._update_product_subscription( ProductSubscriptionInfo( is_approved=is_approved, @@ -144,6 +145,7 @@ def _update_relationship(self, origin_url: str = None): is_hold=is_hold, product_subscription_id=product_subscription_id, org_id=account_id, + org_name=org.name, task_remarks=Task.get_task_remark(task_model), ) ) diff --git a/auth-api/src/auth_api/utils/enums.py b/auth-api/src/auth_api/utils/enums.py index 7896840d9..f589d6287 100644 --- a/auth-api/src/auth_api/utils/enums.py +++ b/auth-api/src/auth_api/utils/enums.py @@ -319,6 +319,7 @@ class TaskAction(Enum): ACCOUNT_REVIEW = "ACCOUNT_REVIEW" PRODUCT_REVIEW = "PRODUCT_REVIEW" QUALIFIED_SUPPLIER_REVIEW = "QUALIFIED_SUPPLIER_REVIEW" + NEW_PRODUCT_FEE_REVIEW = "NEW_PRODUCT_FEE_REVIEW" class ActivityAction(Enum): diff --git a/auth-api/src/auth_api/utils/notifications.py b/auth-api/src/auth_api/utils/notifications.py index 25f646b26..02dc8fa40 100644 --- a/auth-api/src/auth_api/utils/notifications.py +++ b/auth-api/src/auth_api/utils/notifications.py @@ -32,6 +32,7 @@ class ProductSubscriptionInfo: is_approved: bool product_subscription_id: int org_id: int + org_name: str | None = None task_remarks: str | None = None is_hold: bool | None = False is_resubmitted: bool | None = False @@ -47,6 +48,8 @@ class ProductNotificationInfo: remarks: str | None = None is_reapproved: bool | None = False is_confirmation: bool | None = False + org_id: int | None = None + org_name: str | None = None # e.g [BC Registries and Online Services] Your {{MHR_QUALIFIED_SUPPLIER}} Access Has Been Approved @@ -114,7 +117,9 @@ def get_product_notification_data(product_notification_info: ProductNotification remarks = product_notification_info.remarks if product_model.code not in DETAILED_MHR_NOTIFICATIONS: - return get_default_product_notification_data(product_model, recipient_emails) + org_id = product_notification_info.org_id + org_name = product_notification_info.org_name + return get_default_product_notification_data(product_model, recipient_emails, org_id, org_name) if is_confirmation: return get_mhr_qs_confirmation_data(product_model, recipient_emails) @@ -128,9 +133,14 @@ def get_product_notification_data(product_notification_info: ProductNotification return None -def get_default_product_notification_data(product_model: ProductCodeModel, recipient_emails: str): +def get_default_product_notification_data(product_model: ProductCodeModel, recipient_emails: str, org_id: int = None, org_name: str = None): """Get the default product notification data.""" - data = {"productName": product_model.description, "emailAddresses": recipient_emails} + data = { + "productName": product_model.description, + "emailAddresses": recipient_emails, + "accountId": org_id, + "accountName": org_name, + } return data 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..189700e04 --- /dev/null +++ b/auth-api/src/auth_api/utils/pay.py @@ -0,0 +1,48 @@ +# 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_dict(org: OrgModel) -> dict: + """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_dict = {} + + 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 = response_data.get("accountFees", []) + + for fee in account_fees: + product_code = fee.get("product") + if product_code: + account_fees_dict[product_code] = True + 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.error(f"{Error.ACCOUNT_FEES_FETCH_FAILED.message} for org {org.id}: {e}") + + return account_fees_dict diff --git a/auth-api/src/auth_api/utils/roles.py b/auth-api/src/auth_api/utils/roles.py index 13d0e760f..77f4f5ce6 100644 --- a/auth-api/src/auth_api/utils/roles.py +++ b/auth-api/src/auth_api/utils/roles.py @@ -15,7 +15,7 @@ from enum import Enum -from .enums import OrgStatus, OrgType, ProductSubscriptionStatus, Status +from .enums import AccessType, OrgStatus, OrgType, ProductSubscriptionStatus, Status class Role(Enum): @@ -88,3 +88,5 @@ class Role(Enum): EXCLUDED_FIELDS = ("status_code", "type_code") PREMIUM_ORG_TYPES = (OrgType.PREMIUM.value, OrgType.SBC_STAFF.value, OrgType.STAFF.value) + +GOV_ORG_TYPES = (AccessType.GOVM.value, AccessType.GOVN.value) diff --git a/auth-api/tests/unit/api/test_org.py b/auth-api/tests/unit/api/test_org.py index bf7f924ac..8d6fad186 100644 --- a/auth-api/tests/unit/api/test_org.py +++ b/auth-api/tests/unit/api/test_org.py @@ -454,7 +454,7 @@ def test_add_govm_full_flow(client, jwt, session, keycloak_mock): # pylint:disa list_products = json.loads(rv_products.data) vs_product = next(x for x in list_products if x.get("code") == "VS") - assert vs_product.get("subscriptionStatus") == "ACTIVE" + assert vs_product.get("subscriptionStatus") == "PENDING_STAFF_REVIEW" def test_add_anonymous_org_staff_admin(client, jwt, session, keycloak_mock): # pylint:disable=unused-argument diff --git a/auth-api/tests/unit/models/test_task.py b/auth-api/tests/unit/models/test_task.py index 23a9e3750..2c58b5557 100644 --- a/auth-api/tests/unit/models/test_task.py +++ b/auth-api/tests/unit/models/test_task.py @@ -20,7 +20,7 @@ from auth_api.models import Task as TaskModel from auth_api.models.dataclass import TaskSearch -from auth_api.utils.enums import TaskRelationshipStatus, TaskRelationshipType, TaskStatus, TaskTypePrefix +from auth_api.utils.enums import TaskAction, TaskRelationshipStatus, TaskRelationshipType, TaskStatus, TaskTypePrefix from tests.utilities.factory_utils import factory_task_models, factory_user_model @@ -249,3 +249,48 @@ def test_find_by_task_for_user(session): # pylint:disable=unused-argument assert found_task.name == "TEST 1" assert found_task.relationship_id == user.id assert found_task.status == TaskStatus.OPEN.value + + +def test_fetch_tasks_by_action(session): # pylint:disable=unused-argument + """Assert that we can fetch tasks filtered by action NEW_PRODUCT_FEE_REVIEW.""" + user = factory_user_model() + task_type = TaskTypePrefix.NEW_ACCOUNT_STAFF_REVIEW.value + + task1 = TaskModel( + name="Task 1", + date_submitted=datetime.now(), + relationship_type=TaskRelationshipType.PRODUCT.value, + relationship_id=10, + type=task_type, + status=TaskStatus.OPEN.value, + action=TaskAction.NEW_PRODUCT_FEE_REVIEW.value, + related_to=user.id, + relationship_status=TaskRelationshipStatus.PENDING_STAFF_REVIEW.value, + ) + task1.save() + + task2 = TaskModel( + name="Task 2", + date_submitted=datetime.now(), + relationship_type=TaskRelationshipType.PRODUCT.value, + relationship_id=10, + type=task_type, + status=TaskStatus.OPEN.value, + action=TaskAction.PRODUCT_REVIEW.value, + related_to=user.id, + relationship_status=TaskRelationshipStatus.PENDING_STAFF_REVIEW.value, + ) + task2.save() + + task_search = TaskSearch( + type=task_type, + status=[TaskStatus.OPEN.value], + action=[TaskAction.NEW_PRODUCT_FEE_REVIEW.value], + relationship_status=TaskRelationshipStatus.PENDING_STAFF_REVIEW.value, + page=1, + limit=10, + ) + + found_tasks, count = TaskModel.fetch_tasks(task_search) + assert count == 1 + assert found_tasks[0].action == TaskAction.NEW_PRODUCT_FEE_REVIEW.value diff --git a/auth-api/tests/unit/services/test_product_notifications.py b/auth-api/tests/unit/services/test_product_notifications.py index f49ebef70..721d17f32 100644 --- a/auth-api/tests/unit/services/test_product_notifications.py +++ b/auth-api/tests/unit/services/test_product_notifications.py @@ -99,7 +99,12 @@ def test_default_approved_notification(mock_mailer, session, auth_mock, keycloak with patch.object(UserService, "get_admin_emails_for_org", return_value="test@test.com"): TaskService.update_task(TaskService(task), task_info=task_info) - expected_data = {"productName": product_code_model.description, "emailAddresses": "test@test.com"} + expected_data = { + "productName": product_code_model.description, + "emailAddresses": "test@test.com", + "accountId": dictionary["id"], + "accountName": dictionary["name"], + } mock_mailer.assert_called_with(QueueMessageTypes.PROD_PACKAGE_APPROVED_NOTIFICATION.value, data=expected_data) @@ -160,7 +165,12 @@ def test_default_rejected_notification(mock_mailer, session, auth_mock, keycloak with patch.object(UserService, "get_admin_emails_for_org", return_value="test@test.com"): TaskService.update_task(TaskService(task), task_info=task_info) - expected_data = {"productName": product_code_model.description, "emailAddresses": "test@test.com"} + expected_data = { + "productName": product_code_model.description, + "emailAddresses": "test@test.com", + "accountId": dictionary["id"], + "accountName": dictionary["name"], + } mock_mailer.assert_called_with(QueueMessageTypes.PROD_PACKAGE_REJECTED_NOTIFICATION.value, data=expected_data) diff --git a/auth-api/tests/unit/utils/test_pay.py b/auth-api/tests/unit/utils/test_pay.py new file mode 100644 index 000000000..24cd40b63 --- /dev/null +++ b/auth-api/tests/unit/utils/test_pay.py @@ -0,0 +1,48 @@ +"""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_dict +from tests.conftest import mock_token +from tests.utilities.factory_utils import factory_org_model + + +def test_get_account_fees_dict_govm_org_success(monkeypatch, session): # pylint:disable=unused-argument + """Test that GOVM org with successful response returns dict with 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_dict(org) + + assert result == {ProductCode.BUSINESS.value: True, ProductCode.VS.value: True, ProductCode.BCA.value: True} diff --git a/auth-web/package-lock.json b/auth-web/package-lock.json index 66420670a..2d428fa44 100644 --- a/auth-web/package-lock.json +++ b/auth-web/package-lock.json @@ -1,12 +1,12 @@ { "name": "auth-web", - "version": "2.10.35", + "version": "2.10.36", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "auth-web", - "version": "2.10.35", + "version": "2.10.36", "hasInstallScript": true, "dependencies": { "@bcrs-shared-components/base-address": "2.0.39", diff --git a/auth-web/package.json b/auth-web/package.json index ce50e4353..13fd43c1f 100644 --- a/auth-web/package.json +++ b/auth-web/package.json @@ -1,6 +1,6 @@ { "name": "auth-web", - "version": "2.10.35", + "version": "2.10.36", "appName": "Auth Web", "sbcName": "SBC Common Components", "private": true, diff --git a/auth-web/src/App.vue b/auth-web/src/App.vue index 10b5da520..7a3f99948 100644 --- a/auth-web/src/App.vue +++ b/auth-web/src/App.vue @@ -24,7 +24,7 @@ @@ -184,7 +184,7 @@ export default class App extends Mixins(NextPageMixin) { EventBus.$on('show-toast', (eventInfo: Event) => { this.showNotification = true this.notificationText = eventInfo.message - this.toastType = eventInfo.type + this.toastType = eventInfo.type || 'primary' this.toastTimeout = eventInfo.timeout }) diff --git a/auth-web/src/components/auth/account-settings/product/ProductPayment.vue b/auth-web/src/components/auth/account-settings/product/ProductPayment.vue index 729ab1bf2..53b90238e 100644 --- a/auth-web/src/components/auth/account-settings/product/ProductPayment.vue +++ b/auth-web/src/components/auth/account-settings/product/ProductPayment.vue @@ -484,8 +484,8 @@ export default defineComponent({ if (!state.staffReviewClear && state.addProductOnAccountAdmin) { state.dialogTitle = 'Staff Review Required' - state.dialogText = `This product needs a review by our staff before it's added to your account. - We'll notify you by email once it's approved.` + state.dialogText = `This product needs a review by our staff before it is added to your account. + You will be notified by email once the request is reviewed.` confirmDialog.value.open() } else if (!state.addProductOnAccountAdmin) { state.displayRemoveProductDialog = true diff --git a/auth-web/src/components/auth/staff/account-management/StaffPendingAccountsTable.vue b/auth-web/src/components/auth/staff/account-management/StaffPendingAccountsTable.vue index 03f428764..b37f13787 100644 --- a/auth-web/src/components/auth/staff/account-management/StaffPendingAccountsTable.vue +++ b/auth-web/src/components/auth/staff/account-management/StaffPendingAccountsTable.vue @@ -97,7 +97,7 @@ >