Skip to content

Commit f9288ee

Browse files
Merge pull request #1165 from InspiringApps/hotfix/align-transcation-reporting-windows
Hotfix/align transcation reporting windows
2 parents c592e59 + 35ecb3b commit f9288ee

8 files changed

Lines changed: 590 additions & 254 deletions

File tree

backend/compact-connect/lambdas/python/common/cc_common/email_service_client.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import json
22
from dataclasses import dataclass
3-
from datetime import date, datetime
3+
from datetime import date
44
from typing import Any, Protocol
55
from uuid import UUID
66

@@ -144,17 +144,17 @@ def send_compact_transaction_report_email(
144144
compact: str,
145145
report_s3_path: str,
146146
reporting_cycle: str,
147-
start_date: datetime,
148-
end_date: datetime,
147+
start_date: date,
148+
end_date: date,
149149
) -> dict[str, Any]:
150150
"""
151151
Send a compact transaction report email.
152152
153153
:param compact: Compact name
154154
:param report_s3_path: S3 path to the report zip file
155155
:param reporting_cycle: Reporting cycle (e.g., 'weekly', 'monthly')
156-
:param start_date: Start datetime of the reporting period
157-
:param end_date: End datetime of the reporting period
156+
:param start_date: Start date of the reporting period
157+
:param end_date: End date of the reporting period
158158
:return: Response from the email notification service
159159
"""
160160

