Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
33dc3c8
feat!: created monthly account reports trigger
edinstance Jul 27, 2025
39c51b9
fix: lint and formatted code
edinstance Jul 27, 2025
0470a0a
refactor: changed file structure
edinstance Jul 27, 2025
a8dc6b5
feat: added a cloudwatch schedule for the monthly trigger
edinstance Jul 27, 2025
a32d453
fix: added missing sqs enviroment variables to proccess transaction f…
edinstance Jul 28, 2025
1672f90
refactor: changed sqs message handling so it is more reusable and upd…
edinstance Jul 29, 2025
19bade3
feat: updated dynamodb data fetching helpers
edinstance Jul 29, 2025
b0878c9
feat!: updated monthly accounts reports trigger to include sqs for co…
edinstance Jul 29, 2025
2677f7b
feat: added longer timeout for trigger function
edinstance Jul 29, 2025
3de7d2f
feat!: created new lambda to process from sqs and moved shared logic …
edinstance Jul 30, 2025
06a77d9
fix: updated powertools version
edinstance Jul 30, 2025
85871d2
feat: updated code based on code rabbit suggestions
edinstance Jul 30, 2025
f41e6c6
fix: updated comparisons
edinstance Jul 30, 2025
1dcad17
fix: formatted code
edinstance Jul 30, 2025
98e22af
feat: added a dlq to the trigger and processing of monthly reports
edinstance Jul 30, 2025
ed80c5e
fix: added fixes from coderabbit
edinstance Jul 30, 2025
42ee22b
refactor: changed report proccessing to have one step function per ac…
edinstance Aug 29, 2025
23bc899
fix: added missing cloudwatch log groups
edinstance Aug 29, 2025
34b5390
feat: created get account transactions lambda and also added it to th…
edinstance Aug 29, 2025
011f5db
feat: created s3 bucket for reports
edinstance Aug 30, 2025
082ee26
feat: created shared s3 client
edinstance Aug 30, 2025
906cc64
fix: added balance to state machine input and scan params
edinstance Aug 30, 2025
7919f59
feat: created lambda to create reports
edinstance Aug 30, 2025
8b95612
feat: updated ses to include no reply and added options to send attac…
edinstance Aug 30, 2025
f9a5cbd
feat: created notify user lambda and added it to state machine
edinstance Aug 30, 2025
e787c61
fix: formatted code
edinstance Aug 30, 2025
7a2293e
feat!: updated state machine to include retries and error handling wi…
edinstance Aug 30, 2025
2cf9d4d
feat: updated unit tests for all new functions
edinstance Sep 1, 2025
e1fa0e9
feat: added option for api request to send a report to the user
edinstance Sep 1, 2025
001cd27
fix: added check to make sure future dates cannot be requested
edinstance Sep 1, 2025
01d04b7
fix: fixed formatting and linting
edinstance Sep 1, 2025
684edef
fix: updated dev requirements
edinstance Sep 1, 2025
47a7381
fix: fixed mismatched environment variable names
edinstance Sep 1, 2025
12d3e38
feat: added input validation
edinstance Sep 1, 2025
2c9d901
fix: added auto escape to jinja
edinstance Sep 1, 2025
24d6c7c
fix: stopped writing file to lambda
edinstance Sep 1, 2025
8bee5de
fix: fixed formatting and tests
edinstance Sep 1, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,11 @@ aws ssm put-parameter \
--name "/banking-app/dev/SesSenderEmail" \
--value "sender@yourdomain.co.uk" \
--type "String"

aws ssm put-parameter \
--name "/banking-app/dev/SesNoReplyEmail" \
--value "no-reply@yourdomain.co.uk" \
--type "String"

aws ssm put-parameter \
--name "/banking-app/dev/SesReplyEmail" \
Expand Down
4 changes: 3 additions & 1 deletion dev-requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ black==25.1.0
ruff==0.11.9

# Running
aws_lambda_powertools==3.12.0
aws_lambda_powertools==3.17.0
boto3==1.38.13
xhtml2pdf==0.2.17
Jinja2==3.1.6

# Tests
pytest==8.3.5
Expand Down
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import os
import json

from aws_lambda_powertools import Logger
from aws_lambda_powertools.event_handler import (
APIGatewayRestResolver,
CORSConfig,
)
from aws_lambda_powertools.event_handler.exceptions import (
InternalServerError,
BadRequestError,
)
from aws_lambda_powertools.utilities.typing import LambdaContext

from dynamodb import get_dynamodb_resource
from .exceptions import ValidationError
Comment thread
edinstance marked this conversation as resolved.
from .transaction_helpers import query_transactions

TRANSACTIONS_TABLE_NAME = os.environ.get("TRANSACTIONS_TABLE_NAME")
ENVIRONMENT_NAME = os.environ.get("ENVIRONMENT_NAME", "dev")
POWERTOOLS_LOG_LEVEL = os.environ.get("POWERTOOLS_LOG_LEVEL", "INFO").upper()
DYNAMODB_ENDPOINT = os.environ.get("DYNAMODB_ENDPOINT")
AWS_REGION = os.environ.get("AWS_REGION", "eu-west-2")

