Skip to content

Commit 1a9e9e4

Browse files
committed
Calculate start/end times once, persist across iterations
1 parent d19c22b commit 1a9e9e4

3 files changed

Lines changed: 166 additions & 26 deletions

File tree

backend/compact-connect/lambdas/python/purchases/handlers/transaction_history.py

Lines changed: 38 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -66,31 +66,7 @@ def process_settled_transactions(event: dict, context: LambdaContext) -> dict:
6666
'status': 'COMPLETE', # This will skip straight to the end of the step function flow
6767
}
6868

69-
# We collect a window spanning the most recent captured batch of settled transactions, through the scheduled
70-
# EventBridge time. Nominally this is a 1-day window, but if something goes wrong and causes a batch to be
71-
# delayed the window will expand in the subsequent run to pick up where it left off.
72-
end_time = datetime.fromisoformat(scheduled_time).replace(hour=1, minute=0, second=0, microsecond=0)
73-
# Authorize.net will only allow us to query a 31-day window, so we'll limit ourselves to 30. If we fail to
74-
# collect settled transactions for 30 days, we're well outside normal operations and will require manual
75-
# intervention to recover data
76-
oldest_allowed_start = end_time - timedelta(days=30)
77-
try:
78-
most_recent_settled_transaction = config.transaction_client.get_most_recent_transaction_for_compact(compact)
79-
# Time ranges are inclusive in the Authorize.net API, so we need to shift our start forward by 1 second
80-
most_recent_settlement = datetime.fromisoformat(
81-
most_recent_settled_transaction.batch['settlementTimeUTC']
82-
) + timedelta(seconds=1)
83-
except ValueError as e:
84-
# We should make some noise if we can't find any transactions, but it's also an expected state for a compact
85-
# that just went live, so we do need to be able to collect our first batch after launch. If we're in this
86-
# state we'll log an error and collect what we can.
87-
logger.warning('Failed to find transactions for compact', compact=compact, exc_info=e)
88-
most_recent_settlement = oldest_allowed_start
89-
start_time = max(most_recent_settlement, oldest_allowed_start)
90-
91-
# Format timestamps for API call
92-
start_time_str = start_time.strftime('%Y-%m-%dT%H:%M:%SZ')
93-
end_time_str = end_time.strftime('%Y-%m-%dT%H:%M:%SZ')
69+
start_time_str, end_time_str = _get_time_window_strings(event, compact)
9470

