Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions auth-api/src/auth_api/exceptions/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions auth-api/src/auth_api/models/dataclass.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions auth-api/src/auth_api/models/task.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions auth-api/src/auth_api/resources/v1/task.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down
46 changes: 29 additions & 17 deletions auth-api/src/auth_api/services/products.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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,
)
)

Expand Down
2 changes: 2 additions & 0 deletions auth-api/src/auth_api/services/task.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,13 +137,15 @@ 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,
is_resubmitted=task_model.is_resubmitted,
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),
)
)
Expand Down
1 change: 1 addition & 0 deletions auth-api/src/auth_api/utils/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
16 changes: 13 additions & 3 deletions auth-api/src/auth_api/utils/notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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


Expand Down
48 changes: 48 additions & 0 deletions auth-api/src/auth_api/utils/pay.py
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +31 to +42
Copy link
Collaborator

@seeker25 seeker25 Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this logic is unit tested? should it be?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added

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
4 changes: 3 additions & 1 deletion auth-api/src/auth_api/utils/roles.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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)
2 changes: 1 addition & 1 deletion auth-api/tests/unit/api/test_org.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 46 additions & 1 deletion auth-api/tests/unit/models/test_task.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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
14 changes: 12 additions & 2 deletions auth-api/tests/unit/services/test_product_notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down Expand Up @@ -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)


Expand Down
Loading
Loading