diff --git a/backend/compact-connect/app_clients/bin/create_app_client.py b/backend/compact-connect/app_clients/bin/create_app_client.py index bb99339b2..3e8d34dee 100755 --- a/backend/compact-connect/app_clients/bin/create_app_client.py +++ b/backend/compact-connect/app_clients/bin/create_app_client.py @@ -125,6 +125,17 @@ def get_user_input(): """Get user input for app client configuration.""" print('=== App Client Configuration ===\n') + # Get environment + while True: + try: + print('Valid environments: test, beta, prod') + environment = input('Enter the environment: ').strip().lower() + if environment not in ['test', 'beta', 'prod']: + raise ValueError('Invalid environment. Must be one of: test, beta, prod') + break + except ValueError as e: + print(f'Error: {e}') + # Get client name client_name = input("Enter the app client name (e.g., 'example-ky-app-client-v1'): ").strip() if not client_name: @@ -183,7 +194,13 @@ def get_user_input(): print('Configuration cancelled.') sys.exit(0) - return {'clientName': client_name, 'compact': compact, 'state': state, 'scopes': deduped_scopes} + return { + 'environment': environment, + 'clientName': client_name, + 'compact': compact, + 'state': state, + 'scopes': deduped_scopes, + } def create_app_client(user_pool_id, config): @@ -293,20 +310,18 @@ def print_email_template(environment, compact, state): def main(): parser = argparse.ArgumentParser(description='Create AWS Cognito app client interactively') - parser.add_argument( - '-e', '--environment', required=True, choices=['test', 'beta', 'prod'], help='Environment (test, beta, or prod)' - ) parser.add_argument('-u', '--user-pool-id', required=True, help='AWS Cognito User Pool ID') args = parser.parse_args() try: - print(f'Creating app client for {args.environment} environment...') print(f'User Pool ID: {args.user_pool_id}\n') - # Get configuration from user input + # Get configuration from user input (including environment) config = get_user_input() + print(f'\nCreating app client for {config["environment"]} environment...') + # Create the app client response = create_app_client(args.user_pool_id, config) @@ -328,7 +343,7 @@ def main(): print_credentials(client_id, client_secret) # Print email template - print_email_template(args.environment, config['compact'], config['state']) + print_email_template(config['environment'], config['compact'], config['state']) print('\nšŸ“ Remember to add this app client to your external registry!') diff --git a/backend/compact-connect/lambdas/nodejs/email-notification-service/lambda.ts b/backend/compact-connect/lambdas/nodejs/email-notification-service/lambda.ts index 91579cd30..3298496c3 100644 --- a/backend/compact-connect/lambdas/nodejs/email-notification-service/lambda.ts +++ b/backend/compact-connect/lambdas/nodejs/email-notification-service/lambda.ts @@ -79,7 +79,8 @@ export class Lambda implements LambdaInterface { await this.emailService.sendTransactionBatchSettlementFailureEmail( event.compact, event.recipientType, - event.specificEmails + event.specificEmails, + event.templateVariables.batchFailureErrorMessage ); break; case 'privilegeDeactivationJurisdictionNotification': diff --git a/backend/compact-connect/lambdas/nodejs/lib/email/email-notification-service.ts b/backend/compact-connect/lambdas/nodejs/lib/email/email-notification-service.ts index 7ecfb0dc4..3bc0b6e89 100644 --- a/backend/compact-connect/lambdas/nodejs/lib/email/email-notification-service.ts +++ b/backend/compact-connect/lambdas/nodejs/lib/email/email-notification-service.ts @@ -57,7 +57,8 @@ export class EmailNotificationService extends BaseEmailService { public async sendTransactionBatchSettlementFailureEmail( compact: string, recipientType: RecipientType, - specificEmails?: string[] + specificEmails?: string[], + batchFailureErrorMessage?: string ): Promise { this.logger.info('Sending transaction batch settlement failure email', { compact: compact }); const recipients = await this.getCompactRecipients(compact, recipientType, specificEmails); @@ -69,12 +70,36 @@ export class EmailNotificationService extends BaseEmailService { const compactConfig = await this.compactConfigurationClient.getCompactConfiguration(compact); const report = this.getNewEmailTemplate(); const subject = `Transactions Failed to Settle for ${compactConfig.compactName} Payment Processor`; - const bodyText = 'A transaction settlement error was detected within the payment processing account for the compact. ' + - 'Please reach out to your payment processing representative to determine the cause. ' + - 'Transactions made in the account will not be able to be settled until the issue is addressed.'; + + let bodyText = 'A transaction settlement error was detected within the payment processing account for the compact. ' + + 'Please reach out to your payment processing representative if needed to determine the cause. '; + + // Include detailed error message if provided + if (batchFailureErrorMessage) { + try { + const errorDetails = JSON.parse(batchFailureErrorMessage); + + bodyText += '\n\nDetailed Error Information:\n'; + + if (errorDetails.message) { + bodyText += `Error Message: ${errorDetails.message}\n`; + } + + if (errorDetails.failedTransactionIds && errorDetails.failedTransactionIds.length > 0) { + bodyText += `Failed Transaction IDs: ${errorDetails.failedTransactionIds.join(', ')}\n`; + } + + if (errorDetails.unsettledTransactionIds && errorDetails.unsettledTransactionIds.length > 0) { + bodyText += `Unsettled Transaction IDs (older than 48 hours): ${errorDetails.unsettledTransactionIds.join(', ')}\n`; + } + } catch (parseError) { + // If JSON parsing fails, include the raw message + bodyText += `\n\nError Details: ${batchFailureErrorMessage}`; + } + } this.insertHeader(report, subject); - this.insertBody(report, bodyText); + this.insertBody(report, bodyText, 'center', true); this.insertFooter(report); const htmlContent = this.renderTemplate(report); diff --git a/backend/compact-connect/lambdas/nodejs/tests/email-notification-service.test.ts b/backend/compact-connect/lambdas/nodejs/tests/email-notification-service.test.ts index c86e9aedb..2243a14d8 100644 --- a/backend/compact-connect/lambdas/nodejs/tests/email-notification-service.test.ts +++ b/backend/compact-connect/lambdas/nodejs/tests/email-notification-service.test.ts @@ -174,6 +174,64 @@ describe('EmailNotificationServiceLambda', () => { }); }); + it('should include detailed error information for failed transactions', async () => { + const eventWithFailedTransactions: EmailNotificationEvent = { + ...SAMPLE_EVENT, + templateVariables: { + batchFailureErrorMessage: JSON.stringify({ + message: 'Settlement errors detected in one or more transactions.', + failedTransactionIds: ['tx-123', 'tx-456', 'tx-789'] + }) + } + }; + + const response = await lambda.handler(eventWithFailedTransactions, {} as any); + + expect(response).toEqual({ + message: 'Email message sent' + }); + + // Get the actual HTML content for detailed validation + const emailCall = mockSESClient.commandCalls(SendEmailCommand)[0]; + const htmlContent = emailCall.args[0].input.Content?.Simple?.Body?.Html?.Data; + + expect(htmlContent).toBeDefined(); + expect(htmlContent).toContain('A transaction settlement error was detected within the payment processing account for the compact.'); + expect(htmlContent).toContain('Please reach out to your payment processing representative if needed to determine the cause.'); + expect(htmlContent).toContain('Detailed Error Information:'); + expect(htmlContent).toContain('Error Message: Settlement errors detected in one or more transactions.'); + expect(htmlContent).toContain('Failed Transaction IDs: tx-123, tx-456, tx-789'); + }); + + it('should include detailed error information for unsettled transactions', async () => { + const eventWithUnsettledTransactions: EmailNotificationEvent = { + ...SAMPLE_EVENT, + templateVariables: { + batchFailureErrorMessage: JSON.stringify({ + message: 'One or more transactions have not settled in over 48 hours.', + unsettledTransactionIds: ['unsettled-tx-001', 'unsettled-tx-002'] + }) + } + }; + + const response = await lambda.handler(eventWithUnsettledTransactions, {} as any); + + expect(response).toEqual({ + message: 'Email message sent' + }); + + // Get the actual HTML content for detailed validation + const emailCall = mockSESClient.commandCalls(SendEmailCommand)[0]; + const htmlContent = emailCall.args[0].input.Content?.Simple?.Body?.Html?.Data; + + expect(htmlContent).toBeDefined(); + expect(htmlContent).toContain('A transaction settlement error was detected within the payment processing account for the compact.'); + expect(htmlContent).toContain('Please reach out to your payment processing representative if needed to determine the cause.'); + expect(htmlContent).toContain('Detailed Error Information:'); + expect(htmlContent).toContain('Error Message: One or more transactions have not settled in over 48 hours.'); + expect(htmlContent).toContain('Unsettled Transaction IDs (older than 48 hours): unsettled-tx-001, unsettled-tx-002'); + }); + it('should throw error for unsupported template', async () => { const event: EmailNotificationEvent = { ...SAMPLE_EVENT, diff --git a/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/transaction/record.py b/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/transaction/record.py index 2ebf7bff7..3e97ccd52 100644 --- a/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/transaction/record.py +++ b/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/transaction/record.py @@ -71,3 +71,32 @@ def generate_pk_sk(self, in_data, **kwargs): f'#TX#{in_data["transactionId"]}' ) return in_data + + +@BaseRecordSchema.register_schema('unsettled_transaction') +class UnsettledTransactionRecordSchema(BaseRecordSchema): + """ + Schema for unsettled transaction records in the transaction history table. + + These records track transactions that have been submitted but not yet settled, + allowing detection of transactions that fail to settle within the expected timeframe. + """ + + _record_type = 'unsettled_transaction' + + # Required fields + compact = Compact(required=True, allow_none=False) + transactionId = String(required=True, allow_none=False) + transactionDate = String(required=True, allow_none=False) # ISO datetime string + dateOfUpdate = String(required=True, allow_none=False) # ISO datetime string + + @pre_dump + def generate_pk_sk(self, in_data, **kwargs): + """Generate the partition key and sort key for DynamoDB.""" + transaction_time = datetime.fromisoformat(in_data['transactionDate']) + # Convert to epoch timestamp for sorting + epoch_timestamp = int(transaction_time.timestamp()) + + in_data['pk'] = f'COMPACT#{in_data["compact"]}#UNSETTLED_TRANSACTIONS' + in_data['sk'] = f'COMPACT#{in_data["compact"]}#TIME#{epoch_timestamp}#TX#{in_data["transactionId"]}' + return in_data diff --git a/backend/compact-connect/lambdas/python/common/cc_common/data_model/transaction_client.py b/backend/compact-connect/lambdas/python/common/cc_common/data_model/transaction_client.py index 763f9dff7..59c8387ac 100644 --- a/backend/compact-connect/lambdas/python/common/cc_common/data_model/transaction_client.py +++ b/backend/compact-connect/lambdas/python/common/cc_common/data_model/transaction_client.py @@ -1,9 +1,9 @@ -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from boto3.dynamodb.conditions import Key from cc_common.config import _Config, logger -from cc_common.data_model.schema.transaction.record import TransactionRecordSchema +from cc_common.data_model.schema.transaction.record import TransactionRecordSchema, UnsettledTransactionRecordSchema AUTHORIZE_DOT_NET_CLIENT_TYPE = 'authorize.net' @@ -231,3 +231,110 @@ def add_privilege_information_to_transactions(self, compact: str, transactions: ) return transactions + + def store_unsettled_transaction(self, compact: str, transaction_id: str, transaction_date: str) -> None: + """ + Store an unsettled transaction record in DynamoDB. + + :param compact: The compact abbreviation + :param transaction_id: The transaction ID from the payment processor + :param transaction_date: ISO datetime string of when the transaction was submitted + """ + try: + # Create the record data + record_data = { + 'compact': compact, + 'transactionId': transaction_id, + 'transactionDate': transaction_date, + 'dateOfUpdate': datetime.now(UTC).isoformat(), + } + + # Validate and serialize using the schema + unsettled_schema = UnsettledTransactionRecordSchema() + serialized_record = unsettled_schema.dump(record_data) + + self.config.transaction_history_table.put_item(Item=serialized_record) + logger.info( + 'Stored unsettled transaction record', + compact=compact, + transaction_id=transaction_id, + ) + except Exception as e: # noqa: BLE001 + # This record is created for monitoring unsettled transactions, not business critical + # If we fail to record it for whatever reason, log error but don't raise an exception + logger.error( + 'Failed to store unsettled transaction record', + compact=compact, + transaction_id=transaction_id, + error=str(e), + ) + + def reconcile_unsettled_transactions(self, compact: str, settled_transactions: list[dict]) -> list[str]: + """ + Reconcile unsettled transactions with settled transactions and detect old unsettled transactions. + + This method: + 1. Queries all unsettled transactions for the compact + 2. Matches them with settled transactions by transaction ID + 3. Deletes matched unsettled transactions + 4. Checks for unsettled transactions older than 48 hours + + :param compact: The compact abbreviation + :param settled_transactions: List of settled transaction records + :return: List of transaction IDs that have not been matched and are older than 48 hours + (empty list if none found) + """ + # Query all unsettled transactions for this compact + pk = f'COMPACT#{compact}#UNSETTLED_TRANSACTIONS' + response = self.config.transaction_history_table.query( + KeyConditionExpression=Key('pk').eq(pk), + ) + + unsettled_transactions = response.get('Items', []) + + if not unsettled_transactions: + logger.info('No unsettled transactions found for compact', compact=compact) + return [] + + # Create a set of settled transaction IDs for efficient lookup + settled_transaction_ids = {tx['transactionId'] for tx in settled_transactions} + + # Separate matched and unmatched unsettled transactions + matched_unsettled = [] + unmatched_unsettled = [] + + for unsettled_tx in unsettled_transactions: + if unsettled_tx['transactionId'] in settled_transaction_ids: + matched_unsettled.append(unsettled_tx) + else: + unmatched_unsettled.append(unsettled_tx) + + # Batch delete matched unsettled transactions + if matched_unsettled: + logger.info( + 'Deleting matched unsettled transactions', + compact=compact, + count=len(matched_unsettled), + settled_transaction_ids=settled_transaction_ids + ) + with self.config.transaction_history_table.batch_writer() as batch: + for tx in matched_unsettled: + batch.delete_item(Key={'pk': tx['pk'], 'sk': tx['sk']}) + + # Check for unsettled transactions older than 48 hours + cutoff_time = datetime.now(UTC) - timedelta(hours=48) + old_unsettled_transactions = [] + + for unsettled_tx in unmatched_unsettled: + transaction_date = datetime.fromisoformat(unsettled_tx['transactionDate']) + if transaction_date < cutoff_time: + old_unsettled_transactions.append(unsettled_tx['transactionId']) + + if old_unsettled_transactions: + logger.warning( + 'Found unsettled transactions older than 48 hours', + compact=compact, + old_transaction_ids=old_unsettled_transactions, + ) + + return old_unsettled_transactions diff --git a/backend/compact-connect/lambdas/python/common/tests/function/test_data_model/test_transaction_client.py b/backend/compact-connect/lambdas/python/common/tests/function/test_data_model/test_transaction_client.py index 780738e83..67f34b7d1 100644 --- a/backend/compact-connect/lambdas/python/common/tests/function/test_data_model/test_transaction_client.py +++ b/backend/compact-connect/lambdas/python/common/tests/function/test_data_model/test_transaction_client.py @@ -1,5 +1,5 @@ import json -from datetime import datetime +from datetime import UTC, datetime, timedelta from moto import mock_aws @@ -107,3 +107,161 @@ def test_transaction_history_edge_times(self): }, transactions[0], ) + + def test_store_unsettled_transaction(self): + """Test storing an unsettled transaction record""" + from cc_common.data_model.transaction_client import TransactionClient + + client = TransactionClient(self.config) + + compact = 'aslp' + transaction_id = 'test-tx-123' + transaction_date = datetime.now(UTC).isoformat() + + # Store the unsettled transaction + client.store_unsettled_transaction( + compact=compact, transaction_id=transaction_id, transaction_date=transaction_date + ) + + # Query the transaction from DynamoDB + pk = f'COMPACT#{compact}#UNSETTLED_TRANSACTIONS' + response = self._transaction_history_table.query( + KeyConditionExpression='pk = :pk', ExpressionAttributeValues={':pk': pk} + ) + + # Verify the record was stored + self.assertEqual(1, len(response['Items'])) + item = response['Items'][0] + self.assertEqual(compact, item['compact']) + self.assertEqual(transaction_id, item['transactionId']) + self.assertEqual(transaction_date, item['transactionDate']) + self.assertIn('dateOfUpdate', item) + + def test_store_unsettled_transaction_with_invalid_data(self): + """Test that storing unsettled transaction with invalid data doesn't raise exception""" + from cc_common.data_model.transaction_client import TransactionClient + + client = TransactionClient(self.config) + + # This should not raise an exception even with invalid date format + # The schema validation will fail, but the method catches the exception and logs it + client.store_unsettled_transaction( + compact='aslp', transaction_id='test-tx-123', transaction_date='invalid-date' + ) + + # Verify the record was not stored + pk = 'COMPACT#aslp#UNSETTLED_TRANSACTIONS' + response = self._transaction_history_table.query( + KeyConditionExpression='pk = :pk', ExpressionAttributeValues={':pk': pk} + ) + self.assertEqual(0, len(response['Items'])) + + def test_reconcile_unsettled_transactions_no_unsettled_passes(self): + """Test reconciliation when there are no unsettled transactions""" + from cc_common.data_model.transaction_client import TransactionClient + + client = TransactionClient(self.config) + + settled_transactions = [ + {'transactionId': 'tx-1', 'compact': 'aslp'}, + {'transactionId': 'tx-2', 'compact': 'aslp'}, + ] + + # Should not raise any errors + result = client.reconcile_unsettled_transactions(compact='aslp', settled_transactions=settled_transactions) + + self.assertEqual([], result) + + def test_reconcile_unsettled_transactions_all_matched(self): + """Test reconciliation when all unsettled transactions match settled ones""" + from cc_common.data_model.transaction_client import TransactionClient + + client = TransactionClient(self.config) + + # Create some unsettled transactions + compact = 'aslp' + transaction_date = datetime.now(UTC).isoformat() + client.store_unsettled_transaction(compact=compact, transaction_id='tx-1', transaction_date=transaction_date) + client.store_unsettled_transaction(compact=compact, transaction_id='tx-2', transaction_date=transaction_date) + + # Create matching settled transactions + settled_transactions = [ + {'transactionId': 'tx-1', 'compact': compact}, + {'transactionId': 'tx-2', 'compact': compact}, + ] + + client.reconcile_unsettled_transactions(compact=compact, settled_transactions=settled_transactions) + + # Verify the unsettled transactions were deleted + pk = f'COMPACT#{compact}#UNSETTLED_TRANSACTIONS' + response = self._transaction_history_table.query( + KeyConditionExpression='pk = :pk', ExpressionAttributeValues={':pk': pk} + ) + self.assertEqual(0, len(response['Items'])) + + def test_reconcile_unsettled_transactions_with_old_unsettled(self): + """Test reconciliation when there are old unsettled transactions (>48 hours)""" + from cc_common.data_model.transaction_client import TransactionClient + + client = TransactionClient(self.config) + + # Create an old unsettled transaction (50 hours ago) + compact = 'aslp' + old_transaction_date = (datetime.now(UTC) - timedelta(hours=50)).isoformat() + client.store_unsettled_transaction( + compact=compact, transaction_id='old-tx-1', transaction_date=old_transaction_date + ) + + # Create a recent unsettled transaction (1 hour ago) + recent_transaction_date = (datetime.now(UTC) - timedelta(hours=1)).isoformat() + client.store_unsettled_transaction( + compact=compact, transaction_id='recent-tx-1', transaction_date=recent_transaction_date + ) + + # No settled transactions to match + settled_transactions = [] + + result = client.reconcile_unsettled_transactions(compact=compact, settled_transactions=settled_transactions) + + # Verify old unsettled transaction was detected + self.assertIn('old-tx-1', result) + self.assertNotIn('recent-tx-1', result) + + def test_reconcile_unsettled_transactions_deletes_matching_record_and_returns_old_record(self): + """Test reconciliation when some unsettled transactions match and some don't""" + from cc_common.data_model.transaction_client import TransactionClient + + client = TransactionClient(self.config) + + # Create unsettled transactions + compact = 'aslp' + recent_date = (datetime.now(UTC) - timedelta(hours=1)).isoformat() + old_date = (datetime.now(UTC) - timedelta(hours=50)).isoformat() + + client.store_unsettled_transaction(compact=compact, transaction_id='tx-matched', transaction_date=recent_date) + client.store_unsettled_transaction( + compact=compact, transaction_id='tx-old-unmatched', transaction_date=old_date + ) + client.store_unsettled_transaction( + compact=compact, transaction_id='tx-recent-unmatched', transaction_date=recent_date + ) + + # Only one settled transaction that matches + settled_transactions = [ + {'transactionId': 'tx-matched', 'compact': compact}, + ] + + result = client.reconcile_unsettled_transactions(compact=compact, settled_transactions=settled_transactions) + + # Verify only old unmatched transaction is flagged + self.assertEqual(['tx-old-unmatched'], result) + + # Verify matched transaction was deleted but unmatched remain + pk = f'COMPACT#{compact}#UNSETTLED_TRANSACTIONS' + response = self._transaction_history_table.query( + KeyConditionExpression='pk = :pk', ExpressionAttributeValues={':pk': pk} + ) + self.assertEqual( + ['tx-old-unmatched', 'tx-recent-unmatched'], + [transaction['transactionId'] for transaction in response['Items']], + ) # Two unmatched transactions remain diff --git a/backend/compact-connect/lambdas/python/purchases/handlers/privileges.py b/backend/compact-connect/lambdas/python/purchases/handlers/privileges.py index 9f92f1ead..8ef3d0cde 100644 --- a/backend/compact-connect/lambdas/python/purchases/handlers/privileges.py +++ b/backend/compact-connect/lambdas/python/purchases/handlers/privileges.py @@ -366,6 +366,13 @@ def post_purchase_privileges(event: dict, context: LambdaContext): # noqa: ARG0 user_active_military=user_active_military, ) + # Store unsettled transaction record for reconciliation with settled transactions + config.transaction_client.store_unsettled_transaction( + compact=compact_abbr, + transaction_id=transaction_response['transactionId'], + transaction_date=transaction_response['submitTimeUTC'], + ) + # transaction was successful, now we create privilege records for the selected jurisdictions generated_privileges = config.data_client.create_provider_privileges( compact=compact_abbr, diff --git a/backend/compact-connect/lambdas/python/purchases/handlers/transaction_history.py b/backend/compact-connect/lambdas/python/purchases/handlers/transaction_history.py index 9c290f788..f51b3e7c1 100644 --- a/backend/compact-connect/lambdas/python/purchases/handlers/transaction_history.py +++ b/backend/compact-connect/lambdas/python/purchases/handlers/transaction_history.py @@ -123,7 +123,7 @@ def process_settled_transactions(event: dict, context: LambdaContext) -> dict: # here we check if there were any settlement errors in the batch or if there was a settlement failure # in a previous iteration, and we need to send an alert to the compact operations team - failed_transactions_ids = transaction_response.get('settlementErrorTransactionIds') + failed_transactions_ids = transaction_response.get('settlementErrorTransactionIds', []) if failed_transactions_ids or event.get('batchFailureErrorMessage'): # error message should be a json object we can load if event.get('batchFailureErrorMessage'): @@ -142,9 +142,40 @@ def process_settled_transactions(event: dict, context: LambdaContext) -> dict: if _all_transactions_processed(transaction_response): # we've finished storing all transactions for this period, # and we need to send an alert to the compact operations team - logger.warning( + logger.error( 'Batch settlement error detected', batchFailureErrorMessage=response['batchFailureErrorMessage'] ) response['status'] = 'BATCH_FAILURE' + # Reconcile unsettled transactions with settled transactions + # This must be run every iteration to clean up all unsettled transaction records from the transaction history + # table + old_unsettled_transaction_ids = config.transaction_client.reconcile_unsettled_transactions( + compact=compact, settled_transactions=transaction_response['transactions'] + ) + + # Check for old unsettled transactions (older than 48 hours) and report on them if we've + # finished processing all transactions + if old_unsettled_transaction_ids and _all_transactions_processed(transaction_response): + # Parse existing error message if it exists + existing_error = {} + if response.get('batchFailureErrorMessage'): + existing_error = json.loads(response.get('batchFailureErrorMessage')) + + existing_error.update( + { + 'message': existing_error.get('message', '') + + ' One or more transactions have not settled in over 48 hours.', + 'unsettledTransactionIds': old_unsettled_transaction_ids, + } + ) + + response['batchFailureErrorMessage'] = json.dumps(existing_error) + + logger.error( + 'Unsettled transactions older than 48 hours detected', + unsettledTransactionIds=old_unsettled_transaction_ids, + ) + response['status'] = 'BATCH_FAILURE' + return response diff --git a/backend/compact-connect/lambdas/python/purchases/purchase_client.py b/backend/compact-connect/lambdas/python/purchases/purchase_client.py index e664539bd..b668acd59 100644 --- a/backend/compact-connect/lambdas/python/purchases/purchase_client.py +++ b/backend/compact-connect/lambdas/python/purchases/purchase_client.py @@ -504,6 +504,9 @@ def process_charge_on_credit_card_for_privilege_purchase( # noqa: RET503 this b # their SDK returns the transaction id as an internal IntElement type, so we need to cast it # or this will cause an error when we try to serialize it to JSON 'transactionId': str(response.transactionResponse.transId), + # Use current datetime as the transaction submission time + # since authorize.net does not return this value in the response + 'submitTimeUTC': config.current_standard_datetime.isoformat(), } logger.warning('Failed Transaction.') if hasattr(response.transactionResponse, 'errors'): diff --git a/backend/compact-connect/lambdas/python/purchases/tests/function/test_handlers/test_purchase_privileges.py b/backend/compact-connect/lambdas/python/purchases/tests/function/test_handlers/test_purchase_privileges.py index b572a00bd..3008029b3 100644 --- a/backend/compact-connect/lambdas/python/purchases/tests/function/test_handlers/test_purchase_privileges.py +++ b/backend/compact-connect/lambdas/python/purchases/tests/function/test_handlers/test_purchase_privileges.py @@ -14,6 +14,7 @@ TEST_PROVIDER_ID = '89a6377e-c3a5-40e5-bca5-317ec854c570' MOCK_TRANSACTION_ID = '1234' +MOCK_SUBMIT_TIME = '2024-11-08T23:59:55+00:00' ALL_ATTESTATION_IDS = [ 'jurisprudence-confirmation', 'scope-of-practice-attestation', @@ -146,6 +147,7 @@ def _when_purchase_client_successfully_processes_request(self, mock_purchase_cli mock_purchase_client.process_charge_for_licensee_privileges.return_value = { 'transactionId': MOCK_TRANSACTION_ID, 'lineItems': MOCK_LINE_ITEMS, + 'submitTimeUTC': MOCK_SUBMIT_TIME, } return mock_purchase_client @@ -825,7 +827,11 @@ def test_post_purchase_privileges_voids_transaction_if_aws_error_occurs( # verify that the transaction was voided mock_purchase_client.void_privilege_purchase_transaction.assert_called_once_with( compact_abbr=TEST_COMPACT, - order_information={'transactionId': MOCK_TRANSACTION_ID, 'lineItems': MOCK_LINE_ITEMS}, + order_information={ + 'transactionId': MOCK_TRANSACTION_ID, + 'lineItems': MOCK_LINE_ITEMS, + 'submitTimeUTC': MOCK_SUBMIT_TIME, + }, ) @patch('handlers.privileges.PurchaseClient') @@ -1174,3 +1180,38 @@ def test_purchase_privileges_removes_license_deactivated_status_when_renewing_pr privilege_update_record = privilege_update_records[0] # Ensure the licenseDeactivatedStatus field is in the list of removed values self.assertEqual(['licenseDeactivatedStatus'], privilege_update_record.removedValues) + + @patch('handlers.privileges.PurchaseClient') + def test_post_purchase_privileges_stores_unsettled_transaction(self, mock_purchase_client_constructor): + """Test that an unsettled transaction record is stored when a purchase is successful.""" + from handlers.privileges import post_purchase_privileges + + self._when_purchase_client_successfully_processes_request(mock_purchase_client_constructor) + + event = self._when_testing_provider_user_event_with_custom_claims(license_expiration_date='2050-01-01') + event['body'] = _generate_test_request_body() + + resp = post_purchase_privileges(event, self.mock_context) + self.assertEqual(200, resp['statusCode'], resp['body']) + + # Verify that an unsettled transaction record was stored + pk = f'COMPACT#{TEST_COMPACT}#UNSETTLED_TRANSACTIONS' + response = self._transaction_history_table.query( + KeyConditionExpression='pk = :pk', ExpressionAttributeValues={':pk': pk} + ) + + # Should have exactly one unsettled transaction record + self.assertEqual(1, len(response['Items'])) + unsettled_tx = response['Items'][0] + + # Verify the record structure + self.assertEqual(TEST_COMPACT, unsettled_tx['compact']) + self.assertEqual(MOCK_TRANSACTION_ID, unsettled_tx['transactionId']) + self.assertEqual(MOCK_SUBMIT_TIME, unsettled_tx['transactionDate']) + self.assertIn('dateOfUpdate', unsettled_tx) + # Verify the SK format + # Convert MOCK_SUBMIT_TIME into an epoch timestamp + dt = datetime.fromisoformat(MOCK_SUBMIT_TIME) + expected_epoch = int(dt.timestamp()) + expected_sk = f'COMPACT#{TEST_COMPACT}#TIME#{expected_epoch}#TX#{MOCK_TRANSACTION_ID}' + self.assertEqual(expected_sk, unsettled_tx['sk']) diff --git a/backend/compact-connect/lambdas/python/purchases/tests/function/test_handlers/test_transaction_history.py b/backend/compact-connect/lambdas/python/purchases/tests/function/test_handlers/test_transaction_history.py index 9d0db87cc..3e77d9a7e 100644 --- a/backend/compact-connect/lambdas/python/purchases/tests/function/test_handlers/test_transaction_history.py +++ b/backend/compact-connect/lambdas/python/purchases/tests/function/test_handlers/test_transaction_history.py @@ -1,5 +1,5 @@ import json -from datetime import datetime +from datetime import UTC, datetime, timedelta from decimal import Decimal from unittest.mock import ANY, MagicMock, patch @@ -720,3 +720,100 @@ def test_process_settled_transactions_exits_early_when_compact_exists_but_no_jur # The purchase client should not be called since we exit early mock_purchase_client_constructor.assert_not_called() + + @patch('handlers.transaction_history.PurchaseClient') + def test_process_settled_transactions_detects_old_unsettled_transactions(self, mock_purchase_client_constructor): + """Test that old unsettled transactions (>48 hours) are detected and reported as BATCH_FAILURE.""" + from cc_common.config import config + from handlers.transaction_history import process_settled_transactions + + # Add compact configuration data + self._add_compact_configuration_data() + + # Create an old unsettled transaction (50 hours ago) + old_transaction_date = (datetime.now(UTC) - timedelta(hours=50)).isoformat() + config.transaction_client.store_unsettled_transaction( + compact=TEST_COMPACT, transaction_id='old-unsettled-tx', transaction_date=old_transaction_date + ) + + # Create a recent unsettled transaction (1 hour ago) that will be matched + recent_transaction_date = (datetime.now(UTC) - timedelta(hours=1)).isoformat() + config.transaction_client.store_unsettled_transaction( + compact=TEST_COMPACT, transaction_id=MOCK_TRANSACTION_ID, transaction_date=recent_transaction_date + ) + + # Mock the purchase client to return a transaction that matches the recent unsettled one + mock_purchase_client = MagicMock() + mock_purchase_client.get_settled_transactions.return_value = { + 'transactions': [_generate_mock_transaction(transaction_id=MOCK_TRANSACTION_ID, jurisdictions=['oh'])], + 'processedBatchIds': [], + 'settlementErrorTransactionIds': [], + } + mock_purchase_client_constructor.return_value = mock_purchase_client + + # Add privilege record for privilege id lookup + self._add_mock_privilege_to_database( + jurisdiction='oh', + licensee_id=MOCK_LICENSEE_ID, + transaction_id=MOCK_TRANSACTION_ID, + privilege_id=MOCK_PRIVILEGE_ID, + ) + + event = self._when_testing_non_paginated_event() + resp = process_settled_transactions(event, self.mock_context) + + # Should return BATCH_FAILURE status with old unsettled transaction details + self.assertEqual('BATCH_FAILURE', resp['status']) + self.assertIn('batchFailureErrorMessage', resp) + + # Parse the error message + error_message = json.loads(resp['batchFailureErrorMessage']) + self.assertIn('One or more transactions have not settled in over 48 hours', error_message['message']) + self.assertIn('old-unsettled-tx', error_message['unsettledTransactionIds']) + self.assertNotIn(MOCK_TRANSACTION_ID, error_message.get('unsettledTransactionIds', [])) + + @patch('handlers.transaction_history.PurchaseClient') + def test_process_settled_transactions_reconciles_unsettled_transactions(self, mock_purchase_client_constructor): + """Test that matched unsettled transactions are deleted from the database.""" + from cc_common.config import config + from handlers.transaction_history import process_settled_transactions + + # Add compact configuration data + self._add_compact_configuration_data() + + # Create an unsettled transaction + transaction_date = (datetime.now(UTC) - timedelta(hours=1)).isoformat() + config.transaction_client.store_unsettled_transaction( + compact=TEST_COMPACT, transaction_id=MOCK_TRANSACTION_ID, transaction_date=transaction_date + ) + + # Mock the purchase client to return a matching settled transaction + mock_purchase_client = MagicMock() + mock_purchase_client.get_settled_transactions.return_value = { + 'transactions': [_generate_mock_transaction(transaction_id=MOCK_TRANSACTION_ID, jurisdictions=['oh'])], + 'processedBatchIds': [], + 'settlementErrorTransactionIds': [], + } + mock_purchase_client_constructor.return_value = mock_purchase_client + + # Add privilege record for privilege id lookup + self._add_mock_privilege_to_database( + jurisdiction='oh', + licensee_id=MOCK_LICENSEE_ID, + transaction_id=MOCK_TRANSACTION_ID, + privilege_id=MOCK_PRIVILEGE_ID, + ) + + event = self._when_testing_non_paginated_event() + resp = process_settled_transactions(event, self.mock_context) + + # Should return COMPLETE status + self.assertEqual('COMPLETE', resp['status']) + self.assertNotIn('batchFailureErrorMessage', resp) + + # Verify the unsettled transaction was deleted + pk = f'COMPACT#{TEST_COMPACT}#UNSETTLED_TRANSACTIONS' + response = self._transaction_history_table.query( + KeyConditionExpression='pk = :pk', ExpressionAttributeValues={':pk': pk} + ) + self.assertEqual(0, len(response['Items'])) diff --git a/backend/compact-connect/lambdas/python/purchases/tests/unit/test_purchase_client.py b/backend/compact-connect/lambdas/python/purchases/tests/unit/test_purchase_client.py index 690ee4140..016e848d0 100644 --- a/backend/compact-connect/lambdas/python/purchases/tests/unit/test_purchase_client.py +++ b/backend/compact-connect/lambdas/python/purchases/tests/unit/test_purchase_client.py @@ -1,6 +1,7 @@ # ruff: noqa: ARG001 unused-argument import json import os +from datetime import datetime from decimal import Decimal from unittest.mock import MagicMock, patch @@ -19,6 +20,8 @@ 'transaction_key': MOCK_TRANSACTION_KEY, } +MOCK_CURRENT_DATETIME = '2024-11-08T23:59:59+00:00' + MOCK_TRANSACTION_ID = '123456' MOCK_LICENSEE_ID = '89a6377e-c3a5-40e5-bca5-317ec854c570' @@ -132,6 +135,7 @@ def _generate_selected_jurisdictions(jurisdiction_items: list[dict] = None): @mock_aws +@patch('cc_common.config._Config.current_standard_datetime', datetime.fromisoformat(MOCK_CURRENT_DATETIME)) class TestAuthorizeDotNetPurchaseClient(TstLambdas): """Testing that the purchase client works with authorize.net SDK as expected.""" @@ -287,7 +291,7 @@ def message_getitem_with_text_attribute(self, key): return mock_transaction_controller @patch('purchase_client.createTransactionController') - def test_purchase_client_returns_transaction_id_in_response(self, mock_create_transaction_controller): + def test_purchase_client_returns_expected_response(self, mock_create_transaction_controller): from purchase_client import PurchaseClient mock_secrets_manager_client = self._generate_mock_secrets_manager_client() @@ -306,7 +310,32 @@ def test_purchase_client_returns_transaction_id_in_response(self, mock_create_tr user_active_military=False, ) - self.assertEqual(MOCK_TRANSACTION_ID, response['transactionId']) + self.assertEqual( + { + 'transactionId': MOCK_TRANSACTION_ID, + 'lineItems': [ + { + 'description': 'Compact Privilege for Ohio', + 'itemId': 'priv:aslp-oh-slp', + 'name': 'Ohio Compact Privilege', + 'quantity': '1', + 'taxable': 'None', + 'unitPrice': '100', + }, + { + 'description': 'Compact fee applied for each privilege purchased', + 'itemId': 'aslp-compact-fee', + 'name': 'ASLP Compact Fee', + 'quantity': '1', + 'taxable': 'None', + 'unitPrice': '50.5', + }, + ], + 'message': 'Successfully processed charge', + 'submitTimeUTC': MOCK_CURRENT_DATETIME, + }, + response, + ) @patch('purchase_client.createTransactionController') def test_purchase_client_returns_expected_line_items_in_response(self, mock_create_transaction_controller): diff --git a/backend/compact-connect/stacks/api_lambda_stack/purchases.py b/backend/compact-connect/stacks/api_lambda_stack/purchases.py index 94594ca9e..e3c78cf00 100644 --- a/backend/compact-connect/stacks/api_lambda_stack/purchases.py +++ b/backend/compact-connect/stacks/api_lambda_stack/purchases.py @@ -13,7 +13,7 @@ from common_constructs.python_function import PythonFunction from stacks import api_lambda_stack as als -from stacks.persistent_stack import CompactConfigurationTable, PersistentStack, ProviderTable +from stacks.persistent_stack import CompactConfigurationTable, PersistentStack, ProviderTable, TransactionHistoryTable class PurchasesLambdas: @@ -31,12 +31,14 @@ def __init__( data_encryption_key = persistent_stack.shared_encryption_key compact_configuration_table = persistent_stack.compact_configuration_table provider_data_table = persistent_stack.provider_table + transaction_history_table = persistent_stack.transaction_history_table alarm_topic = persistent_stack.alarm_topic lambda_environment = { 'COMPACT_CONFIGURATION_TABLE_NAME': compact_configuration_table.table_name, 'PROVIDER_TABLE_NAME': provider_data_table.table_name, 'EVENT_BUS_NAME': data_event_bus.event_bus_name, + 'TRANSACTION_HISTORY_TABLE_NAME': transaction_history_table.table_name, **stack.common_env_vars, } @@ -45,6 +47,7 @@ def __init__( data_encryption_key=data_encryption_key, compact_configuration_table=compact_configuration_table, provider_data_table=provider_data_table, + transaction_history_table=transaction_history_table, data_event_bus=data_event_bus, compact_payment_processor_secrets=compact_payment_processor_secrets, alarm_topic=alarm_topic, @@ -67,6 +70,7 @@ def _post_purchase_privileges_handler( data_encryption_key: IKey, compact_configuration_table: CompactConfigurationTable, provider_data_table: ProviderTable, + transaction_history_table: TransactionHistoryTable, data_event_bus: EventBus, compact_payment_processor_secrets: list[ISecret], lambda_environment: dict, @@ -100,6 +104,8 @@ def _post_purchase_privileges_handler( compact_configuration_table.grant_read_data(handler) # This lambda is responsible for adding privilege records to a provider after they have purchased them. provider_data_table.grant_read_write_data(handler) + # allow lambda to track unsettled transactions in the transaction history table + transaction_history_table.grant_read_write_data(handler) data_event_bus.grant_put_events_to(handler) # grant access to secrets manager secrets following this namespace pattern diff --git a/backend/compact-connect/stacks/event_listener_stack/__init__.py b/backend/compact-connect/stacks/event_listener_stack/__init__.py index a47edc3a8..afae5ddcf 100644 --- a/backend/compact-connect/stacks/event_listener_stack/__init__.py +++ b/backend/compact-connect/stacks/event_listener_stack/__init__.py @@ -60,6 +60,7 @@ def _add_license_encumbrance_listener(self, persistent_stack: ps.PersistentStack environment={ 'PROVIDER_TABLE_NAME': persistent_stack.provider_table.table_name, 'EMAIL_NOTIFICATION_SERVICE_LAMBDA_NAME': persistent_stack.email_notification_service_lambda.function_name, # noqa: E501 line-too-long + 'EVENT_BUS_NAME': data_event_bus.event_bus_name, **self.common_env_vars, }, alarm_topic=persistent_stack.alarm_topic, @@ -68,6 +69,7 @@ def _add_license_encumbrance_listener(self, persistent_stack: ps.PersistentStack # Grant necessary permissions persistent_stack.provider_table.grant_read_write_data(license_encumbrance_listener_handler) persistent_stack.email_notification_service_lambda.grant_invoke(license_encumbrance_listener_handler) + data_event_bus.grant_put_events_to(license_encumbrance_listener_handler) NagSuppressions.add_resource_suppressions_by_path( self, @@ -108,6 +110,7 @@ def _add_lifting_license_encumbrance_listener(self, persistent_stack: ps.Persist timeout=Duration.minutes(2), environment={ 'PROVIDER_TABLE_NAME': persistent_stack.provider_table.table_name, + 'EVENT_BUS_NAME': data_event_bus.event_bus_name, **self.common_env_vars, }, alarm_topic=persistent_stack.alarm_topic, @@ -115,6 +118,7 @@ def _add_lifting_license_encumbrance_listener(self, persistent_stack: ps.Persist # Grant necessary permissions persistent_stack.provider_table.grant_read_write_data(lifting_license_encumbrance_listener_handler) + data_event_bus.grant_put_events_to(lifting_license_encumbrance_listener_handler) NagSuppressions.add_resource_suppressions_by_path( self, diff --git a/backend/compact-connect/stacks/transaction_monitoring_stack/transaction_history_processing_workflow.py b/backend/compact-connect/stacks/transaction_monitoring_stack/transaction_history_processing_workflow.py index 77e05136a..a2a909c6b 100644 --- a/backend/compact-connect/stacks/transaction_monitoring_stack/transaction_history_processing_workflow.py +++ b/backend/compact-connect/stacks/transaction_monitoring_stack/transaction_history_processing_workflow.py @@ -9,7 +9,7 @@ from aws_cdk.aws_events_targets import SfnStateMachine from aws_cdk.aws_iam import Effect, PolicyStatement from aws_cdk.aws_lambda import Runtime -from aws_cdk.aws_logs import LogGroup, RetentionDays +from aws_cdk.aws_logs import FilterPattern, LogGroup, MetricFilter, RetentionDays from aws_cdk.aws_stepfunctions import ( Choice, Condition, @@ -76,7 +76,7 @@ def __init__( }, ], ) - persistent_stack.transaction_history_table.grant_write_data(self.transaction_processor_handler) + persistent_stack.transaction_history_table.grant_read_write_data(self.transaction_processor_handler) persistent_stack.provider_table.grant_read_data(self.transaction_processor_handler) persistent_stack.compact_configuration_table.grant_read_data(self.transaction_processor_handler) persistent_stack.shared_encryption_key.grant_encrypt(self.transaction_processor_handler) @@ -141,6 +141,9 @@ def __init__( 'compact': compact, 'template': 'transactionBatchSettlementFailure', 'recipientType': 'COMPACT_OPERATIONS_TEAM', + 'templateVariables': { + 'batchFailureErrorMessage.$': '$.Payload.batchFailureErrorMessage', + }, } ), result_path='$.notificationResult', @@ -235,6 +238,34 @@ def __init__( treat_missing_data=TreatMissingData.NOT_BREACHING, ).add_alarm_action(SnsAction(persistent_stack.alarm_topic)) + # Create a metric filter to capture ERROR level logs from the transaction processor Lambda + error_log_metric = MetricFilter( + self, + f'{compact}-TransactionProcessorErrorLogMetric', + log_group=self.transaction_processor_handler.log_group, + metric_namespace='CompactConnect/TransactionProcessing', + metric_name=f'{compact}/TransactionProcessorErrors', + filter_pattern=FilterPattern.string_value(json_field='$.level', comparison='=', value='ERROR'), + metric_value='1', + default_value=0, + ) + + # Create an alarm that triggers when ERROR logs are detected + error_log_alarm = Alarm( + self, + f'{compact}-TransactionProcessorErrorLogAlarm', + metric=error_log_metric.metric(statistic='Sum'), + evaluation_periods=1, + threshold=1, + actions_enabled=True, + alarm_description=f'The {compact} Transaction Processor Lambda logged an ERROR level message. Investigate ' + f'the logs for the {self.transaction_processor_handler.function_name} lambda to ' + f'determine the cause.', + comparison_operator=ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, + treat_missing_data=TreatMissingData.NOT_BREACHING, + ) + error_log_alarm.add_alarm_action(SnsAction(persistent_stack.alarm_topic)) + def _get_secrets_manager_compact_payment_processor_arn_for_compact( self, compact: str, environment_name: str ) -> str: diff --git a/backend/compact-connect/tests/app/test_transaction_monitoring.py b/backend/compact-connect/tests/app/test_transaction_monitoring.py index 2bacd1b9a..7e292ef63 100644 --- a/backend/compact-connect/tests/app/test_transaction_monitoring.py +++ b/backend/compact-connect/tests/app/test_transaction_monitoring.py @@ -213,6 +213,7 @@ def test_workflow_generates_expected_batch_failure_notification_step(self): 'compact': 'aslp', 'recipientType': 'COMPACT_OPERATIONS_TEAM', 'template': 'transactionBatchSettlementFailure', + 'templateVariables': {'batchFailureErrorMessage.$': '$.Payload.batchFailureErrorMessage'}, }, }, 'Resource': 'arn:${Token[AWS.Partition]}:states:::lambda:invoke',