From c70a74b94aae8ab8158767f9fdd584dfdf538618 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Rafael=20Siqueira?= Date: Thu, 21 Aug 2025 13:32:44 -0300 Subject: [PATCH] add billing period --- enrollments-events/app/enrollment.py | 40 ++++----- .../reminder_access_period_before_15_days.py | 12 --- .../emails/reminder_no_access_after_3_days.py | 14 +-- .../reminder_no_activity_after_7_days.py | 85 ++++++++++++++++++- enrollments-events/app/events/enroll.py | 2 +- .../stopgap/set_subscription_covered.py | 8 +- enrollments-events/template.yaml | 76 ++++++++++++++--- enrollments-events/uv.lock | 46 ++++++++-- .../app/events/billing/append_enrollment.py | 63 ++++++++------ .../events/billing/send_email_on_closing.py | 2 +- .../app/events/remove_slots_if_canceled.py | 6 +- order-events/template.yaml | 16 ++-- .../events/billing/test_append_enrollment.py | 9 +- order-events/tests/seeds.jsonl | 1 + 14 files changed, 282 insertions(+), 98 deletions(-) delete mode 100644 enrollments-events/app/events/emails/reminder_access_period_before_15_days.py diff --git a/enrollments-events/app/enrollment.py b/enrollments-events/app/enrollment.py index f162f4b..513b07f 100644 --- a/enrollments-events/app/enrollment.py +++ b/enrollments-events/app/enrollment.py @@ -40,29 +40,41 @@ class LifecycleEvents(str, Enum): """Lifecycle events related to scheduling actions.""" # Reminder if the user does not access within 3 days - # REMINDER_NO_ACCESS_3_DAYS = 'SCHEDULES#REMINDER_NO_ACCESS_3_DAYS' + # REMINDER_NO_ACCESS_AFTER_3_DAYS = 'SCHEDULE#REMINDER_NO_ACCESS_AFTER_3_DAYS' DOES_NOT_ACCESS = 'schedules#does_not_access' # When there is no activity 7 days after the first access - # NO_ACTIVITY_7_DAYS = 'SCHEDULES#NO_ACTIVITY_7_DAYS' + # REMINDER_NO_ACTIVITY_AFTER_7_DAYS = 'SCHEDULE#REMINDER_NO_ACTIVITY_AFTER_7_DAYS' NO_ACTIVITY = 'schedules#no_activity' # Reminder 30 days before the access period expires - # ACCESS_PERIOD_REMINDER_30_DAYS = 'SCHEDULES#ACCESS_PERIOD_REMINDER_30_DAYS' + # REMINDER_ACCESS_PERIOD_BEFORE_30_DAYS = 'SCHEDULE#REMINDER_ACCESS_PERIOD_BEFORE_30_DAYS' ACCESS_PERIOD_ENDS = 'schedules#access_period_ends' # Reminder for certificate expiration set to 30 days from now - CERT_EXP_REMINDER_30_DAYS = 'SCHEDULES#CERT_EXP_REMINDER_30_DAYS' + REMINDER_CERT_EXPIRATION_BEFORE_30_DAYS = ( + 'SCHEDULE#REMINDER_CERT_EXPIRATION_BEFORE_30_DAYS' + ) # Archive the course after the certificate expires - # SET_AS_ARCHIVE = 'schedules#set_as_archive' + # SET_AS_ARCHIVE = 'SCHEDULE#SET_AS_ARCHIVE' ARCHIVE_IT = 'schedules#archive_it' # When the access period ends for a course without a certificate - # SET_AS_EXPIRE = 'schedules#set_as_expire' + # SET_AS_EXPIRE = 'SCHEDULE#SET_AS_EXPIRE' EXPIRATION = 'schedules#expiration' +class DeduplicationConflictError(Exception): + def __init__(self, *args): + super().__init__('Enrollment already exists') + + +class SlotDoesNotExistError(Exception): + def __init__(self, *args): + super().__init__('Slot does not exist') + + def enroll( enrollment: Enrollment, *, @@ -112,8 +124,8 @@ def enroll( }, ) # Enrollment expires by default when the access period ends. - # When the course is finished, it is automatically removed, - # and the `schedules#course_archived` event is created. + # When the course is completed, it is automatically removed, + # and the `SCHEDULE#SET_AS_ARCHIVE` event is created. transact.put( item={ 'id': enrollment.id, @@ -146,9 +158,7 @@ def enroll( transact.put( item={ 'id': enrollment.id, - # Post-migration: uncomment the following line - # 'sk': f'LINKED_ENTITIES#{entity.type}', - 'sk': f'linked_entities#{entity.type}', + 'sk': f'LINKED_ENTITIES#{entity.type}', 'created_at': now_, f'{keyprefix}_id': entity.id, } @@ -166,10 +176,6 @@ def enroll( } ) - class SlotDoesNotExistError(Exception): - def __init__(self, *args): - super().__init__('Slot does not exist') - transact.delete( key=KeyPair(slot.id, slot.sk), cond_expr='attribute_exists(sk)', @@ -194,10 +200,6 @@ def enroll( }, ) - class DeduplicationConflictError(Exception): - def __init__(self, *args): - super().__init__('Enrollment already exists') - # Prevents the user from enrolling in the same course again until # the deduplication window expires or is removed. if deduplication_window: diff --git a/enrollments-events/app/events/emails/reminder_access_period_before_15_days.py b/enrollments-events/app/events/emails/reminder_access_period_before_15_days.py deleted file mode 100644 index 78941b7..0000000 --- a/enrollments-events/app/events/emails/reminder_access_period_before_15_days.py +++ /dev/null @@ -1,12 +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 - -logger = Logger(__name__) - - -@event_source(data_classe=EventBridgeEvent) -@logger.inject_lambda_context -def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool: - new_image = event.detail['new_image'] - return True diff --git a/enrollments-events/app/events/emails/reminder_no_access_after_3_days.py b/enrollments-events/app/events/emails/reminder_no_access_after_3_days.py index 00492e1..1661b91 100644 --- a/enrollments-events/app/events/emails/reminder_no_access_after_3_days.py +++ b/enrollments-events/app/events/emails/reminder_no_access_after_3_days.py @@ -19,7 +19,7 @@ SUBJECT = 'Seu curso de {course} está esperando por você na EDUSEG®' MESSAGE = """ Oi {first_name}, tudo bem?