logger = Logger(service="GetAccountTransactions", level=POWERTOOLS_LOG_LEVEL)

app = APIGatewayRestResolver(
cors=CORSConfig(allow_headers=["Content-Type", "Authorization"])
)

dynamodb = get_dynamodb_resource(DYNAMODB_ENDPOINT, AWS_REGION, logger)
if TRANSACTIONS_TABLE_NAME:
table = dynamodb.Table(TRANSACTIONS_TABLE_NAME)
logger.debug(f"Initialized DynamoDB table: {TRANSACTIONS_TABLE_NAME}")
else:
logger.critical("FATAL: TRANSACTIONS_TABLE_NAME environment variable not set!")
table = None


@app.get("/accounts/<account_id>/transactions")
def get_account_transactions(account_id: str):
try:
period = app.current_event.get_query_string_value("period", default_value=None)
start = app.current_event.get_query_string_value("start", default_value=None)
end = app.current_event.get_query_string_value("end", default_value=None)

result = query_transactions(
table=table,
account_id=account_id,
logger=logger,
period=period,
start=start,
end=end,
)
return result

except ValidationError as ve:
logger.warning(f"Validation error: {ve}")
raise BadRequestError(str(ve))
except Exception as e:
logger.error(f"Error fetching transactions: {e}", exc_info=True)
raise InternalServerError("Internal server error")


@logger.inject_lambda_context
def lambda_handler(event, context: LambdaContext):
logger.append_keys(request_id=context.aws_request_id)
logger.info(f"Processing request in {ENVIRONMENT_NAME}")

if not table:
logger.error("DynamoDB table resource is not initialized")
raise InternalServerError("Server configuration error")

# Detect Step Functions or API Gateway
if "httpMethod" in event or "requestContext" in event:
return app.resolve(event, context)
else:
account_id = event.get("accountId")
if not account_id:
return {
"statusCode": 400,
"body": json.dumps({"error": "Missing accountId"}),
}

try:
result = query_transactions(
table=table, account_id=account_id, logger=logger
)

response = {
**event,
"transactions": result.get("transactions", result),
}

return response

except Exception as e:
logger.error(f"Error fetching transactions: {e}", exc_info=True)
return {"statusCode": 500, "body": json.dumps({"error": str(e)})}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
from datetime import datetime, timedelta, timezone
import calendar

from .exceptions import ValidationError


def get_date_range(period: str = None, start: str = None, end: str = None):
# --- Validation rules ---
if period and (start or end):
raise ValidationError("Cannot combine 'period' with 'start'/'end'")

if (start and not end) or (end and not start):
raise ValidationError("Both 'start' and 'end' must be provided together")

# --- Custom range ---
if start and end:
try:
start_dt = datetime.strptime(start, "%Y-%m-%d").replace(tzinfo=timezone.utc)
end_dt = datetime.strptime(end, "%Y-%m-%d").replace(
hour=23, minute=59, second=59, tzinfo=timezone.utc
)
except ValueError:
raise ValidationError("Invalid date format, must be YYYY-MM-DD")

if end_dt < start_dt:
raise ValidationError("'end' date must be after 'start' date")

statement_period = (
f"{start_dt.strftime('%Y-%m-%d')}_to_{end_dt.strftime('%Y-%m-%d')}"
)

# --- Period (month) ---
elif period:
try:
year, month = map(int, period.split("-"))
start_dt = datetime(year, month, 1, tzinfo=timezone.utc)
last_day_num = calendar.monthrange(year, month)[1]
end_dt = datetime(
year, month, last_day_num, 23, 59, 59, tzinfo=timezone.utc
)
except Exception:
raise ValidationError("Invalid period format, must be YYYY-MM")

statement_period = start_dt.strftime("%Y-%m")

# --- Default: last month ---
else:
today = datetime.now(timezone.utc)
first_day_this_month = datetime(today.year, today.month, 1, tzinfo=timezone.utc)
last_day_last_month = first_day_this_month - timedelta(days=1)
start_dt = datetime(
last_day_last_month.year, last_day_last_month.month, 1, tzinfo=timezone.utc
)
end_dt = datetime(
last_day_last_month.year,
last_day_last_month.month,
last_day_last_month.day,
23,
59,
59,
tzinfo=timezone.utc,
)
statement_period = start_dt.strftime("%Y-%m")

start_iso = start_dt.strftime("%Y-%m-%dT%H:%M:%SZ")
end_iso = end_dt.strftime("%Y-%m-%dT%H:%M:%SZ")

return statement_period, start_iso, end_iso
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
class ValidationError(Exception):
pass
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from aws_lambda_powertools import Logger
from boto3.dynamodb.conditions import Key

from . import date_helpers


def query_transactions(
table,
account_id: str,
logger: Logger,
period: str = None,
start: str = None,
end: str = None,
descending=False,
):
Comment thread
edinstance marked this conversation as resolved.
statement_period, start_iso, end_iso = date_helpers.get_date_range(
period, start, end
)

logger.info(
f"Querying transactions for account {account_id} "
f"from {start_iso} to {end_iso} (period {statement_period})"
)