9571
logger.info(
9672
'Collecting settled transaction for time period',
@@ -126,6 +102,8 @@ def process_settled_transactions(event: dict, context: LambdaContext) -> dict:
126102
response = {
127103
'compact': compact, # Always include the compact name
128104
'scheduledTime': scheduled_time, # Preserve scheduled time for subsequent iterations
105+
'startTime': start_time_str,
106+
'endTime': end_time_str,
129107
'status': 'IN_PROGRESS' if not _all_transactions_processed(transaction_response) else 'COMPLETE',
130108
'processedBatchIds': transaction_response['processedBatchIds'],
131109
}
@@ -198,3 +176,38 @@ def process_settled_transactions(event: dict, context: LambdaContext) -> dict:
198176
response['status'] = 'BATCH_FAILURE'
199177

200178
return response
179+
180+
181+
def _get_time_window_strings(event: dict, compact: str):
182+
if event.get('startTime') and event.get('endTime'):
183+
start_time = datetime.fromisoformat(event['startTime'])
184+
end_time = datetime.fromisoformat(event['endTime'])
185+
logger.info(
186+
'Using start/end times from event', start_time=start_time.isoformat(), end_time=end_time.isoformat()
187+
)
188+
else:
189+
logger.info('Calculating start/end times')
190+
# We collect a window spanning the most recent captured batch of settled transactions, through the scheduled
191+
# EventBridge time. Nominally this is a 1-day window, but if something goes wrong and causes a batch to be
192+
# delayed the window will expand in the subsequent run to pick up where it left off.
193+
end_time = datetime.fromisoformat(event['scheduledTime']).replace(hour=1, minute=0, second=0, microsecond=0)
194+
# Authorize.net will only allow us to query a 31-day window, so we'll limit ourselves to 30. If we fail to
195+
# collect settled transactions for 30 days, we're well outside normal operations and will require manual
196+
# intervention to recover data
197+
oldest_allowed_start = end_time - timedelta(days=30)
198+
try:
199+
most_recent_settled_transaction = config.transaction_client.get_most_recent_transaction_for_compact(compact)
200+
# Time ranges are inclusive in the Authorize.net API, so we need to shift our start forward by 1 second
201+
most_recent_settlement = datetime.fromisoformat(
202+
most_recent_settled_transaction.batch['settlementTimeUTC']
203+
) + timedelta(seconds=1)
204+
except ValueError as e:
205+
# We should make some noise if we can't find any transactions, but it's also an expected state for a compact
206+
# that just went live, so we do need to be able to collect our first batch after launch. If we're in this
207+
# state we'll log an error and collect what we can.
208+
logger.warning('Failed to find transactions for compact', exc_info=e)
209+
most_recent_settlement = oldest_allowed_start
210+
start_time = max(most_recent_settlement, oldest_allowed_start)
211+
212+
# Format timestamps for API call
213+
return start_time.strftime('%Y-%m-%dT%H:%M:%SZ'), end_time.strftime('%Y-%m-%dT%H:%M:%SZ')

backend/compact-connect/lambdas/python/purchases/purchase_client.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
class AuthorizeNetTransactionIgnoreStates(StrEnum):
4444
DeclinedError = 'declined'
4545

46+
4647
class AuthorizeNetTransactionErrorStates(StrEnum):
4748
SettlementError = 'settlementError'
4849
GeneralError = 'generalError'
@@ -828,7 +829,7 @@ def get_settled_transactions(
828829
'Transaction was in an ignorable state. Skipping.',
829830
batch_id=batch_id,
830831
transaction_id=str(tx.transId),
831-
transaction_status=str(tx.transactionStatus)
832+
transaction_status=str(tx.transactionStatus),
832833
)
833834
continue
834835

backend/compact-connect/lambdas/python/purchases/tests/function/test_handlers/test_transaction_history.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,7 @@ def _add_previous_transaction_to_history(
242242
client.store_transactions(transactions=[previous_transaction])
243243

244244
@patch('handlers.transaction_history.PurchaseClient')
245+
@patch('cc_common.config._Config.current_standard_datetime', datetime.fromisoformat('2024-01-01T12:00:00+00:00'))
245246
def test_process_settled_transactions_returns_complete_status(self, mock_purchase_client_constructor):
246247
"""Test successful processing of settled transactions."""
247248
from handlers.transaction_history import process_settled_transactions
@@ -257,10 +258,20 @@ def test_process_settled_transactions_returns_complete_status(self, mock_purchas
257258
event = self._when_testing_non_paginated_event()
258259
resp = process_settled_transactions(event, self.mock_context)
259260

261+
# Calculate expected start/end times
262+
scheduled_dt = datetime.fromisoformat(MOCK_SCHEDULED_TIME)
263+
previous_settlement_dt = scheduled_dt - timedelta(days=2)
264+
expected_start_time = (previous_settlement_dt + timedelta(seconds=1)).strftime('%Y-%m-%dT%H:%M:%SZ')
265+
expected_end_time = scheduled_dt.replace(hour=1, minute=0, second=0, microsecond=0).strftime(
266+
'%Y-%m-%dT%H:%M:%SZ'
267+
)
268+
260269
self.assertEqual(
261270
{
262271
'compact': 'aslp',
263272
'scheduledTime': MOCK_SCHEDULED_TIME,
273+
'startTime': expected_start_time,
274+
'endTime': expected_end_time,
264275
'processedBatchIds': [MOCK_BATCH_ID],
265276
'status': 'COMPLETE',
266277
},
@@ -404,6 +415,7 @@ def test_process_settled_transactions_does_not_duplicate_identical_transaction_r
404415
self.assertEqual(1, len(stored_transactions['Items']))
405416

406417
@patch('handlers.transaction_history.PurchaseClient')
418+
@patch('cc_common.config._Config.current_standard_datetime', datetime.fromisoformat('2024-01-01T12:00:00+00:00'))
407419
def test_process_settled_transactions_returns_in_progress_status_with_pagination_values(
408420
self, mock_purchase_client_constructor
409421
):
@@ -418,10 +430,20 @@ def test_process_settled_transactions_returns_in_progress_status_with_pagination
418430
event = self._when_testing_non_paginated_event()
419431
resp = process_settled_transactions(event, self.mock_context)
420432

433+
# Calculate expected start/end times
434+
scheduled_dt = datetime.fromisoformat(MOCK_SCHEDULED_TIME)
435+
previous_settlement_dt = scheduled_dt - timedelta(days=2)
436+
expected_start_time = (previous_settlement_dt + timedelta(seconds=1)).strftime('%Y-%m-%dT%H:%M:%SZ')
437+
expected_end_time = scheduled_dt.replace(hour=1, minute=0, second=0, microsecond=0).strftime(
438+
'%Y-%m-%dT%H:%M:%SZ'
439+
)
440+
421441
self.assertEqual(
422442
{
423443
'compact': TEST_COMPACT,
424444
'scheduledTime': MOCK_SCHEDULED_TIME,
445+
'startTime': expected_start_time,
446+
'endTime': expected_end_time,
425447
'status': 'IN_PROGRESS',
426448
'lastProcessedTransactionId': MOCK_LAST_PROCESSED_TRANSACTION_ID,
427449
'currentBatchId': MOCK_CURRENT_BATCH_ID,
@@ -431,6 +453,7 @@ def test_process_settled_transactions_returns_in_progress_status_with_pagination
431453
)
432454

433455
@patch('handlers.transaction_history.PurchaseClient')
456+
@patch('cc_common.config._Config.current_standard_datetime', datetime.fromisoformat('2024-01-01T12:00:00+00:00'))
434457
def test_process_settled_transactions_returns_batch_failure_status_after_processing_all_transaction(
435458
self, mock_purchase_client_constructor
436459
):
@@ -488,11 +511,21 @@ def test_process_settled_transactions_returns_batch_failure_status_after_process
488511
event = self._when_testing_non_paginated_event()
489512
first_resp = process_settled_transactions(event, self.mock_context)
490513

514+
# Calculate expected start/end times
515+
scheduled_dt = datetime.fromisoformat(MOCK_SCHEDULED_TIME)
516+
previous_settlement_dt = scheduled_dt - timedelta(days=2)
517+
expected_start_time = (previous_settlement_dt + timedelta(seconds=1)).strftime('%Y-%m-%dT%H:%M:%SZ')
518+
expected_end_time = scheduled_dt.replace(hour=1, minute=0, second=0, microsecond=0).strftime(
519+
'%Y-%m-%dT%H:%M:%SZ'
520+
)
521+
491522
self.assertEqual(
492523
{
493524
'status': 'IN_PROGRESS',
494525
'compact': TEST_COMPACT,
495526
'scheduledTime': MOCK_SCHEDULED_TIME,
527+
'startTime': expected_start_time,
528+
'endTime': expected_end_time,
496529
'currentBatchId': MOCK_BATCH_ID,
497530
'lastProcessedTransactionId': mock_first_iteration_failed_transaction_id,
498531
'processedBatchIds': [],
@@ -516,6 +549,8 @@ def test_process_settled_transactions_returns_batch_failure_status_after_process
516549
'status': 'BATCH_FAILURE',
517550
'compact': TEST_COMPACT,
518551
'scheduledTime': MOCK_SCHEDULED_TIME,
552+
'startTime': expected_start_time,
553+
'endTime': expected_end_time,
519554
'processedBatchIds': [MOCK_BATCH_ID],
520555
'batchFailureErrorMessage': json.dumps(
521556
{
@@ -930,3 +965,94 @@ def test_process_settled_transactions_uses_30_day_fallback_when_no_previous_tran
930965
mock_purchase_client = mock_purchase_client_constructor.return_value
931966
call_kwargs = mock_purchase_client.get_settled_transactions.call_args.kwargs
932967
self.assertEqual(expected_start_time, call_kwargs['start_time'])
968+
969+
@patch('handlers.transaction_history.PurchaseClient')
970+
@patch('cc_common.config._Config.current_standard_datetime', datetime.fromisoformat('2024-01-01T12:00:00+00:00'))
971+
def test_process_settled_transactions_uses_provided_start_and_end_times(self, mock_purchase_client_constructor):
972+
"""
973+
Test that when startTime and endTime are provided in the event, they are used instead of calculating new ones.
974+
"""
975+
from handlers.transaction_history import process_settled_transactions
976+
977+
self._when_purchase_client_returns_transactions(mock_purchase_client_constructor)
978+
self._add_mock_privilege_to_database()
979+
self._add_compact_configuration_data()
980+
# Add a previous transaction to simulate normal operation
981+
self._add_previous_transaction_to_history()
982+
983+
# Provide start and end times in the event
984+
provided_start_time = '2023-12-15T10:00:00Z'
985+
provided_end_time = '2024-01-01T05:00:00Z'
986+
event = {
987+
'compact': TEST_COMPACT,
988+
'scheduledTime': MOCK_SCHEDULED_TIME,
989+
'startTime': provided_start_time,
990+
'endTime': provided_end_time,
991+
'lastProcessedTransactionId': None,
992+
'currentBatchId': None,
993+
'processedBatchIds': None,
994+
}
995+
996+
resp = process_settled_transactions(event, self.mock_context)
997+
998+
# Verify that the provided times are used and returned in the response
999+
self.assertEqual(provided_start_time, resp['startTime'])
1000+
self.assertEqual(provided_end_time, resp['endTime'])
1001+
1002+
# Verify that get_settled_transactions was called with the provided times
1003+
mock_purchase_client = mock_purchase_client_constructor.return_value
1004+
call_kwargs = mock_purchase_client.get_settled_transactions.call_args.kwargs
1005+
self.assertEqual(provided_start_time, call_kwargs['start_time'])
1006+
self.assertEqual(provided_end_time, call_kwargs['end_time'])
1007+
1008+
@patch('handlers.transaction_history.PurchaseClient')
1009+
@patch('cc_common.config._Config.current_standard_datetime', datetime.fromisoformat('2024-01-01T12:00:00+00:00'))
1010+
def test_process_settled_transactions_persists_start_and_end_times_across_pagination_iterations(
1011+
self, mock_purchase_client_constructor
1012+
):
1013+
"""Test that startTime and endTime persist across pagination iterations."""
1014+
from handlers.transaction_history import process_settled_transactions
1015+
1016+
# Set up paginated transactions
1017+
self._when_purchase_client_returns_paginated_transactions(mock_purchase_client_constructor)
1018+
self._add_mock_privilege_to_database()
1019+
self._add_compact_configuration_data()
1020+
# Add a previous transaction to simulate normal operation
1021+
self._add_previous_transaction_to_history()
1022+
1023+
# First iteration: no start/end times provided, they should be calculated
1024+
event = self._when_testing_non_paginated_event()
1025+
resp = process_settled_transactions(event, self.mock_context)
1026+
1027+
# Verify start/end times are calculated and returned
1028+
self.assertIn('startTime', resp)
1029+
self.assertIn('endTime', resp)
1030+
first_iteration_start_time = resp['startTime']
1031+
first_iteration_end_time = resp['endTime']
1032+
1033+
# Verify status is IN_PROGRESS
1034+
self.assertEqual('IN_PROGRESS', resp['status'])
1035+
1036+
# Second iteration: use the start/end times from the first iteration
1037+
event = {
1038+
'compact': TEST_COMPACT,
1039+
'scheduledTime': MOCK_SCHEDULED_TIME,
1040+
'startTime': first_iteration_start_time,
1041+
'endTime': first_iteration_end_time,
1042+
'lastProcessedTransactionId': MOCK_LAST_PROCESSED_TRANSACTION_ID,
1043+
'currentBatchId': MOCK_CURRENT_BATCH_ID,
1044+
'processedBatchIds': [MOCK_BATCH_ID],
1045+
}
1046+
1047+
resp = process_settled_transactions(event, self.mock_context)
1048+
1049+
# Verify that the same start/end times are persisted in the response
1050+
self.assertEqual(first_iteration_start_time, resp['startTime'])
1051+
self.assertEqual(first_iteration_end_time, resp['endTime'])
1052+
1053+
# Verify that get_settled_transactions was called with the persisted times
1054+
mock_purchase_client = mock_purchase_client_constructor.return_value
1055+
# Get the second call (index 1) since we made two calls
1056+
second_call_kwargs = mock_purchase_client.get_settled_transactions.call_args_list[1].kwargs
1057+
self.assertEqual(first_iteration_start_time, second_call_kwargs['start_time'])
1058+
self.assertEqual(first_iteration_end_time, second_call_kwargs['end_time'])

0 commit comments

Comments
 (0)