-Há 3 dias você foi matriculado no curso de {course}, mas ainda não iniciou.
+Você foi matriculado no curso de {course} há 3 dias, mas ainda não iniciou.
Não perca a oportunidade de aprender e aproveitar ao máximo seu curso!

Clique no link para acessar seu curso: @@ -39,10 +39,10 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool: # Post-migration: Remove the following lines if 'email' not in old_image: # If email is missing, use enrollment email - data = enrollment_layer.get_item(KeyPair(old_image['id'], '0')) - old_image['name'] = data['user']['name'] - old_image['email'] = data['user']['email'] - old_image['course'] = data['course']['name'] + cur_image = enrollment_layer.get_item(KeyPair(old_image['id'], '0')) + old_image['name'] = cur_image['user']['name'] + old_image['email'] = cur_image['user']['email'] + old_image['course'] = cur_image['course']['name'] emailmsg = Message( from_=EMAIL_SENDER, @@ -74,7 +74,7 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool: enrollment_layer.put_item( item={ 'id': old_image['id'], - 'sk': 'SCHEDULES#REMINDER_NO_ACCESS_AFTER_3_DAYS#FAILED', + 'sk': 'SCHEDULE#REMINDER_NO_ACCESS_AFTER_3_DAYS#FAILED', # Post-migration: Uncomment the following line # 'sk': f'{old_image["sk"]}#FAILED', 'created_at': now_, @@ -86,7 +86,7 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool: enrollment_layer.put_item( item={ 'id': old_image['id'], - 'sk': 'SCHEDULES#REMINDER_NO_ACCESS_AFTER_3_DAYS#EXECUTED', + 'sk': 'SCHEDULE#REMINDER_NO_ACCESS_AFTER_3_DAYS#EXECUTED', # Post-migration: Uncomment the following line # 'sk': f'{old_image["sk"]}#EXECUTED', 'created_at': now_, diff --git a/enrollments-events/app/events/emails/reminder_no_activity_after_7_days.py b/enrollments-events/app/events/emails/reminder_no_activity_after_7_days.py index 78941b7..1dbb9f7 100644 --- a/enrollments-events/app/events/emails/reminder_no_activity_after_7_days.py +++ b/enrollments-events/app/events/emails/reminder_no_activity_after_7_days.py @@ -1,12 +1,89 @@ from aws_lambda_powertools import Logger -from aws_lambda_powertools.utilities.data_classes import EventBridgeEvent, event_source +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 layercake.email_ import Message +from layercake.strutils import first_word, truncate_str + +from boto3clients import dynamodb_client, sesv2_client +from config import ( + EMAIL_SENDER, + ENROLLMENT_TABLE, +) + +SUBJECT = '' +MESSAGE = """ +""" logger = Logger(__name__) +enrollment_layer = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client) -@event_source(data_classe=EventBridgeEvent) +@event_source(data_class=EventBridgeEvent) @logger.inject_lambda_context def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool: - new_image = event.detail['new_image'] - return True + old_image = event.detail['old_image'] + now_ = now() + + # Post-migration: Remove the following lines + if 'email' not in old_image: + # If email is missing, use enrollment email + cur_image = enrollment_layer.get_item(KeyPair(old_image['id'], '0')) + old_image['name'] = cur_image['user']['name'] + old_image['email'] = cur_image['user']['email'] + old_image['course'] = cur_image['course']['name'] + + emailmsg = Message( + from_=EMAIL_SENDER, + to=( + old_image['name'], + old_image['email'], + ), + subject=SUBJECT.format(course=truncate_str(old_image['course'])), + ) + emailmsg.add_alternative( + MESSAGE.format( + first_name=first_word(old_image['name']), + course=old_image['course'], + ) + ) + + try: + sesv2_client.send_email( + Content={ + 'Raw': { + 'Data': emailmsg.as_bytes(), + }, + } + ) + logger.info('Email sent') + except Exception as exc: + logger.exception(exc) + + enrollment_layer.put_item( + item={ + 'id': old_image['id'], + 'sk': 'SCHEDULE#REMINDER_NO_ACTIVITY_AFTER_7_DAYS#FAILED', + # Post-migration: Uncomment the following line + # 'sk': f'{old_image["sk"]}#FAILED', + 'created_at': now_, + } + ) + + return False + else: + enrollment_layer.put_item( + item={ + 'id': old_image['id'], + 'sk': 'SCHEDULE#REMINDER_NO_ACTIVITY_AFTER_7_DAYS#EXECUTED', + # Post-migration: Uncomment the following line + # 'sk': f'{old_image["sk"]}#EXECUTED', + 'created_at': now_, + } + ) + + return True diff --git a/enrollments-events/app/events/enroll.py b/enrollments-events/app/events/enroll.py index df50233..718df0e 100644 --- a/enrollments-events/app/events/enroll.py +++ b/enrollments-events/app/events/enroll.py @@ -30,7 +30,7 @@ processor = BatchProcessor() @event_source(data_class=EventBridgeEvent) @logger.inject_lambda_context -def lambda_handler(event: EventBridgeEvent, context: LambdaContext): +def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool: new_image = event.detail['new_image'] order = order_layer.collection.get_items( TransactKey(new_image['id']) diff --git a/enrollments-events/app/events/stopgap/set_subscription_covered.py b/enrollments-events/app/events/stopgap/set_subscription_covered.py index 73dc34f..abd2b79 100644 --- a/enrollments-events/app/events/stopgap/set_subscription_covered.py +++ b/enrollments-events/app/events/stopgap/set_subscription_covered.py @@ -23,13 +23,13 @@ enrollment_layer = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client) def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool: new_image = event.detail['new_image'] now_ = now() - data = user_layer.get_item( + terms = user_layer.get_item( # Post-migration: uncomment the following line # KeyPair(new_image['org_id'], 'METADATA#BILLING_TERMS'), KeyPair(new_image['tenant_id'], 'metadata#billing_policy'), ) - if not data: + if not terms: return False try: @@ -49,8 +49,8 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool: 'id': new_image['id'], 'sk': 'METADATA#SUBSCRIPTION_COVERED', 'org_id': new_image['tenant_id'], - 'billing_day': data['billing_day'], - 'created_at': now(), + 'billing_day': terms['billing_day'], + 'created_at': now_, }, cond_expr='attribute_not_exists(sk)', ) diff --git a/enrollments-events/template.yaml b/enrollments-events/template.yaml index 84fec40..be81735 100644 --- a/enrollments-events/template.yaml +++ b/enrollments-events/template.yaml @@ -23,7 +23,7 @@ Globals: Architectures: - x86_64 Layers: - - !Sub arn:aws:lambda:sa-east-1:336641857101:layer:layercake:86 + - !Sub arn:aws:lambda:sa-east-1:336641857101:layer:layercake:94 Environment: Variables: TZ: America/Sao_Paulo @@ -134,7 +134,7 @@ Resources: detail: new_image: # Post-migration: uncomment the following lines - # sk: [slots] + # sk: [SLOT] # mode: [STANDALONE] sk: [generated_items] scope: [SINGLE_USER] @@ -163,12 +163,25 @@ Resources: detail: new_image: # Post-migration: uncomment the following lines - # sk: [slots] + # sk: [SLOT] # mode: [BATCH] sk: [generated_items] scope: [MULTI_USER] status: [PENDING] + SesPolicy: + Type: AWS::IAM::ManagedPolicy + Properties: + PolicyDocument: + Version: 2012-10-17 + Statement: + - Effect: Allow + Action: + - ses:SendRawEmail + Resource: + - !Sub arn:aws:ses:${AWS::Region}:${AWS::AccountId}:identity/eduseg.com.br + - !Sub arn:aws:ses:${AWS::Region}:${AWS::AccountId}:configuration-set/tracking + EventReminderNoAccessAfter3DaysFunction: Type: AWS::Serverless::Function Properties: @@ -176,16 +189,9 @@ Resources: LoggingConfig: LogGroup: !Ref EventLog Policies: + - !Ref SesPolicy - DynamoDBCrudPolicy: TableName: !Ref EnrollmentTable - - Version: 2012-10-17 - Statement: - - Effect: Allow - Action: - - ses:SendRawEmail - Resource: - - !Sub arn:aws:ses:${AWS::Region}:${AWS::AccountId}:identity/eduseg.com.br - - !Sub arn:aws:ses:${AWS::Region}:${AWS::AccountId}:configuration-set/tracking Events: DynamoDBEvent: Type: EventBridgeRule @@ -196,11 +202,55 @@ Resources: detail: keys: sk: - - SCHEDULES#REMINDER_NO_ACCESS_AFTER_3_DAYS + - SCHEDULE#REMINDER_NO_ACCESS_AFTER_3_DAYS # Post-migration: remove the following lines + - SCHEDULES#REMINDER_NO_ACCESS_AFTER_3_DAYS - schedules#does_not_access - schedules#reminder_no_access_3_days + EventReminderNoActivityAfter7DaysFunction: + Type: AWS::Serverless::Function + Properties: + Handler: events.emails.reminder_no_activity_after_7_days.lambda_handler + LoggingConfig: + LogGroup: !Ref EventLog + Policies: + - !Ref SesPolicy + - DynamoDBCrudPolicy: + TableName: !Ref EnrollmentTable + Events: + DynamoDBEvent: + Type: EventBridgeRule + Properties: + Pattern: + resources: [!Ref EnrollmentTable] + detail-type: [EXPIRE] + detail: + keys: + sk: + - SCHEDULE#REMINDER_NO_ACTIVITY_AFTER_7_DAYS + + EventScheduleRemindersFunction: + Type: AWS::Serverless::Function + Properties: + Handler: events.schedule_reminders.lambda_handler + LoggingConfig: + LogGroup: !Ref EventLog + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref EnrollmentTable + Events: + DynamoDBEvent: + Type: EventBridgeRule + Properties: + Pattern: + resources: [!Ref EnrollmentTable] + detail-type: [INSERT] + detail: + new_image: + sk: ["0"] + status: [PENDING] + EventIssueCertFunction: Type: AWS::Serverless::Function Properties: @@ -219,4 +269,4 @@ Resources: new_image: status: [COMPLETED] old_image: - status: [PENDING] + status: [IN_PROGRESS] diff --git a/enrollments-events/uv.lock b/enrollments-events/uv.lock index bc8b3f2..9703cc7 100644 --- a/enrollments-events/uv.lock +++ b/enrollments-events/uv.lock @@ -29,6 +29,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" }, ] +[[package]] +name = "authlib" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/a1/d8d1c6f8bc922c0b87ae0d933a8ed57be1bef6970894ed79c2852a153cd3/authlib-1.6.1.tar.gz", hash = "sha256:4dffdbb1460ba6ec8c17981a4c67af7d8af131231b5a36a88a1e8c80c111cdfd", size = 159988, upload-time = "2025-07-20T07:38:42.834Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/58/cc6a08053f822f98f334d38a27687b69c6655fb05cd74a7a5e70a2aeed95/authlib-1.6.1-py2.py3-none-any.whl", hash = "sha256:e9d2031c34c6309373ab845afc24168fe9e93dc52d252631f52642f21f5ed06e", size = 239299, upload-time = "2025-07-20T07:38:39.259Z" }, +] + [[package]] name = "aws-encryption-sdk" version = "4.0.1" @@ -46,15 +58,15 @@ wheels = [ [[package]] name = "aws-lambda-powertools" -version = "3.13.0" +version = "3.19.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jmespath" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fd/2b/068efd467c0866e2272c5de7525ddb02ff4e694f71245c8d2a83d4948f23/aws_lambda_powertools-3.13.0.tar.gz", hash = "sha256:99dc11ac6eb81564f599fdd85ba79069f7740ae3481c99bca2cee8abb7c95543", size = 672664, upload-time = "2025-05-20T07:35:30.254Z" } +sdist = { url = "https://files.pythonhosted.org/packages/58/db/eb2708f7c27ab02b8d85936ce9308538e1e22c8c8224be5f00da3e6f44f7/aws_lambda_powertools-3.19.0.tar.gz", hash = "sha256:8897ba4be0b3a51f2b8f68946d650f3ef574fa2c40395544de03bd0c61979999", size = 689768, upload-time = "2025-08-12T08:45:46.887Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/98/cd/2241ff877528c66ee11ea636684c4242ceeadb6459a33b08507a40151414/aws_lambda_powertools-3.13.0-py3-none-any.whl", hash = "sha256:9df045f4c3ff944176655813dbff8c1160e056babf5e6d71d4e18c0003818f2e", size = 802546, upload-time = "2025-05-20T07:35:27.767Z" }, + { url = "https://files.pythonhosted.org/packages/c6/52/5a73194286af329309263e9c4e2a57b8feac63bb6027be8d2d6222cd4da7/aws_lambda_powertools-3.19.0-py3-none-any.whl", hash = "sha256:98f18d35f843cd46b80ccadcf39eefc0c489325bea116383bd93048a5241d9fc", size = 832645, upload-time = "2025-08-12T08:45:44.982Z" }, ] [package.optional-dependencies] @@ -439,19 +451,22 @@ wheels = [ [[package]] name = "layercake" -version = "0.8.2" +version = "0.9.12" source = { directory = "../layercake" } dependencies = [ { name = "arnparse" }, + { name = "authlib" }, { name = "aws-lambda-powertools", extra = ["all"] }, { name = "dictdiffer" }, { name = "ftfy" }, { name = "glom" }, { name = "meilisearch" }, { name = "orjson" }, + { name = "passlib" }, { name = "pycpfcnpj" }, { name = "pydantic", extra = ["email"] }, { name = "pydantic-extra-types" }, + { name = "pyjwt" }, { name = "pytz" }, { name = "requests" }, { name = "smart-open", extra = ["s3"] }, @@ -462,15 +477,18 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "arnparse", specifier = ">=0.0.2" }, - { name = "aws-lambda-powertools", extras = ["all"], specifier = ">=3.8.0" }, + { name = "authlib", specifier = ">=1.6.1" }, + { name = "aws-lambda-powertools", extras = ["all"], specifier = ">=3.18.0" }, { name = "dictdiffer", specifier = ">=0.9.0" }, { name = "ftfy", specifier = ">=6.3.1" }, { name = "glom", specifier = ">=24.11.0" }, { name = "meilisearch", specifier = ">=0.34.0" }, { name = "orjson", specifier = ">=3.10.15" }, + { name = "passlib", specifier = ">=1.7.4" }, { name = "pycpfcnpj", specifier = ">=1.8" }, { name = "pydantic", extras = ["email"], specifier = ">=2.10.6" }, { name = "pydantic-extra-types", specifier = ">=2.10.3" }, + { name = "pyjwt", specifier = ">=2.10.1" }, { name = "pytz", specifier = ">=2025.1" }, { name = "requests", specifier = ">=2.32.3" }, { name = "smart-open", extras = ["s3"], specifier = ">=7.1.0" }, @@ -534,6 +552,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] +[[package]] +name = "passlib" +version = "1.7.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/06/9da9ee59a67fae7761aab3ccc84fa4f3f33f125b370f1ccdb915bf967c11/passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04", size = 689844, upload-time = "2020-10-08T19:00:52.121Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/a4/ab6b7589382ca3df236e03faa71deac88cae040af60c071a78d254a62172/passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1", size = 525554, upload-time = "2020-10-08T19:00:49.856Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -645,6 +672,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b6/5f/d6d641b490fd3ec2c4c13b4244d68deea3a1b970a97be64f34fb5504ff72/pydantic_settings-2.9.1-py3-none-any.whl", hash = "sha256:59b4f431b1defb26fe620c71a7d3968a710d719f5f4cdbbdb7926edeb770f6ef", size = 44356, upload-time = "2025-04-18T16:44:46.617Z" }, ] +[[package]] +name = "pyjwt" +version = "2.10.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" }, +] + [[package]] name = "pytest" version = "8.3.5" diff --git a/order-events/app/events/billing/append_enrollment.py b/order-events/app/events/billing/append_enrollment.py index d3f6235..048b730 100644 --- a/order-events/app/events/billing/append_enrollment.py +++ b/order-events/app/events/billing/append_enrollment.py @@ -112,31 +112,46 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool: ), flatten_top=False, ) - order_layer.put_item( - item={ - 'id': pk, - 'sk': f'{sk}#ENROLLMENT#{enrollment["id"]}', - 'user': pick(('id', 'name'), enrollment['user']), - 'course': pick(('id', 'name'), enrollment['course']), - 'unit_price': course['unit_price'], - # Post-migration: uncomment the following line - # 'enrolled_at': enrollment['created_at'], - 'enrolled_at': enrollment['create_date'], - 'created_at': now_, - } - # Add author if present - | ( - { - 'author': { - 'id': author['user_id'], - 'name': author['name'], - } + with order_layer.transact_writer() as transact: + transact.put( + item={ + 'id': pk, + 'sk': f'{sk}#ENROLLMENT#{enrollment["id"]}', + 'user': pick(('id', 'name'), enrollment['user']), + 'course': pick(('id', 'name'), enrollment['course']), + 'unit_price': course['unit_price'], + # Post-migration: uncomment the following line + # 'enrolled_at': enrollment['created_at'], + 'enrolled_at': enrollment['create_date'], + 'created_at': now_, } - if author - else {} - ), - cond_expr='attribute_not_exists(sk)', - ) + # Add author if present + | ( + { + 'author': { + 'id': author['user_id'], + 'name': author['name'], + } + } + if author + else {} + ), + cond_expr='attribute_not_exists(sk)', + ) + transact.update( + key=KeyPair( + pk=new_image['id'], + sk=new_image['sk'], + ), + table_name=ENROLLMENT_TABLE, + update_expr='SET billing_period = :billing_period, \ + updated_at = :updated_at', + expr_attr_values={ + ':billing_period': sk, + ':updated_at': now_, + }, + cond_expr='attribute_exists(sk)', + ) except Exception as exc: logger.exception( exc, diff --git a/order-events/app/events/billing/send_email_on_closing.py b/order-events/app/events/billing/send_email_on_closing.py index 48d3dfc..f3e4ae2 100644 --- a/order-events/app/events/billing/send_email_on_closing.py +++ b/order-events/app/events/billing/send_email_on_closing.py @@ -38,7 +38,7 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool: new_image = event.detail['new_image'] # Key pattern `BILLING#ORG#{org_id}` *_, org_id = new_image['id'].split('#') - # Key pattern `START#{start_date}#END#{end_date}#SCHEDULE#AUTO_CLOSE` + # Key pattern `START#{start_date}#END#{end_date} _, start_date, _, end_date, *_ = new_image['sk'].split('#') emailmsg = Message( diff --git a/order-events/app/events/remove_slots_if_canceled.py b/order-events/app/events/remove_slots_if_canceled.py index 627104c..9d1f5ca 100644 --- a/order-events/app/events/remove_slots_if_canceled.py +++ b/order-events/app/events/remove_slots_if_canceled.py @@ -23,13 +23,15 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool: new_image = event.detail['new_image'] order_id = new_image['id'] org_id = new_image['tenant_id'] + # Post-migration: Uncomment the following line + # org_id = new_image['org_id'] result = enrollment_layer.collection.query( KeyPair( # Post-migration: Uncomment the following line # f'SLOT#ORG#{org_id}', - f'vacancies#{org_id}', - order_id, + pk=f'vacancies#{org_id}', + sk=order_id, ), limit=100, ) diff --git a/order-events/template.yaml b/order-events/template.yaml index 47ec6fc..c4852c3 100644 --- a/order-events/template.yaml +++ b/order-events/template.yaml @@ -46,7 +46,7 @@ Resources: Properties: RetentionInDays: 90 - EventBillingAddEnrollmentFunction: + EventBillingAppendEnrollmentFunction: Type: AWS::Serverless::Function Properties: Handler: events.billing.append_enrollment.lambda_handler @@ -55,7 +55,7 @@ Resources: Policies: - DynamoDBCrudPolicy: TableName: !Ref OrderTable - - DynamoDBReadPolicy: + - DynamoDBCrudPolicy: TableName: !Ref EnrollmentTable - DynamoDBReadPolicy: TableName: !Ref CourseTable @@ -69,6 +69,8 @@ Resources: detail: new_image: sk: ["METADATA#SUBSCRIPTION_COVERED"] + billing_period: + - exists: false EventBillingCancelEnrollmentFunction: Type: AWS::Serverless::Function @@ -91,10 +93,10 @@ Resources: detail: new_image: sk: ["0"] - status: ["CANCELED"] + status: [CANCELED] subscription_covered: [true] old_image: - status: ["PENDING"] + status: [PENDING] EventBillingCloseWindowFunction: Type: AWS::Serverless::Function @@ -117,6 +119,8 @@ Resources: detail-type: [EXPIRE] detail: keys: + id: + - prefix: BILLING sk: - suffix: SCHEDULE#AUTO_CLOSE @@ -149,9 +153,11 @@ Resources: detail-type: [MODIFY] detail: new_image: - status: [CLOSED] + id: + - prefix: BILLING s3_uri: - exists: true + status: [CLOSED] old_image: status: [PENDING] diff --git a/order-events/tests/events/billing/test_append_enrollment.py b/order-events/tests/events/billing/test_append_enrollment.py index 90545d5..6d1ee8d 100644 --- a/order-events/tests/events/billing/test_append_enrollment.py +++ b/order-events/tests/events/billing/test_append_enrollment.py @@ -9,10 +9,11 @@ def test_append_enrollment( dynamodb_persistence_layer: DynamoDBPersistenceLayer, lambda_context: LambdaContext, ): + enrollment_id = '945e8672-1d72-45c6-b76c-ac06aa8b52ab' event = { 'detail': { 'new_image': { - 'id': '945e8672-1d72-45c6-b76c-ac06aa8b52ab', + 'id': enrollment_id, 'sk': 'METADATA#SUBSCRIPTION_COVERED', 'billing_day': 6, 'created_at': '2025-07-23T18:09:22.785678-03:00', @@ -34,3 +35,9 @@ def test_append_enrollment( == 'START#2025-05-06#END#2025-06-05#ENROLLMENT#945e8672-1d72-45c6-b76c-ac06aa8b52ab' ) assert items[2]['sk'] == 'START#2025-05-06#END#2025-06-05' + + print( + dynamodb_persistence_layer.collection.get_item( + KeyPair(enrollment_id, 'METADATA#SUBSCRIPTION_COVERED') + ) + ) diff --git a/order-events/tests/seeds.jsonl b/order-events/tests/seeds.jsonl index 7479dde..15519eb 100644 --- a/order-events/tests/seeds.jsonl +++ b/order-events/tests/seeds.jsonl @@ -24,6 +24,7 @@ // Enrollments {"id": "945e8672-1d72-45c6-b76c-ac06aa8b52ab", "sk": "0", "course": {"id": "123", "name": "pytest"}, "user": {"id": "5OxmMjL-ujoR5IMGegQz", "name": "Sérgio R Siqueira"}, "create_date": "2025-06-05T12:13:54.371416+00:00"} {"id": "945e8672-1d72-45c6-b76c-ac06aa8b52ab", "sk": "author", "name": "Carolina Brand", "user_id": "SMEXYk5MQkKCzknJpxqr8n"} +{"id": "945e8672-1d72-45c6-b76c-ac06aa8b52ab", "sk": "METADATA#SUBSCRIPTION_COVERED", "billing_day": 6, "org_id": "cJtK9SsnJhKPyxESe7g3DG", "created_at": "2025-07-23T18:09:22.785678-03:00"} {"id": "77055ad7-03e1-4b07-98dc-a2f1a90913ba", "sk": "0", "course": {"id": "123", "name": "pytest"}, "user": {"id": "5OxmMjL-ujoR5IMGegQz", "name": "Sérgio R Siqueira"}, "create_date": "2025-06-05T12:13:54.371416+00:00"} {"id": "77055ad7-03e1-4b07-98dc-a2f1a90913ba", "sk": "METADATA#SUBSCRIPTION_COVERED", "billing_day": 6, "org_id": "cJtK9SsnJhKPyxESe7g3DG"}