response = table.query(
IndexName="AccountDateIndex",
KeyConditionExpression=Key("accountId").eq(account_id)
& Key("createdAt").between(start_iso, end_iso),
ScanIndexForward=not descending,
)

return {
"statementPeriod": statement_period,
"transactions": response.get("Items", []),
}
2 changes: 2 additions & 0 deletions functions/accounts/get_account_transactions/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
aws_lambda_powertools==3.17.0
boto3==1.38.13
2 changes: 1 addition & 1 deletion functions/accounts/get_accounts/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
aws_lambda_powertools==3.12.0
aws_lambda_powertools==3.17.0
boto3==1.38.13
2 changes: 1 addition & 1 deletion functions/auth/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
aws_lambda_powertools==3.12.0
aws_lambda_powertools==3.17.0
boto3==1.38.13
2 changes: 1 addition & 1 deletion functions/cognito/post_sign_up/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
aws_lambda_powertools==3.12.0
aws_lambda_powertools==3.17.0
boto3==1.38.13
Empty file.
Empty file.
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import os

from aws_lambda_powertools import Logger
from aws_lambda_powertools.utilities.typing import LambdaContext
from botocore.exceptions import ClientError

from .exceptions import ReportGenerationError, ReportTemplateError, ReportUploadError
from s3 import get_s3_client
from .generate_pdf import generate_transactions_pdf

REPORTS_BUCKET = os.environ.get("REPORTS_BUCKET")
POWERTOOLS_LOG_LEVEL = os.environ.get("POWERTOOLS_LOG_LEVEL")
AWS_REGION = os.environ.get("AWS_REGION")

logger = Logger(service="CreateAccountsReport", level=POWERTOOLS_LOG_LEVEL)

s3 = get_s3_client(AWS_REGION, logger)


def lambda_handler(event, _context: LambdaContext):
logger.info(f"Received event: {event}")

try:
required = [
"accountId",
"userId",
"statementPeriod",
"transactions",
"accountBalance",
]
missing = [k for k in required if k not in event]

if missing:
logger.error(f"Missing required fields: {missing}")
raise ReportGenerationError(f"Invalid event: missing {missing}")

# Generate PDF
pdf_bytes = generate_transactions_pdf(event=event, logger=logger)

logger.info("PDF generated successfully")

# Store in S3
s3_key = f"{event['accountId']}/{event['statementPeriod']}.pdf"
try:
s3.put_object(
Bucket=REPORTS_BUCKET,
Key=s3_key,
Body=pdf_bytes,
ContentType="application/pdf",
)
Comment thread
edinstance marked this conversation as resolved.
except ClientError as e:
logger.exception("Failed to upload report to S3")
raise ReportUploadError(f"S3 upload failed: {str(e)}") from e

logger.info("Generating PDF uploaded to S3")

try:
presigned_url = s3.generate_presigned_url(
"get_object",
Params={"Bucket": REPORTS_BUCKET, "Key": s3_key},
ExpiresIn=3600,
)
except ClientError as e:
logger.exception("Failed to generate presigned URL")
raise ReportUploadError(f"Presigned URL generation failed: {str(e)}") from e

logger.info("Presigned URL generated successfully")

return {
"reportUrl": presigned_url,
"accountId": event["accountId"],
"userId": event["userId"],
"statementPeriod": event["statementPeriod"],
}

except (ReportGenerationError, ReportTemplateError, ReportUploadError):
logger.exception("Report generation failed")
raise
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
class ReportGenerationError(Exception):
"""Raised when PDF generation fails."""


class ReportTemplateError(Exception):
"""Raised when the Jinja2 template is missing or invalid."""


class ReportUploadError(Exception):
"""Raised when uploading to S3 fails."""
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import io
import os
from datetime import datetime, timezone

from aws_lambda_powertools import Logger
from jinja2 import Environment, FileSystemLoader, TemplateNotFound, select_autoescape
from xhtml2pdf import pisa

from .exceptions import ReportGenerationError, ReportTemplateError


def generate_transactions_pdf(event: dict, logger: Logger) -> bytes:
current_dir = os.path.dirname(os.path.abspath(__file__))
env = Environment(
loader=FileSystemLoader(current_dir),
autoescape=select_autoescape(["html", "xml"]),
)

try:
template = env.get_template("template.html")
except TemplateNotFound as e:
logger.error("Template 'template.html' not found")
raise ReportTemplateError("Missing template: template.html") from e

html_out = template.render(
accountId=event["accountId"],
statementPeriod=event["statementPeriod"],
transactions=event["transactions"],
accountBalance=event["accountBalance"],
generationDate=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC"),
)

pdf_buffer = io.BytesIO()
pisa_status = pisa.CreatePDF(io.StringIO(html_out), dest=pdf_buffer)

if pisa_status.err:
logger.error("xhtml2pdf failed to generate PDF")
raise ReportGenerationError("Error generating PDF")

pdf_buffer.seek(0)
pdf_bytes = pdf_buffer.getvalue()

logger.debug("PDF generated (%d bytes).", len(pdf_bytes))
return pdf_bytes
Loading