From 01af28b4cfe4260a071f8bfadca20b2dbe8b959d Mon Sep 17 00:00:00 2001 From: Vysakh Menon Date: Wed, 3 Jun 2026 10:21:56 -0700 Subject: [PATCH 1/5] 33304 regenerate documents after receiving BN15 --- gcp-jobs/bn-retry/devops/vaults.gcp.env | 3 + gcp-jobs/bn-retry/src/bn_retry/config.py | 2 + gcp-jobs/bn-retry/src/bn_retry/worker.py | 22 +++- .../business_filings/business_documents.py | 102 +++++++++++++++++- .../resources/v2/test_business_documents.py | 34 +++++- .../business-bn/devops/vaults.gcp.env | 2 + .../business_bn/bn_processors/registration.py | 19 ++++ .../business-bn/src/business_bn/config.py | 1 + 8 files changed, 178 insertions(+), 7 deletions(-) diff --git a/gcp-jobs/bn-retry/devops/vaults.gcp.env b/gcp-jobs/bn-retry/devops/vaults.gcp.env index 2432f809ca..c941d92e2e 100644 --- a/gcp-jobs/bn-retry/devops/vaults.gcp.env +++ b/gcp-jobs/bn-retry/devops/vaults.gcp.env @@ -4,6 +4,9 @@ ACCOUNT_SVC_CLIENT_ID="op://keycloak/$APP_ENV/entity-service-account/ENTITY_SERV ACCOUNT_SVC_CLIENT_SECRET="op://keycloak/$APP_ENV/entity-service-account/ENTITY_SERVICE_ACCOUNT_CLIENT_SECRET" ACCOUNT_SVC_TIMEOUT="op://keycloak/$APP_ENV/entity-service-account/ENTITY_SERVICE_ACCOUNT_SVC_TIMEOUT" +BUSINESS_API_URL="op://API/$APP_ENV/business-api/BUSINESS_API_URL" +BUSINESS_API_VERSION_2="op://API/$APP_ENV/business-api/BUSINESS_API_VERSION_2" + # Colin API COLIN_API_URL="op://API/$APP_ENV/colin-api-entity/COLIN_API_URL" COLIN_API_VERSION="op://API/$APP_ENV/colin-api-entity/COLIN_API_VERSION" diff --git a/gcp-jobs/bn-retry/src/bn_retry/config.py b/gcp-jobs/bn-retry/src/bn_retry/config.py index 6668846193..90ab357a0f 100644 --- a/gcp-jobs/bn-retry/src/bn_retry/config.py +++ b/gcp-jobs/bn-retry/src/bn_retry/config.py @@ -47,6 +47,8 @@ class _Config: # pylint: disable=too-few-public-methods PROJECT_ROOT = os.path.abspath(os.path.dirname(__file__)) + LEGAL_API_URL = os.getenv("BUSINESS_API_URL", "") + os.getenv("BUSINESS_API_VERSION_2", "") + # Colin API configuration COLIN_API_URL = os.getenv("COLIN_API_URL", "") COLIN_API_VERSION = os.getenv("COLIN_API_VERSION", "/api/v1") diff --git a/gcp-jobs/bn-retry/src/bn_retry/worker.py b/gcp-jobs/bn-retry/src/bn_retry/worker.py index 79a4fd308f..2df64a4295 100644 --- a/gcp-jobs/bn-retry/src/bn_retry/worker.py +++ b/gcp-jobs/bn-retry/src/bn_retry/worker.py @@ -36,6 +36,7 @@ This module processes firms to check BN15 status. """ +import requests import uuid from datetime import UTC, datetime @@ -44,7 +45,7 @@ from sqlalchemy import func, or_ from bn_retry import db -from bn_retry.services import check_bn15_status_batch, gcp_queue +from bn_retry.services import check_bn15_status_batch, gcp_queue, get_bearer_token from business_model.models import Business @@ -123,6 +124,24 @@ def publish_business_event(identifier: str): raise +def regenerate_documents(identifier: str): + """Regenerate documents for business.""" + try: + timeout = int(current_app.config.get("ACCOUNT_SVC_TIMEOUT")) + token = get_bearer_token(timeout) + legal_api_url = current_app.config.get("LEGAL_API_URL") + url = f'{legal_api_url}/businesses/{identifier}/documents/regenerate?only_required=true&previous=true' + response = requests.post( + url, + headers={"Content-Type": "application/json", "Authorization": f"Bearer {token}"}, + timeout=timeout, + data={} + ) + response.raise_for_status() + except Exception as err: + current_app.logger.error("Failed to regenerate documents for %s %s", identifier, err, exc_info=True) + + def run_job(): """Run the BN15 retry job with batch processing. @@ -169,6 +188,7 @@ def run_job(): update_business_bn(business, bn15) publish_email_notification(identifier) publish_business_event(identifier) + regenerate_documents(identifier) except Exception as ex: current_app.logger.error(f"Error updating {identifier}: {ex}") # Continue with other updates in batch diff --git a/legal-api/src/legal_api/resources/v2/business/business_filings/business_documents.py b/legal-api/src/legal_api/resources/v2/business/business_filings/business_documents.py index 03ed3da139..5d46ab45ab 100644 --- a/legal-api/src/legal_api/resources/v2/business/business_filings/business_documents.py +++ b/legal-api/src/legal_api/resources/v2/business/business_filings/business_documents.py @@ -15,16 +15,18 @@ Provides all the search and retrieval from the business entity documents. """ +from pydantic import BaseModel from http import HTTPStatus from typing import Final, Optional import requests from flask import current_app, jsonify, request from flask_cors import cross_origin +from flask_pydantic import validate as pydantic_validate from legal_api.core import Filing from legal_api.exceptions import ErrorCode, get_error_message -from legal_api.models import Business, Document +from legal_api.models import Business, Document, UserRoles from legal_api.models import Filing as FilingModel from legal_api.reports import get_pdf from legal_api.reports.document_service import DocumentService @@ -102,8 +104,13 @@ def get_documents(identifier: str, # noqa: PLR0911, PLR0912 file_name=file_name, filing_id=filing_id, identifier=identifier) ), HTTPStatus.NOT_FOUND - if _regenerate(legal_filing_name): - return get_pdf(filing.storage, legal_filing_name, True) + if _should_regenerate(legal_filing_name): + if jwt.validate_roles([UserRoles.staff, UserRoles.system]): + return get_pdf(filing.storage, legal_filing_name, True) + else: + return jsonify( + message=get_error_message(ErrorCode.NOT_AUTHORIZED, identifier=identifier) + ), HTTPStatus.UNAUTHORIZED if drs_params := _get_drs_params(): return _get_drs_documents(drs_params) @@ -137,7 +144,7 @@ def _get_drs_params() -> dict: return params -def _regenerate(legal_filing_name: str) -> bool: +def _should_regenerate(legal_filing_name: str) -> bool: """Determine if individual report request should regenerate and update the DRS.""" if legal_filing_name and legal_filing_name.lower().startswith("receipt"): return False @@ -255,3 +262,90 @@ def _get_corp_name(business, filing): return Business.BUSINESSES.get(legal_type, {}).get("numberedDescription", "") return "" + + +class RegenerateQueryModel(BaseModel): + """Document regenerate query model.""" + + previous: bool | None = None + only_required: bool | None = None + + +@cors_preflight("POST") +@bp.route("//documents/regenerate", methods=["POST", "OPTIONS"]) +@bp.route(DOCUMENTS_BASE_ROUTE + "/regenerate", methods=["POST", "OPTIONS"]) +@cross_origin(origin="*") +@jwt.has_one_of_roles([UserRoles.system]) +@pydantic_validate(get_json_params={"silent": True}) +def regenerate_document(query: RegenerateQueryModel, identifier: str, filing_id: int | None = None): + """ + Regenerate documents for a business. + """ + if identifier.startswith("T"): + # not supported for temp registrations + return {}, HTTPStatus.NOT_FOUND + + business = Business.find_by_identifier(identifier) + if filing_id is not None: + filing = Filing.find_by_id(filing_id) + else: + filing_storage = FilingModel.get_most_recent_filing(business.id) + filing = Filing() + filing.storage = filing_storage + + if not business or not filing: + return {}, HTTPStatus.NOT_FOUND + + _regenerate_documents(business, filing, query) + if query.previous: # regenerate documents for previous completed filings + while filing_storage := FilingModel.get_previous_completed_filing(filing.storage): + prev_filing = Filing() + prev_filing.storage = filing_storage + _regenerate_documents(business, prev_filing, query) + filing = prev_filing + + return {}, HTTPStatus.OK + + +def _regenerate_documents(business: Business, filing: Filing, query: RegenerateQueryModel): + """Regenerate documents for a business.""" + # these documents shows BN15 + regeneration_required = [ + "registration", + "amendedRegistrationStatement", + "correctedRegistrationStatement", + "changeOfRegistration", + "annualReport", + "dissolution" + ] + document_list = Filing.get_document_list(business, filing, jwt) + if not document_list or 'documents' not in document_list: + return {}, HTTPStatus.OK + + docs = document_list.get("documents", {}) + + doc_keys = [] + if legal_filings := docs.pop("legalFilings", None): + for doc in legal_filings: + doc_keys.extend(doc.keys()) + + docs.pop("receipt", None) + docs.pop("staticDocuments", None) + docs.pop("uploadedCourtOrder", None) + doc_keys.extend(docs.keys()) + + for doc_name in doc_keys: + if query.only_required and doc_name not in regeneration_required: + continue + + response = get_pdf(filing.storage, doc_name, True) + if isinstance(response, tuple): + status_code = response[1] + else: + status_code = getattr(response, 'status_code', HTTPStatus.OK) + + if status_code != HTTPStatus.OK: + current_app.logger.error( + f"Failed to regenerate document {doc_name} for filing {filing.id} of {business.identifier}" + ) + return response diff --git a/legal-api/tests/unit/resources/v2/test_business_documents.py b/legal-api/tests/unit/resources/v2/test_business_documents.py index b116588cae..0c960354f1 100644 --- a/legal-api/tests/unit/resources/v2/test_business_documents.py +++ b/legal-api/tests/unit/resources/v2/test_business_documents.py @@ -21,8 +21,7 @@ from http import HTTPStatus from legal_api.models.business import Business - -from legal_api.services.authz import STAFF_ROLE +from legal_api.services.authz import STAFF_ROLE, SYSTEM_ROLE from legal_api.models.document import Document, DocumentType from tests import integration_reports from tests.unit.models import factory_business, factory_completed_filing, factory_incorporation_filing @@ -322,3 +321,34 @@ def test_get_business_summary_involuntary_dissolution(requests_mock, session, cl assert state_filing['filingType'] == 'dissolution' assert state_filing['filingName'] == 'Involuntary Dissolution' + +def test_regenerate_documents(mocker, session, client, jwt): + """Assert that we can regenerate documents for a filing.""" + # setup + identifier = 'CP7654321' + business = factory_business(identifier) + filing_json = copy.deepcopy(FILING_HEADER) + filing_json['filing']['header']['name'] = 'specialResolution' + filing_json['filing']['alteration'] = copy.deepcopy(ALTERATION) + filing_json['filing']['alteration']['memorandumInResolution'] = True + filing_json['filing']['alteration']['rulesInResolution'] = True + filing = factory_completed_filing(business, filing_json) + + mocker.patch('legal_api.resources.v2.business.business_filings.business_documents.get_pdf', + return_value=(b'pdf-content', HTTPStatus.OK)) + headers = create_header(jwt, [SYSTEM_ROLE], identifier) + # test + rv = client.post(f'/api/v2/businesses/{identifier}/filings/{filing.id}/documents/regenerate', headers=headers) + # check + assert rv.status_code == HTTPStatus.OK + + +def test_regenerate_documents_invalid_business_filing(session, client, jwt): + """Assert that regenerating documents for an invalid business or filing returns 404.""" + identifier = 'CP7654321' + headers = create_header(jwt, [SYSTEM_ROLE], identifier) + # test with invalid filing id + rv = client.post(f'/api/v2/businesses/{identifier}/filings/9999/documents/regenerate', headers=headers) + assert rv.status_code == HTTPStatus.NOT_FOUND + + diff --git a/queue_services/business-bn/devops/vaults.gcp.env b/queue_services/business-bn/devops/vaults.gcp.env index 2956455082..858fcd4807 100644 --- a/queue_services/business-bn/devops/vaults.gcp.env +++ b/queue_services/business-bn/devops/vaults.gcp.env @@ -4,6 +4,8 @@ BN_HUB_CLIENT_SECRET="op://entity/$APP_ENV/business-bn/BN_HUB_CLIENT_SECRET" BN_HUB_MAX_RETRY="op://entity/$APP_ENV/business-bn/BN_HUB_MAX_RETRY" LEGISLATIVE_TIMEZONE="op://entity/$APP_ENV/business-api/LEGISLATIVE_TIMEZONE" LD_SDK_KEY="op://launchdarkly/$APP_ENV/business-filings/BUSINESS_FILING_LD_CLIENT_ID" +BUSINESS_API_URL="op://API/$APP_ENV/business-api/BUSINESS_API_URL" +BUSINESS_API_VERSION_2="op://API/$APP_ENV/business-api/BUSINESS_API_VERSION_2" COLIN_API_URL="op://API/$APP_ENV/colin-api-entity/COLIN_API_URL" COLIN_API_VERSION="op://API/$APP_ENV/colin-api-entity/COLIN_API_VERSION" REGISTRIES_SEARCH_API_INTERNAL_URL="op://registries-search/$APP_ENV/search-api/INTERNAL_URL" diff --git a/queue_services/business-bn/src/business_bn/bn_processors/registration.py b/queue_services/business-bn/src/business_bn/bn_processors/registration.py index 2109fdca42..fe918cdb16 100644 --- a/queue_services/business-bn/src/business_bn/bn_processors/registration.py +++ b/queue_services/business-bn/src/business_bn/bn_processors/registration.py @@ -164,6 +164,25 @@ def process( # noqa: PLR0912, PLR0915 except Exception as err: # pylint: disable=broad-except; current_app.logger.error("Failed to publish BN update for %s %s", business.identifier, err, exc_info=True) + _regenerate_documents(business) + + +def _regenerate_documents(business: Business): + """Regenerate documents for business.""" + try: + token = AccountService.get_bearer_token() + legal_api_url = current_app.config.get("LEGAL_API_URL") + url = f'{legal_api_url}/businesses/{business.identifier}/documents/regenerate?only_required=true&previous=true' + response = requests.post( + url, + headers={**AccountService.CONTENT_TYPE_JSON, "Authorization": AccountService.BEARER + token}, + timeout=AccountService.timeout, + data={} + ) + response.raise_for_status() + except Exception as err: + current_app.logger.error("Failed to regenerate documents for %s %s", business.identifier, err, exc_info=True) + def _inform_cra( business: Business, # pylint: disable=too-many-locals diff --git a/queue_services/business-bn/src/business_bn/config.py b/queue_services/business-bn/src/business_bn/config.py index 2c261bb7df..04fb158caa 100644 --- a/queue_services/business-bn/src/business_bn/config.py +++ b/queue_services/business-bn/src/business_bn/config.py @@ -58,6 +58,7 @@ class Config: # pylint: disable=too-few-public-methods PROJECT_ROOT = os.path.abspath(os.path.dirname(__file__)) LD_SDK_KEY = os.getenv("LD_SDK_KEY", None) + LEGAL_API_URL = os.getenv("BUSINESS_API_URL", "") + os.getenv("BUSINESS_API_VERSION_2", "") COLIN_API = f"{os.getenv('COLIN_API_URL', '')}{os.getenv('COLIN_API_VERSION', '')}" SEARCH_API = ( From 10408358268e4f78bbaeb9abf79ff1f9777faae1 Mon Sep 17 00:00:00 2001 From: Vysakh Menon Date: Wed, 3 Jun 2026 10:43:26 -0700 Subject: [PATCH 2/5] no message --- gcp-jobs/bn-retry/src/bn_retry/worker.py | 4 ++-- .../business/business_filings/business_documents.py | 13 +++++-------- .../src/business_bn/bn_processors/registration.py | 2 +- 3 files changed, 8 insertions(+), 11 deletions(-) diff --git a/gcp-jobs/bn-retry/src/bn_retry/worker.py b/gcp-jobs/bn-retry/src/bn_retry/worker.py index 2df64a4295..16979d6437 100644 --- a/gcp-jobs/bn-retry/src/bn_retry/worker.py +++ b/gcp-jobs/bn-retry/src/bn_retry/worker.py @@ -36,10 +36,10 @@ This module processes firms to check BN15 status. """ -import requests import uuid from datetime import UTC, datetime +import requests from flask import current_app from simple_cloudevent import SimpleCloudEvent, to_queue_message from sqlalchemy import func, or_ @@ -130,7 +130,7 @@ def regenerate_documents(identifier: str): timeout = int(current_app.config.get("ACCOUNT_SVC_TIMEOUT")) token = get_bearer_token(timeout) legal_api_url = current_app.config.get("LEGAL_API_URL") - url = f'{legal_api_url}/businesses/{identifier}/documents/regenerate?only_required=true&previous=true' + url = f"{legal_api_url}/businesses/{identifier}/documents/regenerate?only_required=true&previous=true" response = requests.post( url, headers={"Content-Type": "application/json", "Authorization": f"Bearer {token}"}, diff --git a/legal-api/src/legal_api/resources/v2/business/business_filings/business_documents.py b/legal-api/src/legal_api/resources/v2/business/business_filings/business_documents.py index 5d46ab45ab..2c5d63e989 100644 --- a/legal-api/src/legal_api/resources/v2/business/business_filings/business_documents.py +++ b/legal-api/src/legal_api/resources/v2/business/business_filings/business_documents.py @@ -15,8 +15,8 @@ Provides all the search and retrieval from the business entity documents. """ -from pydantic import BaseModel from http import HTTPStatus +from pydantic import BaseModel from typing import Final, Optional import requests @@ -109,7 +109,7 @@ def get_documents(identifier: str, # noqa: PLR0911, PLR0912 return get_pdf(filing.storage, legal_filing_name, True) else: return jsonify( - message=get_error_message(ErrorCode.NOT_AUTHORIZED, identifier=identifier) + message="Unauthorized to regenerate" ), HTTPStatus.UNAUTHORIZED if drs_params := _get_drs_params(): @@ -319,7 +319,7 @@ def _regenerate_documents(business: Business, filing: Filing, query: RegenerateQ "dissolution" ] document_list = Filing.get_document_list(business, filing, jwt) - if not document_list or 'documents' not in document_list: + if not document_list or "documents" not in document_list: return {}, HTTPStatus.OK docs = document_list.get("documents", {}) @@ -337,12 +337,9 @@ def _regenerate_documents(business: Business, filing: Filing, query: RegenerateQ for doc_name in doc_keys: if query.only_required and doc_name not in regeneration_required: continue - + response = get_pdf(filing.storage, doc_name, True) - if isinstance(response, tuple): - status_code = response[1] - else: - status_code = getattr(response, 'status_code', HTTPStatus.OK) + status_code = response[1] if isinstance(response, tuple) else getattr(response, "status_code", HTTPStatus.OK) if status_code != HTTPStatus.OK: current_app.logger.error( diff --git a/queue_services/business-bn/src/business_bn/bn_processors/registration.py b/queue_services/business-bn/src/business_bn/bn_processors/registration.py index fe918cdb16..7319c4e348 100644 --- a/queue_services/business-bn/src/business_bn/bn_processors/registration.py +++ b/queue_services/business-bn/src/business_bn/bn_processors/registration.py @@ -172,7 +172,7 @@ def _regenerate_documents(business: Business): try: token = AccountService.get_bearer_token() legal_api_url = current_app.config.get("LEGAL_API_URL") - url = f'{legal_api_url}/businesses/{business.identifier}/documents/regenerate?only_required=true&previous=true' + url = f"{legal_api_url}/businesses/{business.identifier}/documents/regenerate?only_required=true&previous=true" response = requests.post( url, headers={**AccountService.CONTENT_TYPE_JSON, "Authorization": AccountService.BEARER + token}, From eab524cc9b501a914d93b2430c4c6b5a02c97d0c Mon Sep 17 00:00:00 2001 From: Vysakh Menon Date: Wed, 3 Jun 2026 11:06:49 -0700 Subject: [PATCH 3/5] no message --- .../v2/business/business_filings/business_documents.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/legal-api/src/legal_api/resources/v2/business/business_filings/business_documents.py b/legal-api/src/legal_api/resources/v2/business/business_filings/business_documents.py index 2c5d63e989..e75606ed5f 100644 --- a/legal-api/src/legal_api/resources/v2/business/business_filings/business_documents.py +++ b/legal-api/src/legal_api/resources/v2/business/business_filings/business_documents.py @@ -16,13 +16,13 @@ Provides all the search and retrieval from the business entity documents. """ from http import HTTPStatus -from pydantic import BaseModel from typing import Final, Optional import requests from flask import current_app, jsonify, request from flask_cors import cross_origin from flask_pydantic import validate as pydantic_validate +from pydantic import BaseModel from legal_api.core import Filing from legal_api.exceptions import ErrorCode, get_error_message @@ -267,8 +267,8 @@ def _get_corp_name(business, filing): class RegenerateQueryModel(BaseModel): """Document regenerate query model.""" - previous: bool | None = None - only_required: bool | None = None + previous: Optional[bool] + only_required: Optional[bool] @cors_preflight("POST") From ecf46260e1c33434f95e1d7a1185303581fdd594 Mon Sep 17 00:00:00 2001 From: Vysakh Menon Date: Wed, 3 Jun 2026 11:18:34 -0700 Subject: [PATCH 4/5] no message --- .../v2/business/business_filings/business_documents.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/legal-api/src/legal_api/resources/v2/business/business_filings/business_documents.py b/legal-api/src/legal_api/resources/v2/business/business_filings/business_documents.py index e75606ed5f..a86411c0db 100644 --- a/legal-api/src/legal_api/resources/v2/business/business_filings/business_documents.py +++ b/legal-api/src/legal_api/resources/v2/business/business_filings/business_documents.py @@ -276,7 +276,7 @@ class RegenerateQueryModel(BaseModel): @bp.route(DOCUMENTS_BASE_ROUTE + "/regenerate", methods=["POST", "OPTIONS"]) @cross_origin(origin="*") @jwt.has_one_of_roles([UserRoles.system]) -@pydantic_validate(get_json_params={"silent": True}) +@pydantic_validate() def regenerate_document(query: RegenerateQueryModel, identifier: str, filing_id: int | None = None): """ Regenerate documents for a business. From 2fd6d9bd3704e99dd7557e4aa6091ab3ad018015 Mon Sep 17 00:00:00 2001 From: Vysakh Menon Date: Wed, 3 Jun 2026 11:25:49 -0700 Subject: [PATCH 5/5] no message --- .../v2/business/business_filings/business_documents.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/legal-api/src/legal_api/resources/v2/business/business_filings/business_documents.py b/legal-api/src/legal_api/resources/v2/business/business_filings/business_documents.py index a86411c0db..6feb9615d9 100644 --- a/legal-api/src/legal_api/resources/v2/business/business_filings/business_documents.py +++ b/legal-api/src/legal_api/resources/v2/business/business_filings/business_documents.py @@ -277,7 +277,7 @@ class RegenerateQueryModel(BaseModel): @cross_origin(origin="*") @jwt.has_one_of_roles([UserRoles.system]) @pydantic_validate() -def regenerate_document(query: RegenerateQueryModel, identifier: str, filing_id: int | None = None): +def regenerate_document(query: RegenerateQueryModel, identifier: str, filing_id: Optional[int] = None): """ Regenerate documents for a business. """