@@ -178,8 +178,8 @@ def send_jurisdiction_transaction_report_email(
178178
jurisdiction: str,
179179
report_s3_path: str,
180180
reporting_cycle: str,
181-
start_date: datetime,
182-
end_date: datetime,
181+
start_date: date,
182+
end_date: date,
183183
) -> dict[str, str]:
184184
"""
185185
Send a jurisdiction transaction report email.

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

Lines changed: 42 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from __future__ import annotations
22

33
import csv
4-
from datetime import datetime, timedelta
4+
from datetime import date, datetime
55
from decimal import Decimal
66
from enum import StrEnum
77
from io import BytesIO, StringIO
@@ -13,6 +13,7 @@
1313
from cc_common.data_model.schema.compact.common import COMPACT_TYPE
1414
from cc_common.data_model.schema.jurisdiction.common import JURISDICTION_TYPE
1515
from cc_common.exceptions import CCInternalException
16+
from report_window import ReportCycle, ReportWindow
1617

1718

1819
class ReportableTransactionStatuses(StrEnum):
@@ -21,73 +22,10 @@ class ReportableTransactionStatuses(StrEnum):
2122
SettledSuccessfully = 'settledSuccessfully'
2223

2324

24-
def _get_display_date_range(reporting_cycle: str) -> tuple[datetime, datetime]:
25-
"""Get the display date range for reports.
26-
27-
These dates are used for report filenames and email notifications.
28-
29-
:param reporting_cycle: Either 'weekly' or 'monthly'
30-
:return: Tuple of (start_time, end_time) in UTC for display purposes
31-
"""
32-
if reporting_cycle == 'weekly':
33-
end_time = config.current_standard_datetime
34-
# Go back 7 days to capture the full week
35-
start_time = end_time - timedelta(days=7)
36-
return start_time, end_time
37-
if reporting_cycle == 'monthly':
38-
# Reports run on the first day of the month.
39-
# Knowing this, we can use the current date to get the start and end of the month.
40-
# By going back 1 day from the first day of the current month, we get the last day of the previous month.
41-
end_time = config.current_standard_datetime.replace(
42-
day=1, hour=0, minute=0, second=0, microsecond=0
43-
) - timedelta(days=1)
44-
# Start time is the first day of the previous month
45-
start_time = end_time.replace(day=1)
46-
return start_time, end_time
47-
raise ValueError(f'Invalid reporting cycle: {reporting_cycle}')
48-
49-
50-
def _get_query_date_range(reporting_cycle: str) -> tuple[datetime, datetime]:
51-
"""Get the query date range for DynamoDB queries.
52-
53-
Our Sort Key format for transactions includes additional components after the timestamp
54-
(COMPACT#name#TIME#timestamp#BATCH#id#TX#id), So the DynamoDB BETWEEN condition is INCLUSIVE for the beginning
55-
range and EXCLUSIVE at the end range. This is because DynamoDB performs lexicographical comparison on the entire
56-
sort key string. When the sort key continues beyond the comparison value:
57-
58-
- For the lower bound: Additional characters after the comparison point make the full key "greater than" the bound,
59-
satisfying the >= condition
60-
- For the upper bound: Additional characters after the comparison point make the full key "greater than" the bound,
61-
failing the <= condition
62-
63-
We need to adjust our timestamps accordingly to ensure we capture all settled transactions exactly once.
64-
65-
:param reporting_cycle: Either 'weekly' or 'monthly'
66-
:return: Tuple of (start_time, end_time) in UTC for DynamoDB queries
67-
"""
68-
if reporting_cycle == 'weekly':
69-
# Reports run on Friday 10:00 PM UTC
70-
end_time = config.current_standard_datetime.replace(hour=22, minute=0, second=0, microsecond=0)
71-
# Go back 7 days to capture the full week
72-
start_time = end_time - timedelta(days=7)
73-
return start_time, end_time
74-
75-
if reporting_cycle == 'monthly':
76-
# Reports run on the first day of the month
77-
# End time is midnight, since that will be excluded from the BETWEEN key condition
78-
end_time = config.current_standard_datetime.replace(hour=0, minute=0, second=0, microsecond=0)
79-
# Start time is midnight of the previous month
80-
start_time = (end_time - timedelta(days=1)).replace(day=1, hour=0, minute=0, second=0, microsecond=0)
81-
return start_time, end_time
82-
83-
raise ValueError(f'Invalid reporting cycle: {reporting_cycle}')
84-
85-
8625
def _store_compact_reports_in_s3(
8726
compact: str,
8827
reporting_cycle: str,
89-
start_time: datetime,
90-
end_time: datetime,
28+
report_window: ReportWindow,
9129
summary_report: str,
9230
transaction_detail: str,
9331
bucket_name: str,
@@ -96,33 +34,31 @@ def _store_compact_reports_in_s3(
9634
9735
:param compact: Compact name
9836
:param reporting_cycle: Either 'weekly' or 'monthly'
99-
:param start_time: Report start time
100-
:param end_time: Report end time
37+
:param report_window: the Report Window
10138
:param summary_report: Financial summary report CSV content
10239
:param transaction_detail: Transaction detail report CSV content
10340
:param bucket_name: S3 bucket name
10441
:return: Dictionary of file types to their S3 paths
10542
"""
106-
date_range = f'{start_time.strftime("%Y-%m-%d")}--{end_time.strftime("%Y-%m-%d")}'
10743
base_path = (
10844
f'compact/{compact}/reports/compact-transactions/reporting-cycle/{reporting_cycle}/'
109-
f'{end_time.strftime("%Y/%m/%d")}'
45+
f'{report_window.display_end.strftime("%Y/%m/%d")}'
11046
)
11147

11248
# Define paths for all report files
11349
# Currently, we are only sending the .zip file in the email reporting, but there is potential
11450
# to store .gz files in the future
11551
paths = {
116-
'report_zip': f'{base_path}/{compact}-{date_range}-report.zip',
52+
'report_zip': f'{base_path}/{compact}-{report_window.display_text}-report.zip',
11753
}
11854

11955
s3_client = config.s3_client
12056

12157
# Create and store combined zip with uncompressed CSVs
12258
zip_buffer = BytesIO()
12359
with ZipFile(zip_buffer, 'w', compression=ZIP_DEFLATED) as zip_file:
124-
zip_file.writestr(f'financial-summary-{date_range}.csv', summary_report.encode('utf-8'))
125-
zip_file.writestr(f'transaction-detail-{date_range}.csv', transaction_detail.encode('utf-8'))
60+
zip_file.writestr(f'financial-summary-{report_window.display_text}.csv', summary_report.encode('utf-8'))
61+
zip_file.writestr(f'transaction-detail-{report_window.display_text}.csv', transaction_detail.encode('utf-8'))
12662
s3_client.put_object(Bucket=bucket_name, Key=paths['report_zip'], Body=zip_buffer.getvalue())
12763

12864
return paths
@@ -132,8 +68,7 @@ def _store_jurisdiction_reports_in_s3(
13268
compact: str,
13369
jurisdiction: str,
13470
reporting_cycle: str,
135-
start_time: datetime,
136-
end_time: datetime,
71+
report_window: ReportWindow,
13772
transaction_detail: str,
13873
bucket_name: str,
13974
) -> dict[str, str]:
@@ -142,31 +77,31 @@ def _store_jurisdiction_reports_in_s3(
14277
:param compact: Compact name
14378
:param jurisdiction: Jurisdiction postal code
14479
:param reporting_cycle: Either 'weekly' or 'monthly'
145-
:param start_time: Report start time
146-
:param end_time: Report end time
80+
:param report_window: The report window
14781
:param transaction_detail: Transaction detail report CSV content
14882
:param bucket_name: S3 bucket name
14983
:return: Dictionary of file types to their S3 paths
15084
"""
151-
date_range = f'{start_time.strftime("%Y-%m-%d")}--{end_time.strftime("%Y-%m-%d")}'
15285
base_path = (
15386
f'compact/{compact}/reports/jurisdiction-transactions/jurisdiction/{jurisdiction}/'
154-
f'reporting-cycle/{reporting_cycle}/{end_time.strftime("%Y/%m/%d")}'
87+
f'reporting-cycle/{reporting_cycle}/{report_window.display_end.strftime("%Y/%m/%d")}'
15588
)
15689

15790
# Define paths for all report files
15891
# Currently, we are only sending the .zip file in the email reporting, but there is potential
15992
# to store .gz files in the future
16093
paths = {
161-
'report_zip': f'{base_path}/{jurisdiction}-{date_range}-report.zip',
94+
'report_zip': f'{base_path}/{jurisdiction}-{report_window.display_text}-report.zip',
16295
}
16396

16497
s3_client = config.s3_client
16598

16699
# Create and store zip with uncompressed CSV
167100
zip_buffer = BytesIO()
168101
with ZipFile(zip_buffer, 'w', compression=ZIP_DEFLATED) as zip_file:
169-
zip_file.writestr(f'{jurisdiction}-transaction-detail-{date_range}.csv', transaction_detail.encode('utf-8'))
102+
zip_file.writestr(
103+
f'{jurisdiction}-transaction-detail-{report_window.display_text}.csv', transaction_detail.encode('utf-8')
104+
)
170105
s3_client.put_object(Bucket=bucket_name, Key=paths['report_zip'], Body=zip_buffer.getvalue())
171106

172107
return paths
@@ -186,8 +121,26 @@ def generate_transaction_reports(event: dict, context: LambdaContext) -> dict:
186121
:return: Success message
187122
"""
188123
compact = event['compact']
189-
reporting_cycle = event['reportingCycle']
190-
logger.info('Generating transaction reports', compact=compact, reporting_cycle=reporting_cycle)
124+
reporting_cycle = ReportCycle(event['reportingCycle'])
125+
126+
# Support 'manual' report date overrides for re-runs
127+
report_start_override = event.get('reportStartOverride')
128+
report_end_override = event.get('reportEndOverride')
129+
if report_start_override and report_end_override:
130+
report_window = ReportWindow(
131+
reporting_cycle,
132+
display_start_date=date.fromisoformat(report_start_override),
133+
display_end_date=date.fromisoformat(report_end_override),
134+
)
135+
else:
136+
report_window = ReportWindow(reporting_cycle)
137+
138+
logger.info(
139+
'Generating transaction reports',
140+
compact=compact,
141+
reporting_cycle=reporting_cycle,
142+
window=report_window.display_text,
143+
)
191144

192145
# this is used to track any errors that occur when generating the reports
193146
# without preventing valid reports from being sent
@@ -216,16 +169,9 @@ def generate_transaction_reports(event: dict, context: LambdaContext) -> dict:
216169
# Get the S3 bucket name
217170
bucket_name = config.transaction_reports_bucket_name
218171

219-
# Get both query and display date ranges
220-
query_start_time, query_end_time = _get_query_date_range(reporting_cycle)
221-
222-
# Convert query times to epochs for DynamoDB
223-
start_epoch = int(query_start_time.timestamp())
224-
end_epoch = int(query_end_time.timestamp())
225-
226172
# Get all transactions for the time period
227173
transactions = transaction_client.get_transactions_in_range(
228-
compact=compact, start_epoch=start_epoch, end_epoch=end_epoch
174+
compact=compact, start_epoch=report_window.start_epoch, end_epoch=report_window.end_epoch
229175
)
230176

231177
# For now, we only report on transactions that have been successfully settled, so we filter to only include
@@ -263,14 +209,11 @@ def generate_transaction_reports(event: dict, context: LambdaContext) -> dict:
263209
compact_transaction_csv = _generate_compact_transaction_report(transactions, providers)
264210
jurisdiction_reports = _generate_jurisdiction_reports(transactions, providers, jurisdiction_configurations)
265211

266-
display_start_time, display_end_time = _get_display_date_range(reporting_cycle)
267-
268212
# Store compact reports in S3 and get paths
269213
compact_paths = _store_compact_reports_in_s3(
270214
compact=compact,
271215
reporting_cycle=reporting_cycle,
272-
start_time=display_start_time,
273-
end_time=display_end_time,
216+
report_window=report_window,
274217
summary_report=compact_summary_csv,
275218
transaction_detail=compact_transaction_csv,
276219
bucket_name=bucket_name,
@@ -282,8 +225,8 @@ def generate_transaction_reports(event: dict, context: LambdaContext) -> dict:
282225
compact=compact,
283226
report_s3_path=compact_paths['report_zip'],
284227
reporting_cycle=reporting_cycle,
285-
start_date=display_start_time,
286-
end_date=display_end_time,
228+
start_date=report_window.display_start,
229+
end_date=report_window.display_end,
287230
)
288231
except CCInternalException as e:
289232
logger.error(
@@ -300,8 +243,7 @@ def generate_transaction_reports(event: dict, context: LambdaContext) -> dict:
300243
compact=compact,
301244
jurisdiction=jurisdiction,
302245
reporting_cycle=reporting_cycle,
303-
start_time=display_start_time,
304-
end_time=display_end_time,
246+
report_window=report_window,
305247
transaction_detail=report_csv,
306248
bucket_name=bucket_name,
307249
)
@@ -312,8 +254,8 @@ def generate_transaction_reports(event: dict, context: LambdaContext) -> dict:
312254
jurisdiction=jurisdiction,
313255
report_s3_path=jurisdiction_paths['report_zip'],
314256
reporting_cycle=reporting_cycle,
315-
start_date=display_start_time,
316-
end_date=display_end_time,
257+
start_date=report_window.display_start,
258+
end_date=report_window.display_end,
317259
)
318260
except CCInternalException as e:
319261
logger.error(

0 commit comments

Comments
 (0)