From 5eae098098d0b9e42772aa313c1979817ffe7ffd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Rafael=20Siqueira?= Date: Fri, 25 Jul 2025 11:20:01 -0300 Subject: [PATCH] wip reporting --- README.md | 20 ++- order-events/app/events/append_org_id.py | 2 + order-events/app/events/reporting/__init__.py | 0 order-events/app/events/reporting/add_item.py | 125 ++++++++++++++++++ order-events/app/events/set_as_expired.py | 64 --------- .../app/events/stopgap/schedule_expired.py | 38 ------ order-events/app/utils.py | 35 +++++ order-events/template.yaml | 71 ++++------ order-events/tests/conftest.py | 1 - .../tests/events/reporting/__init__.py | 0 .../tests/events/reporting/test_add_item.py | 30 +++++ .../events/stopgap/test_schedule_expired.py | 34 ----- .../tests/events/test_set_as_expired.py | 25 ---- order-events/tests/seeds.jsonl | 6 +- order-events/tests/test_utils.py | 31 +++++ 15 files changed, 269 insertions(+), 213 deletions(-) create mode 100644 order-events/app/events/reporting/__init__.py create mode 100644 order-events/app/events/reporting/add_item.py delete mode 100644 order-events/app/events/set_as_expired.py delete mode 100644 order-events/app/events/stopgap/schedule_expired.py create mode 100644 order-events/app/utils.py create mode 100644 order-events/tests/events/reporting/__init__.py create mode 100644 order-events/tests/events/reporting/test_add_item.py delete mode 100644 order-events/tests/events/stopgap/test_schedule_expired.py delete mode 100644 order-events/tests/events/test_set_as_expired.py create mode 100644 order-events/tests/test_utils.py diff --git a/README.md b/README.md index d3053b4..5c96a44 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ O gestor responsável pela ação também é relacionado à compra, com base no Quando o responsável é uma pessoa física (CPF). ```json -{"id": "20", "sk": "0", "name": "Sérgio", "email": "sergio@somosbeta.com.br", "cpf": "07879819908", "org_id": "123"} +{"id": "20", "sk": "0", "name": "Sérgio", "email": "sergio@somosbeta.com.br", "cpf": "07879819908", "user_id": "321"} {"id": "20", "sk": "SLOT", "status": "PENDING", "mode": "STANDALONE"} {"id": "20", "sk": "SLOT#ENROLLMENT#1123", "status": "SUCCESS"} ``` @@ -60,10 +60,10 @@ Quando o responsável é uma pessoa física (CPF). Quando uma matrícula é criada, também é agendados emails/eventos. -- `REMINDER_NO_ACCESS_3_DAYS` se o usuário não acessar o curso 3 dias após a criação. -- `NO_ACTIVITY_7_DAYS` 7 dias após a última atividade do usuário no curso. -- `ACCESS_PERIOD_REMINDER_30_DAYS` 30 dias antes do perído de acesso ao curso terminar. -- `CERT_EXPIRATION_REMINDER_30_DAYS` se houver certificado, avisa 30 dias antes do certificado expirar. +- `REMINDER_NO_ACCESS_AFTER_3_DAYS` se o usuário não acessar o curso 3 dias após a criação. +- `REMINDER_NO_ACTIVITY_AFTER_7_DAYS` 7 dias após a última atividade do usuário no curso. +- `REMINDER_ACCESS_PERIOD_BEFORE_15_DAYS` 30 dias antes do perído de acesso ao curso terminar. +- `REMINDER_CERT_EXPIRATION_BEFORE_30_DAYS` se houver certificado, avisa 30 dias antes do certificado expirar. - `COURSE_ARCHIVED` após o certificado expirar, a matrícula será marcada como **arquivada (ARCHIVED)**. - `COURSE_EXPIRED` se não houver certificado e o período de acesso for atingido, a matrícula será marcada com **expirada (EXPIRED)**. @@ -113,3 +113,13 @@ Se houver `METADATA#SOURCE_SLOT`, deve ser devolvido. {"id": "CUSTOM_PRICING#ORG#96e523b9-a404-4860-a737-edf412c3da52", "sk": "COURSE#439e9a43-ab92-469a-a849-b6e824370f80", "unit_price": 149, "created_at": "2025-04-06T11:07:32.762178-03:00"} {"id": "CUSTOM_PRICING#ORG#96e523b9-a404-4860-a737-edf412c3da52", "sk": "COURSE#f10c3283-7722-41c6-ba5d-222f9f4f48af", "unit_price": 149, "created_at": "2025-04-06T11:07:32.762178-03:00"} ``` + +# Webhooks + +```json +{"id": "USER#*#EVENT#INSERT_ADMIN", "sk": "https://n8n.eduseg.com.br/webhook/2bc4a7cd-94d0-4214-b330-4261da87dc95"} +{"id": "USER#*#EVENT#OVERDUE_PAYMENT", "sk": "https://n8n.eduseg.com.br/webhook/2bc4a7cd-94d0-4214-b330-4261da87dc95"} +{"id": "REQUEST#URL_HASH#de2815bdfc4238d446eb0f686ff51c0d", "sk": "2025-07-24T21:45:29.642855", "request_payload": {"id": "123", "name": "Sérgio Siqueira"}} +``` + +`URL_HASH` é o hash MD5 da string `USER#{user_id}#EVENT#{event_name}#URL#{url}'` diff --git a/order-events/app/events/append_org_id.py b/order-events/app/events/append_org_id.py index c8d5626..e10ca69 100644 --- a/order-events/app/events/append_org_id.py +++ b/order-events/app/events/append_org_id.py @@ -49,6 +49,8 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool: transact.update( key=KeyPair(new_image['id'], '0'), update_expr='SET tenant_id = :org_id, updated_at = :updated_at', + # Post-migration: uncomment the following line + # update_expr='SET org_id = :org_id, updated_at = :updated_at', expr_attr_values={ ':org_id': data['org_id'], ':updated_at': now_, diff --git a/order-events/app/events/reporting/__init__.py b/order-events/app/events/reporting/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/order-events/app/events/reporting/add_item.py b/order-events/app/events/reporting/add_item.py new file mode 100644 index 0000000..bfd3d23 --- /dev/null +++ b/order-events/app/events/reporting/add_item.py @@ -0,0 +1,125 @@ +from datetime import datetime, time, timedelta + +from aws_lambda_powertools import Logger +from aws_lambda_powertools.utilities.data_classes import ( + EventBridgeEvent, + event_source, +) +from aws_lambda_powertools.utilities.typing import LambdaContext +from layercake.dateutils import now, ttl +from layercake.dynamodb import ( + DynamoDBPersistenceLayer, + KeyPair, + SortKey, + TransactKey, +) +from layercake.funcs import pick + +from boto3clients import dynamodb_client +from config import COURSE_TABLE, ENROLLMENT_TABLE, ORDER_TABLE +from utils import get_billing_period + +logger = Logger(__name__) +order_layer = DynamoDBPersistenceLayer(ORDER_TABLE, dynamodb_client) +enrollment_layer = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client) +course_layer = DynamoDBPersistenceLayer(COURSE_TABLE, dynamodb_client) + + +@event_source(data_class=EventBridgeEvent) +@logger.inject_lambda_context +def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool: + new_image = event.detail['new_image'] + now_ = now() + enrollment_id = new_image['id'] + org_id = new_image['org_id'] + data = enrollment_layer.collection.get_items( + TransactKey(enrollment_id) + SortKey('0') + SortKey('author') + ) + + if not data: + logger.debug('Enrollment not found') + return False + + start_date, end_date = get_billing_period(new_image['billing_day']) + pk = 'BILLING#ORG#{org_id}'.format(org_id=org_id) + sk = 'START#{start}#END#{end}'.format( + start=start_date.isoformat(), + end=end_date.isoformat(), + ) + + try: + with order_layer.transact_writer() as transact: + transact.put( + item={ + 'id': pk, + 'sk': sk, + 'status': 'PENDING', + 'created_at': now_, + }, + cond_expr='attribute_not_exists(sk)', + exc_cls=ExistingBillingConflictError, + ) + transact.put( + item={ + 'id': pk, + 'sk': f'{sk}#SCHEDULE#AUTO_CLOSE', + 'ttl': ttl( + start_dt=datetime.combine(end_date, time()) + timedelta(days=1) + ), + 'created_at': now_, + } + ) + except ExistingBillingConflictError: + pass + + try: + with order_layer.transact_writer() as transact: + author = data['author'] + course_id = data['course']['id'] + course = course_layer.collection.get_items( + KeyPair( + pk=course_id, + sk=SortKey('0', path_spec='metadata__unit_price'), + rename_key='unit_price', + ) + + KeyPair( + pk=f'CUSTOM_PRICING#ORG#{org_id}', + sk=SortKey(f'COURSE#{course_id}', path_spec='unit_price'), + rename_key='unit_price', + ), + flatten_top=False, + ) + + transact.condition( + key=KeyPair(pk, sk), + cond_expr='attribute_exists(sk)', + exc_cls=BillingNotFoundError, + ) + transact.put( + item={ + 'id': pk, + 'sk': f'{sk}#ENROLLMENT#{enrollment_id}', + 'user': pick(('id', 'name'), data['user']), + 'course': pick(('id', 'name'), data['course']), + 'unit_price': course['unit_price'], + 'author': { + 'id': author['user_id'], + 'name': author['name'], + }, + # Post-migration: uncomment the following line + # 'enrolled_at': data['created_at'], + 'enrolled_at': data['create_date'], + 'created_at': now_, + }, + cond_expr='attribute_not_exists(sk)', + ) + except Exception: + return False + else: + return True + + +class ExistingBillingConflictError(Exception): ... + + +class BillingNotFoundError(Exception): ... diff --git a/order-events/app/events/set_as_expired.py b/order-events/app/events/set_as_expired.py deleted file mode 100644 index 59aaf43..0000000 --- a/order-events/app/events/set_as_expired.py +++ /dev/null @@ -1,64 +0,0 @@ -from aws_lambda_powertools import Logger -from aws_lambda_powertools.utilities.data_classes import ( - EventBridgeEvent, - event_source, -) -from aws_lambda_powertools.utilities.typing import LambdaContext -from layercake.dateutils import now -from layercake.dynamodb import ( - DynamoDBPersistenceLayer, - KeyPair, -) - -from boto3clients import dynamodb_client -from config import ORDER_TABLE - -logger = Logger(__name__) -order_layer = DynamoDBPersistenceLayer(ORDER_TABLE, dynamodb_client) - - -@event_source(data_class=EventBridgeEvent) -@logger.inject_lambda_context -def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool: - """Set the order status to `EXPIRED` 24 hours after it becomes overdue.""" - new_image = event.detail['new_image'] - now_ = now() - sk = new_image['sk'] - - try: - order_layer.update_item( - key=KeyPair(new_image['id'], '0'), - update_expr='SET #status = :expired, updated_at = :updated_at', - cond_expr='#status IN (:pending) and payment_method = :payment_method', - expr_attr_names={ - '#status': 'status', - }, - expr_attr_values={ - ':expired': 'EXPIRED', - ':pending': 'PENDING', - ':payment_method': 'MANUAL', - ':updated_at': now_, - }, - ) - except Exception: - logger.info('Failed to update status to EXPIRED', order_id=new_image['id']) - order_layer.put_item( - item={ - 'id': new_image['id'], - 'sk': f'{sk}#failed', - 'created_at': now_, - } - ) - - return False - else: - logger.info('Status set to EXPIRED', order_id=new_image['id']) - order_layer.put_item( - item={ - 'id': new_image['id'], - 'sk': f'{sk}#executed', - 'created_at': now_, - } - ) - - return True diff --git a/order-events/app/events/stopgap/schedule_expired.py b/order-events/app/events/stopgap/schedule_expired.py deleted file mode 100644 index 7676384..0000000 --- a/order-events/app/events/stopgap/schedule_expired.py +++ /dev/null @@ -1,38 +0,0 @@ -from datetime import timedelta - -from aws_lambda_powertools import Logger -from aws_lambda_powertools.utilities.data_classes import ( - EventBridgeEvent, - event_source, -) -from aws_lambda_powertools.utilities.typing import LambdaContext -from layercake.dateutils import fromisoformat, now, ttl -from layercake.dynamodb import ( - DynamoDBPersistenceLayer, -) - -from boto3clients import dynamodb_client -from config import ORDER_TABLE - -logger = Logger(__name__) -order_layer = DynamoDBPersistenceLayer(ORDER_TABLE, dynamodb_client) - - -@event_source(data_class=EventBridgeEvent) -@logger.inject_lambda_context -def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool: - new_image = event.detail['new_image'] - due_date = fromisoformat(new_image['due_date']) - now_ = now() - - if not due_date: - return False - - return order_layer.put_item( - item={ - 'id': new_image['id'], - 'sk': 'schedules#set_as_expired', - 'ttl': ttl(start_dt=due_date + timedelta(hours=24)), - 'created_at': now_, - } - ) diff --git a/order-events/app/utils.py b/order-events/app/utils.py new file mode 100644 index 0000000..de2bb8d --- /dev/null +++ b/order-events/app/utils.py @@ -0,0 +1,35 @@ +import calendar +from datetime import date + +from layercake.dateutils import now + + +def get_billing_period( + billing_day: int, + year: int | None = None, + month: int | None = None, +) -> tuple[date, date]: + today = now() + year_ = year or today.year + month_ = month or today.month + + _, last_day = calendar.monthrange(year_, month_) + start_day = min(billing_day, last_day) + start_date = date(year_, month_, start_day) + + if month_ == 12: + next_month = 1 + next_year = year_ + 1 + else: + next_month = month_ + 1 + next_year = year_ + + _, last_day_next_month = calendar.monthrange(next_year, next_month) + end_day = min(billing_day, last_day_next_month) - 1 + + if end_day == 0: + end_date = date(year_, month_, last_day) + else: + end_date = date(next_year, next_month, end_day) + + return start_date, end_date diff --git a/order-events/template.yaml b/order-events/template.yaml index c00449a..ed90ad8 100644 --- a/order-events/template.yaml +++ b/order-events/template.yaml @@ -42,6 +42,30 @@ Resources: Properties: RetentionInDays: 90 + EventReportingAddItemFunction: + Type: AWS::Serverless::Function + Properties: + Handler: events.reporting.add_item.lambda_handler + LoggingConfig: + LogGroup: !Ref EventLog + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref OrderTable + - DynamoDBReadPolicy: + TableName: !Ref EnrollmentTable + - DynamoDBReadPolicy: + TableName: !Ref CourseTable + Events: + Event: + Type: EventBridgeRule + Properties: + Pattern: + resources: [!Ref EnrollmentTable] + detail-type: [INSERT] + detail: + new_image: + sk: ["METADATA#BILLING_TERMS"] + EventAppendOrgIdFunction: Type: AWS::Serverless::Function Properties: @@ -120,7 +144,7 @@ Resources: - exists: true status: [CANCELED, EXPIRED] - EventSetAsPaidFunction: + EventStopgapSetAsPaidFunction: Type: AWS::Serverless::Function Properties: Handler: events.stopgap.set_as_paid.lambda_handler @@ -145,50 +169,7 @@ Resources: status: [CREATING, PENDING] payment_method: [MANUAL] - EventSetAsExpiredFunction: - Type: AWS::Serverless::Function - Properties: - Handler: events.set_as_expired.lambda_handler - LoggingConfig: - LogGroup: !Ref EventLog - Policies: - - DynamoDBWritePolicy: - TableName: !Ref OrderTable - Events: - Event: - Type: EventBridgeRule - Properties: - Pattern: - resources: [!Ref OrderTable] - detail-type: [EXPIRE] - detail: - new_image: - sk: [schedules#set_as_expired] - - EventScheduleExpiredFunction: - Type: AWS::Serverless::Function - Properties: - Handler: events.stopgap.schedule_expired.lambda_handler - LoggingConfig: - LogGroup: !Ref EventLog - Policies: - - DynamoDBWritePolicy: - TableName: !Ref OrderTable - Events: - Event: - Type: EventBridgeRule - Properties: - Pattern: - resources: [!Ref OrderTable] - detail-type: [INSERT] - detail: - new_image: - sk: ["0"] - payment_method: [MANUAL] - due_date: - - exists: true - - EventRemoveSlotsFunction: + EventStopgapRemoveSlotsFunction: Type: AWS::Serverless::Function Properties: Handler: events.stopgap.remove_slots.lambda_handler diff --git a/order-events/tests/conftest.py b/order-events/tests/conftest.py index 91e68bc..50a2a2a 100644 --- a/order-events/tests/conftest.py +++ b/order-events/tests/conftest.py @@ -19,7 +19,6 @@ def pytest_configure(): os.environ['COURSE_TABLE'] = PYTEST_TABLE_NAME os.environ['ENROLLMENT_TABLE'] = PYTEST_TABLE_NAME os.environ['ORDER_TABLE'] = PYTEST_TABLE_NAME - os.environ['COURSE_TABLE'] = PYTEST_TABLE_NAME os.environ['LOG_LEVEL'] = 'DEBUG' diff --git a/order-events/tests/events/reporting/__init__.py b/order-events/tests/events/reporting/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/order-events/tests/events/reporting/test_add_item.py b/order-events/tests/events/reporting/test_add_item.py new file mode 100644 index 0000000..9644500 --- /dev/null +++ b/order-events/tests/events/reporting/test_add_item.py @@ -0,0 +1,30 @@ +from aws_lambda_powertools.utilities.typing import LambdaContext +from layercake.dynamodb import DynamoDBPersistenceLayer, PartitionKey + +import events.reporting.add_item as app + + +def test_add_item( + dynamodb_seeds, + dynamodb_persistence_layer: DynamoDBPersistenceLayer, + lambda_context: LambdaContext, +): + event = { + 'detail': { + 'new_image': { + 'id': '945e8672-1d72-45c6-b76c-ac06aa8b52ab', + 'sk': 'METADATA#BILLING_TERMS', + 'billing_day': 5, + 'created_at': '2025-07-23T18:09:22.785678-03:00', + 'org_id': 'edp8njvgQuzNkLx2ySNfAD', + } + } + } + + assert app.lambda_handler(event, lambda_context) # type: ignore + + r = dynamodb_persistence_layer.collection.query( + PartitionKey('BILLING#ORG#edp8njvgQuzNkLx2ySNfAD') + ) + + print(r) diff --git a/order-events/tests/events/stopgap/test_schedule_expired.py b/order-events/tests/events/stopgap/test_schedule_expired.py deleted file mode 100644 index 2e90ad2..0000000 --- a/order-events/tests/events/stopgap/test_schedule_expired.py +++ /dev/null @@ -1,34 +0,0 @@ -from decimal import Decimal - -from aws_lambda_powertools.utilities.typing import LambdaContext -from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair - -import events.stopgap.schedule_expired as app - - -def test_schedule_expired( - dynamodb_client, - dynamodb_persistence_layer: DynamoDBPersistenceLayer, - lambda_context: LambdaContext, -): - event = { - 'detail': { - 'new_image': { - 'id': '123', - 'sk': '0', - 'due_date': '2025-07-04T08:34:45.780000-03:00', - } - } - } - - assert app.lambda_handler(event, lambda_context) # type: ignore - - expected = { - 'sk': 'schedules#set_as_expired', - 'ttl': Decimal('1751715285'), - 'id': '123', - } - r = dynamodb_persistence_layer.get_item( - key=KeyPair('123', 'schedules#set_as_expired') - ) - assert r['ttl'] == expected['ttl'] diff --git a/order-events/tests/events/test_set_as_expired.py b/order-events/tests/events/test_set_as_expired.py deleted file mode 100644 index 634f681..0000000 --- a/order-events/tests/events/test_set_as_expired.py +++ /dev/null @@ -1,25 +0,0 @@ -import app.events.set_as_expired as app - - -def test_set_as_expired(dynamodb_seeds, lambda_context): - event = { - 'detail': { - 'new_image': { - 'id': '9omWNKymwU5U4aeun6mWzZ', - 'sk': 'schedules#set_as_expired', - }, - } - } - assert app.lambda_handler(event, lambda_context) - - -def test_set_as_expired_failed(dynamodb_seeds, lambda_context): - event = { - 'detail': { - 'new_image': { - 'id': '18f934d8-035a-4ebc-9f8b-6c84782b8c73', - 'sk': 'schedules#set_as_expired', - }, - } - } - assert not app.lambda_handler(event, lambda_context) diff --git a/order-events/tests/seeds.jsonl b/order-events/tests/seeds.jsonl index 597ad70..5d7a647 100644 --- a/order-events/tests/seeds.jsonl +++ b/order-events/tests/seeds.jsonl @@ -10,4 +10,8 @@ {"id": {"S": "vacancies#cJtK9SsnJhKPyxESe7g3DG"}, "sk": {"S": "9omWNKymwU5U4aeun6mWzZ#3"}} {"id": {"S": "18f934d8-035a-4ebc-9f8b-6c84782b8c73"}, "sk": {"S": "0"}, "payment_method": {"S": "PAID"}} {"id": {"S": "6a60d026-d383-4707-b093-b6eddea1a24e"}, "sk": {"S": "items"},"items": {"L": [{"M": {"id": {"S": "a810dd22-56c0-4d9b-8cd2-7e2ee9c45839"}, "name": {"S": "pytest"},"quantity": {"N": "1"},"unit_price": {"N": "109"}}}]}} -{"id": {"S": "a810dd22-56c0-4d9b-8cd2-7e2ee9c45839"}, "sk": {"S": "metadata#betaeducacao"},"course_id": {"S": "dc1a0428-47bf-4db1-a5da-24be49c9fda6"},"create_date": {"S": "2025-06-05T12:13:54.371416+00:00"}} \ No newline at end of file +{"id": {"S": "a810dd22-56c0-4d9b-8cd2-7e2ee9c45839"}, "sk": {"S": "metadata#betaeducacao"},"course_id": {"S": "dc1a0428-47bf-4db1-a5da-24be49c9fda6"},"create_date": {"S": "2025-06-05T12:13:54.371416+00:00"}} +{"id": {"S": "945e8672-1d72-45c6-b76c-ac06aa8b52ab"}, "sk": {"S": "0"}, "course": {"M": {"id": {"S": "123"}, "name": {"S": "pytest"}}}, "user": {"M": {"id": {"S": "5OxmMjL-ujoR5IMGegQz"}, "name": {"S": "Sérgio R Siqueira"}}}, "create_date": {"S": "2025-06-05T12:13:54.371416+00:00"}} +{"id": {"S": "945e8672-1d72-45c6-b76c-ac06aa8b52ab"}, "sk": {"S": "author"}, "name": {"S": "Carolina Brand"}, "user_id": {"S": "SMEXYk5MQkKCzknJpxqr8n"}} +{"id": {"S": "CUSTOM_PRICING#ORG#edp8njvgQuzNkLx2ySNfAD"},"sk": {"S": "COURSE#123"},"created_at": {"S": "2025-07-24T16:10:09.304073-03:00"},"unit_price": {"N": "79.2"}} +{"id": {"S": "123"},"sk": {"S": "0"},"access_period": {"N": "360"},"cert": {"M": {"exp_interval": {"N": "360"}}},"created_at": {"S": "2024-12-30T00:33:33.088916-03:00"},"metadata__konviva_class_id": {"N": "194"},"metadata__unit_price": {"N": "99"},"name": {"S": "Direção Defensiva (08 horas)"},"tenant_id": {"S": "*"},"updated_at": {"S": "2025-07-24T00:00:24.639003-03:00"}} \ No newline at end of file diff --git a/order-events/tests/test_utils.py b/order-events/tests/test_utils.py new file mode 100644 index 0000000..6d4c416 --- /dev/null +++ b/order-events/tests/test_utils.py @@ -0,0 +1,31 @@ +from datetime import date + +from utils import get_billing_period + + +def test_get_billing_period(): + assert get_billing_period(billing_day=29, year=2025, month=2) == ( + date(2025, 2, 28), + date(2025, 3, 28), + ) + + assert get_billing_period(billing_day=31, year=2025, month=2) == ( + date(2025, 2, 28), + date(2025, 3, 30), + ) + + # Leap year + assert get_billing_period(billing_day=29, year=2028, month=2) == ( + date(2028, 2, 29), + date(2028, 3, 28), + ) + + assert get_billing_period(billing_day=28, year=2025, month=2) == ( + date(2025, 2, 28), + date(2025, 3, 27), + ) + + assert get_billing_period(billing_day=5, year=2025, month=12) == ( + date(2025, 12, 5), + date(2026, 1, 4), + )