Skip to content

Commit 8135251

Browse files
authored
Bug/handle authorizenet delayed reporting (#1201)
### Description List - Updated batch query logic to start time window just after most recent collected batch settlement time - Moved collector timing back by one more hour - Updated related code to leverage newer CCDataClass and TestDataGenerator patterns ### Testing List - Run transaction collection in sandbox - Code review Closes #1192 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Release Notes * **New Features** * Enhanced transaction history processing with dynamic time window retrieval for improved accuracy. * Improved handling of settled and unsettled transaction reconciliation. * **Bug Fixes** * Declined transactions from payment processors are now properly skipped. * Better detection and handling of old unsettled transactions. * **Chores** * Adjusted transaction processing schedule to account for settlement delays. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 5e5eaf2 commit 8135251

15 files changed

Lines changed: 1219 additions & 363 deletions

File tree

backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/purchase/__init__.py

Whitespace-only changes.

backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/purchase/api.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
# ruff: noqa: N801, N815 invalid-name
2+
from marshmallow import ValidationError, validates_schema
3+
from marshmallow.fields import Dict, List, String
4+
25
from cc_common.data_model.schema.base_record import ForgivingSchema
36
from cc_common.data_model.schema.compact.api import CompactOptionsResponseSchema
47
from cc_common.data_model.schema.compact.common import COMPACT_TYPE
58
from cc_common.data_model.schema.jurisdiction.api import JurisdictionOptionsResponseSchema
69
from cc_common.data_model.schema.jurisdiction.common import JURISDICTION_TYPE
7-
from marshmallow import ValidationError, validates_schema
8-
from marshmallow.fields import Dict, List, String
910

1011

1112
class PurchasePrivilegeOptionsResponseSchema(ForgivingSchema):
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# ruff: noqa: N802 we use camelCase to match the marshmallow schema definition
2+
3+
from cc_common.data_model.schema.common import CCDataClass
4+
from cc_common.data_model.schema.transaction.record import TransactionRecordSchema
5+
6+
7+
class TransactionData(CCDataClass):
8+
"""
9+
Class representing a Transaction with read-only properties.
10+
11+
Unlike several other CCDataClass subclasses, this one does not include setters. This is because
12+
transaction records are only created during transaction processing, so we can pass the entire record
13+
from the processing into the constructor.
14+
15+
Note: This class requires valid data when created - it cannot be instantiated empty
16+
and populated later.
17+
"""
18+
19+
# Define the record schema at the class level
20+
_record_schema = TransactionRecordSchema()
21+
22+
# Require valid data when creating instances
23+
_requires_data_at_construction = True
24+
25+
@property
26+
def transactionProcessor(self) -> str:
27+
return self._data['transactionProcessor']
28+
29+
@property
30+
def transactionId(self) -> str:
31+
return self._data['transactionId']
32+
33+
@property
34+
def batch(self) -> dict:
35+
"""Batch information containing batchId, settlementState, settlementTimeLocal, and settlementTimeUTC."""
36+
return self._data['batch']
37+
38+
@property
39+
def lineItems(self) -> list[dict]:
40+
"""
41+
List of line items, each containing description, itemId, name, quantity, taxable,
42+
unitPrice, and optionally privilegeId.
43+
"""
44+
return self._data['lineItems']
45+
46+
@property
47+
def compact(self) -> str:
48+
return self._data['compact']
49+
50+
@property
51+
def licenseeId(self) -> str:
52+
return self._data['licenseeId']
53+
54+
@property
55+
def responseCode(self) -> str:
56+
return self._data['responseCode']
57+
58+
@property
59+
def settleAmount(self) -> str:
60+
return self._data['settleAmount']
61+
62+
@property
63+
def submitTimeUTC(self) -> str:
64+
return self._data['submitTimeUTC']
65+
66+
@property
67+
def transactionStatus(self) -> str:
68+
return self._data['transactionStatus']
69+
70+
@property
71+
def transactionType(self) -> str:
72+
return self._data['transactionType']

backend/compact-connect/lambdas/python/common/cc_common/data_model/transaction_client.py

Lines changed: 75 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
from boto3.dynamodb.conditions import Key
44

55
from cc_common.config import _Config, logger
6-
from cc_common.data_model.schema.transaction.record import TransactionRecordSchema, UnsettledTransactionRecordSchema
6+
from cc_common.data_model.schema.transaction import TransactionData
7+
from cc_common.data_model.schema.transaction.record import UnsettledTransactionRecordSchema
78

89
AUTHORIZE_DOT_NET_CLIENT_TYPE = 'authorize.net'
910

@@ -14,21 +15,18 @@ class TransactionClient:
1415
def __init__(self, config: _Config):
1516
self.config = config
1617

17-
def store_transactions(self, transactions: list[dict]) -> None:
18+
def store_transactions(self, transactions: list[TransactionData]) -> None:
1819
"""
1920
Store transaction records in DynamoDB.
2021
21-
:param compact: The compact name
2222
:param transactions: List of transaction records to store
2323
"""
2424
with self.config.transaction_history_table.batch_writer() as batch:
2525
for transaction in transactions:
2626
# Convert UTC timestamp to epoch for sorting
27-
transaction_processor = transaction['transactionProcessor']
27+
transaction_processor = transaction.transactionProcessor
2828
if transaction_processor == AUTHORIZE_DOT_NET_CLIENT_TYPE:
29-
transaction_schema = TransactionRecordSchema()
30-
31-
serialized_record = transaction_schema.dump(transaction)
29+
serialized_record = transaction.serialize_to_database_record()
3230
batch.put_item(Item=serialized_record)
3331
else:
3432
raise ValueError(f'Unsupported transaction processor: {transaction_processor}')
@@ -80,6 +78,49 @@ def get_transactions_in_range(self, compact: str, start_epoch: int, end_epoch: i
8078

8179
return all_items
8280

81+
def get_most_recent_transaction_for_compact(self, compact: str) -> TransactionData:
82+
"""
83+
Get the most recent transaction for a compact.
84+
85+
Starts by querying the current month's partition key (based on config.current_standard_datetime),
86+
then sequentially queries previous months until a record is found.
87+
88+
:param compact: The compact name
89+
:return: The most recent transaction for the compact
90+
:raises ValueError: If no transactions are found for the compact
91+
"""
92+
# Start with the current month
93+
current_date = self.config.current_standard_datetime.replace(day=1)
94+
# During normal operations, the most recent transaction should be no more than two days old, if there were any
95+
# transactions in that period. We'll look back up to three months, which should cover most reasonable
96+
# situations.
97+
max_months_to_check = 3
98+
99+
for _ in range(max_months_to_check):
100+
month_key = current_date.strftime('%Y-%m')
101+
pk = f'COMPACT#{compact}#TRANSACTIONS#MONTH#{month_key}'
102+
103+
# Query for the most recent transaction in this month (descending order, limit 1)
104+
response = self.config.transaction_history_table.query(
105+
KeyConditionExpression=Key('pk').eq(pk),
106+
ScanIndexForward=False, # Descending order (most recent first)
107+
Limit=1,
108+
)
109+
110+
items = response.get('Items', [])
111+
if items:
112+
# Found a transaction, return it
113+
return TransactionData.from_database_record(items[0])
114+
115+
# Move to previous month
116+
if current_date.month == 1:
117+
current_date = current_date.replace(year=current_date.year - 1, month=12)
118+
else:
119+
current_date = current_date.replace(month=current_date.month - 1)
120+
121+
# No transactions found after checking max_months_to_check months
122+
raise ValueError(f'No transactions found for compact: {compact}')
123+
83124
def _query_transactions_for_month(
84125
self,
85126
compact: str,
@@ -122,10 +163,13 @@ def _query_transactions_for_month(
122163

123164
def _set_privilege_id_in_line_item(self, line_items: list[dict], item_id_prefix: str, privilege_id: str):
124165
for line_item in line_items:
125-
if line_item.get('itemId').lower().startswith(item_id_prefix.lower()):
166+
item_id = line_item.get('itemId')
167+
if item_id and item_id.lower().startswith(item_id_prefix.lower()):
126168
line_item['privilegeId'] = privilege_id
127169

128-
def add_privilege_information_to_transactions(self, compact: str, transactions: list[dict]) -> list[dict]:
170+
def add_privilege_information_to_transactions(
171+
self, compact: str, transactions: list[TransactionData]
172+
) -> list[TransactionData]:
129173
"""
130174
Add privilege and licensee IDs to transaction line items based on the jurisdiction they were purchased for.
131175
@@ -134,7 +178,7 @@ def add_privilege_information_to_transactions(self, compact: str, transactions:
134178
:return: Modified list of transactions with privilege and licensee IDs added to line items
135179
"""
136180
for transaction in transactions:
137-
line_items = transaction['lineItems']
181+
line_items = transaction.lineItems
138182
# Extract jurisdictions from line items with format priv:{compact}-{jurisdiction}-{license type abbr}
139183
jurisdictions_to_process = set()
140184
for line_item in line_items:
@@ -145,7 +189,7 @@ def add_privilege_information_to_transactions(self, compact: str, transactions:
145189
jurisdictions_to_process.add(jurisdiction)
146190

147191
# Query for privilege records using the GSI
148-
gsi_pk = f'COMPACT#{compact}#TX#{transaction["transactionId"]}#'
192+
gsi_pk = f'COMPACT#{compact}#TX#{transaction.transactionId}#'
149193
response = self.config.provider_table.query(
150194
IndexName=self.config.compact_transaction_id_gsi_name,
151195
KeyConditionExpression=Key('compactTransactionIdGSIPK').eq(gsi_pk),
@@ -157,9 +201,9 @@ def add_privilege_information_to_transactions(self, compact: str, transactions:
157201
logger.error(
158202
'No privilege records found for this transaction id.',
159203
compact=compact,
160-
transaction_id=transaction['transactionId'],
204+
transaction_id=transaction.transactionId,
161205
# attempt to grab the licensee id from the authorize.net data, which may be invalid if it was masked
162-
licensee_id=transaction['licenseeId'],
206+
licensee_id=transaction.licenseeId,
163207
)
164208
# We mark the data as UNKNOWN so it still shows up in the history,
165209
# and move onto the next transaction
@@ -182,16 +226,16 @@ def add_privilege_information_to_transactions(self, compact: str, transactions:
182226
logger.error(
183227
'More than one matching provider id found for a transaction id.',
184228
compact=compact,
185-
transaction_id=transaction['transactionId'],
229+
transaction_id=transaction.transactionId,
186230
# attempt to grab the licensee id from the authorize.net data, which may be invalid if it was masked
187-
provider_ids=transaction['licenseeId'],
231+
provider_ids=transaction.licenseeId,
188232
)
189233

190234
# The licensee id recorded in Authorize.net cannot be trusted, as Authorize.net masks any values that look
191235
# like a credit card number (consecutive digits separated by dashes). We need to grab the provider id from
192236
# the privileges associated with this transaction and set the licensee id on the transaction to that value
193237
# to ensure it is valid.
194-
transaction['licenseeId'] = provider_ids.pop()
238+
transaction.update({'licenseeId': provider_ids.pop()})
195239

196240
# Process each privilege record
197241
for jurisdiction in jurisdictions_to_process:
@@ -220,15 +264,16 @@ def add_privilege_information_to_transactions(self, compact: str, transactions:
220264
'No matching jurisdiction privilege record found for transaction. '
221265
'Cannot determine privilege id for this transaction',
222266
compact=compact,
223-
transactionId=transaction['transactionId'],
267+
transactionId=transaction.transactionId,
224268
jurisdiction=jurisdiction,
225-
provider_id=transaction['licenseeId'],
269+
provider_id=transaction.licenseeId,
226270
matching_privilege_records=response.get('Items', []),
227271
)
228272
# we set the privilege id to UNKNOWN, so that it will be visible in the report
229273
self._set_privilege_id_in_line_item(
230274
line_items=line_items, item_id_prefix=item_id_prefix, privilege_id='UNKNOWN'
231275
)
276+
transaction.update({'lineItems': line_items})
232277

233278
return transactions
234279

@@ -269,7 +314,7 @@ def store_unsettled_transaction(self, compact: str, transaction_id: str, transac
269314
error=str(e),
270315
)
271316

272-
def reconcile_unsettled_transactions(self, compact: str, settled_transactions: list[dict]) -> list[str]:
317+
def reconcile_unsettled_transactions(self, compact: str, settled_transactions: list[TransactionData]) -> list[str]:
273318
"""
274319
Reconcile unsettled transactions with settled transactions and detect old unsettled transactions.
275320
@@ -297,7 +342,7 @@ def reconcile_unsettled_transactions(self, compact: str, settled_transactions: l
297342
return []
298343

299344
# Create a set of settled transaction IDs for efficient lookup
300-
settled_transaction_ids = {tx['transactionId'] for tx in settled_transactions}
345+
settled_transaction_ids = {tx.transactionId for tx in settled_transactions}
301346

302347
# Separate matched and unmatched unsettled transactions
303348
matched_unsettled = []
@@ -325,6 +370,16 @@ def reconcile_unsettled_transactions(self, compact: str, settled_transactions: l
325370
cutoff_time = datetime.now(UTC) - timedelta(hours=48)
326371
old_unsettled_transactions = []
327372

373+
# We expect that all transactions we process from Authorize.net will match a record we have already
374+
# created at the time of purchase, as an unsettled transaction. Any mismatch is an error.
375+
matched_unsettled_transaction_ids = {tx['transactionId'] for tx in matched_unsettled}
376+
unmatched_settled_transaction_ids = settled_transaction_ids - matched_unsettled_transaction_ids
377+
if unmatched_settled_transaction_ids:
378+
logger.error(
379+
'Unable to reconcile some transactions from Authorize.Net with our unsettled transactions',
380+
unreconciled_transactions=unmatched_settled_transaction_ids
381+
)
382+
328383
for unsettled_tx in unmatched_unsettled:
329384
transaction_date = datetime.fromisoformat(unsettled_tx['transactionDate'])
330385
if transaction_date < cutoff_time:

backend/compact-connect/lambdas/python/common/common_test/test_constants.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,37 @@
2929
DEFAULT_PRIVILEGE_EXPIRATION_DATE = '2025-04-04'
3030
DEFAULT_PRIVILEGE_UPDATE_DATETIME = '2020-05-05T12:59:59+00:00'
3131
DEFAULT_COMPACT_TRANSACTION_ID = '1234567890'
32+
DEFAULT_COMPACT_TRANSACTION_BATCH = {
33+
'batchId': '67890',
34+
'settlementState': 'settledSuccessfully',
35+
'settlementTimeLocal': '2024-01-01T09:00:00',
36+
'settlementTimeUTC': '2024-01-01T13:00:00.000Z',
37+
}
38+
DEFAULT_COMPACT_TRANSACTION_PRIVILEGE_LINE_ITEM = {
39+
'description': 'Compact Privilege for Ohio',
40+
'itemId': 'priv:aslp-oh',
41+
'name': 'Ohio Compact Privilege',
42+
'quantity': '1.0',
43+
'taxable': 'False',
44+
'unitPrice': '100.00',
45+
'privilegeId': 'mock-privilege-id-oh',
46+
}
47+
DEFAULT_COMPACT_TRANSACTION_COMPACT_LINE_ITEM = {
48+
'description': 'Compact fee applied for each privilege purchased',
49+
'itemId': 'aslp-compact-fee',
50+
'name': 'ASLP Compact Fee',
51+
'quantity': '1',
52+
'taxable': 'False',
53+
'unitPrice': '10.50',
54+
}
55+
DEFAULT_COMPACT_TRANSACTION_FEE_LINE_ITEM = {
56+
'description': 'credit card transaction fee',
57+
'itemId': 'credit-card-transaction-fee',
58+
'name': 'Credit Card Transaction Fee',
59+
'quantity': '1',
60+
'taxable': 'False',
61+
'unitPrice': '3.00',
62+
}
3263
DEFAULT_PRIVILEGE_ID = 'SLP-NE-1'
3364
DEFAULT_MILITARY_AFFILIATION_TYPE = 'militaryMember'
3465
DEFAULT_MILITARY_STATUS = 'active'
@@ -57,6 +88,7 @@
5788
PRIVILEGE_RECORD_TYPE = 'privilege'
5889
PRIVILEGE_UPDATE_RECORD_TYPE = 'privilegeUpdate'
5990
PROVIDER_RECORD_TYPE = 'provider'
91+
TRANSACTION_RECORD_TYPE = 'transaction'
6092

6193
# Privilege update default values
6294
DEFAULT_PRIVILEGE_UPDATE_TYPE = 'renewal'

backend/compact-connect/lambdas/python/common/common_test/test_data_generator.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# ruff: noqa: F403, F405 star import of test constants file
22
import json
3+
from copy import deepcopy
34
from datetime import date, datetime
45
from decimal import Decimal
56

@@ -648,6 +649,38 @@ def put_default_jurisdiction_configuration_in_configuration_table(
648649

649650
return jurisdiction_config
650651

652+
@staticmethod
653+
def generate_default_transaction(value_overrides: dict | None = None):
654+
"""Generate a default transaction"""
655+
from cc_common.data_model.schema.transaction import TransactionData
656+
657+
# We'll fill in any missing batch values with defaults
658+
default_batch = deepcopy(DEFAULT_COMPACT_TRANSACTION_BATCH)
659+
if value_overrides and 'batch' in value_overrides.keys():
660+
default_batch.update(value_overrides.pop('batch'))
661+
662+
default_transaction = {
663+
'transactionProcessor': 'authorize.net',
664+
'transactionId': DEFAULT_COMPACT_TRANSACTION_ID,
665+
'batch': default_batch,
666+
'lineItems': [
667+
DEFAULT_COMPACT_TRANSACTION_PRIVILEGE_LINE_ITEM,
668+
DEFAULT_COMPACT_TRANSACTION_COMPACT_LINE_ITEM,
669+
DEFAULT_COMPACT_TRANSACTION_FEE_LINE_ITEM,
670+
],
671+
'compact': DEFAULT_COMPACT,
672+
'licenseeId': DEFAULT_PROVIDER_ID,
673+
'responseCode': '1',
674+
'settleAmount': '113.50',
675+
'submitTimeUTC': '2024-01-01T12:00:00.000Z',
676+
'transactionStatus': 'settledSuccessfully',
677+
'transactionType': 'authCaptureTransaction',
678+
}
679+
if value_overrides:
680+
default_transaction.update(value_overrides)
681+
682+
return TransactionData.create_new(default_transaction)
683+
651684
@staticmethod
652685
def put_compact_active_member_jurisdictions(
653686
compact: str = DEFAULT_COMPACT, postal_abbreviations: list[str] = None

backend/compact-connect/lambdas/python/common/tests/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,9 @@ def setUpClass(cls):
104104
# Monkey-patch config object to be sure we have it based
105105
# on the env vars we set above
106106
import cc_common.config
107+
from common_test.test_data_generator import TestDataGenerator
107108

108109
cls.config = cc_common.config._Config() # noqa: SLF001 protected-access
109110
cc_common.config.config = cls.config
110111
cls.mock_context = MagicMock(name='MockLambdaContext', spec=LambdaContext)
112+
cls.test_data_generator = TestDataGenerator

0 commit comments

Comments
 (0)