renamem orders
This commit is contained in:
5
orders-events/Makefile
Normal file
5
orders-events/Makefile
Normal file
@@ -0,0 +1,5 @@
|
||||
build:
|
||||
sam build --use-container
|
||||
|
||||
deploy: build
|
||||
sam deploy --debug
|
||||
15
orders-events/app/boto3clients.py
Normal file
15
orders-events/app/boto3clients.py
Normal file
@@ -0,0 +1,15 @@
|
||||
import os
|
||||
|
||||
import boto3
|
||||
|
||||
|
||||
def get_dynamodb_client():
|
||||
if os.getenv('AWS_LAMBDA_FUNCTION_NAME'):
|
||||
return boto3.client('dynamodb')
|
||||
|
||||
return boto3.client('dynamodb', endpoint_url='http://localhost:8000')
|
||||
|
||||
|
||||
dynamodb_client = get_dynamodb_client()
|
||||
s3_client = boto3.client('s3')
|
||||
sesv2_client = boto3.client('sesv2')
|
||||
21
orders-events/app/config.py
Normal file
21
orders-events/app/config.py
Normal file
@@ -0,0 +1,21 @@
|
||||
import os
|
||||
|
||||
USER_TABLE: str = os.getenv('USER_TABLE') # type: ignore
|
||||
ORDER_TABLE: str = os.getenv('ORDER_TABLE') # type: ignore
|
||||
COURSE_TABLE: str = os.getenv('COURSE_TABLE') # type: ignore
|
||||
ENROLLMENT_TABLE: str = os.getenv('ENROLLMENT_TABLE') # type: ignore
|
||||
|
||||
BUCKET_NAME: str = os.getenv('BUCKET_NAME') # type: ignore
|
||||
|
||||
EMAIL_SENDER = ('EDUSEG®', 'noreply@eduseg.com.br')
|
||||
|
||||
# Post-migration: Remove the following lines
|
||||
if os.getenv('AWS_LAMBDA_FUNCTION_NAME'):
|
||||
SQLITE_DATABASE = 'courses_export_2025-06-18_110214.db'
|
||||
else:
|
||||
SQLITE_DATABASE = 'app/courses_export_2025-06-18_110214.db'
|
||||
|
||||
SQLITE_TABLE = 'courses'
|
||||
|
||||
PAPERFORGE_API = 'https://paperforge.saladeaula.digital'
|
||||
BILLING_TEMPLATE_URI = 's3://saladeaula.digital/billing/template.html'
|
||||
BIN
orders-events/app/courses_export_2025-06-18_110214.db
Normal file
BIN
orders-events/app/courses_export_2025-06-18_110214.db
Normal file
Binary file not shown.
0
orders-events/app/events/__init__.py
Normal file
0
orders-events/app/events/__init__.py
Normal file
71
orders-events/app/events/append_org_id.py
Normal file
71
orders-events/app/events/append_org_id.py
Normal file
@@ -0,0 +1,71 @@
|
||||
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,
|
||||
SortKey,
|
||||
)
|
||||
|
||||
from boto3clients import dynamodb_client
|
||||
from config import ORDER_TABLE, USER_TABLE
|
||||
|
||||
logger = Logger(__name__)
|
||||
user_layer = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client)
|
||||
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']
|
||||
now_ = now()
|
||||
data = user_layer.collection.get_items(
|
||||
KeyPair(
|
||||
pk='cnpj',
|
||||
sk=SortKey(new_image['cnpj'], path_spec='user_id'),
|
||||
rename_key='org_id',
|
||||
)
|
||||
+ KeyPair(
|
||||
pk='email',
|
||||
sk=SortKey(new_image['email'], path_spec='user_id'),
|
||||
rename_key='user_id',
|
||||
),
|
||||
flatten_top=False,
|
||||
)
|
||||
|
||||
# Sometimes the function executes before the user insertion completes,
|
||||
# so an exception is raised to trigger a retry.
|
||||
if len(data) < 2:
|
||||
raise ValueError('IDs not found')
|
||||
|
||||
logger.info('IDs found', data=data)
|
||||
|
||||
with order_layer.transact_writer() as transact:
|
||||
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_,
|
||||
},
|
||||
)
|
||||
|
||||
transact.update(
|
||||
key=KeyPair(new_image['id'], 'author'),
|
||||
update_expr='SET user_id = :user_id, updated_at = :updated_at',
|
||||
expr_attr_values={
|
||||
':user_id': data['user_id'],
|
||||
':updated_at': now_,
|
||||
},
|
||||
)
|
||||
|
||||
logger.info('IDs updated')
|
||||
|
||||
return True
|
||||
55
orders-events/app/events/append_user_id.py
Normal file
55
orders-events/app/events/append_user_id.py
Normal file
@@ -0,0 +1,55 @@
|
||||
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,
|
||||
SortKey,
|
||||
)
|
||||
|
||||
from boto3clients import dynamodb_client
|
||||
from config import ORDER_TABLE, USER_TABLE
|
||||
|
||||
logger = Logger(__name__)
|
||||
user_layer = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client)
|
||||
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']
|
||||
now_ = now()
|
||||
data = user_layer.collection.get_items(
|
||||
KeyPair(
|
||||
pk='cpf',
|
||||
sk=SortKey(new_image['cpf'], path_spec='user_id'),
|
||||
rename_key='user_id',
|
||||
)
|
||||
+ KeyPair(
|
||||
pk='email',
|
||||
sk=SortKey(new_image['email'], path_spec='user_id'),
|
||||
rename_key='user_id',
|
||||
),
|
||||
flatten_top=False,
|
||||
)
|
||||
|
||||
# Sometimes the function executes before the user insertion completes,
|
||||
# so an exception is raised to trigger a retry.
|
||||
if not data:
|
||||
raise ValueError('User ID not found')
|
||||
|
||||
order_layer.update_item(
|
||||
key=KeyPair(new_image['id'], '0'),
|
||||
update_expr='SET user_id = :user_id, updated_at = :updated_at',
|
||||
expr_attr_values={
|
||||
':user_id': data['user_id'],
|
||||
':updated_at': now_,
|
||||
},
|
||||
)
|
||||
|
||||
return True
|
||||
0
orders-events/app/events/billing/__init__.py
Normal file
0
orders-events/app/events/billing/__init__.py
Normal file
183
orders-events/app/events/billing/append_enrollment.py
Normal file
183
orders-events/app/events/billing/append_enrollment.py
Normal file
@@ -0,0 +1,183 @@
|
||||
import json
|
||||
import sqlite3
|
||||
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 fromisoformat, now, ttl
|
||||
from layercake.dynamodb import (
|
||||
DynamoDBPersistenceLayer,
|
||||
KeyPair,
|
||||
SortKey,
|
||||
TransactKey,
|
||||
)
|
||||
from layercake.funcs import pick
|
||||
from sqlite_utils import Database
|
||||
|
||||
from boto3clients import dynamodb_client
|
||||
from config import (
|
||||
COURSE_TABLE,
|
||||
ENROLLMENT_TABLE,
|
||||
ORDER_TABLE,
|
||||
SQLITE_DATABASE,
|
||||
SQLITE_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)
|
||||
|
||||
sqlite3.register_converter('json', json.loads)
|
||||
|
||||
|
||||
@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()
|
||||
org_id = new_image['org_id']
|
||||
enrollment = enrollment_layer.collection.get_items(
|
||||
TransactKey(new_image['id']) + SortKey('0') + SortKey('author')
|
||||
# Post-migration: uncomment the following line
|
||||
# + SortKey('CREATED_BY')
|
||||
)
|
||||
|
||||
if not enrollment:
|
||||
logger.debug('Enrollment not found')
|
||||
return False
|
||||
|
||||
logger.info('Enrollment found', data=enrollment)
|
||||
|
||||
# Keep it until the migration has been completed
|
||||
old_course = _get_course(enrollment['course']['id'])
|
||||
if old_course:
|
||||
enrollment['course'] = old_course
|
||||
|
||||
created_at: datetime = fromisoformat(enrollment['created_at']) # type: ignore
|
||||
start_date, end_date = get_billing_period(
|
||||
billing_day=new_image['billing_day'],
|
||||
date_=created_at,
|
||||
)
|
||||
pk = 'BILLING#ORG#{org_id}'.format(org_id=org_id)
|
||||
sk = 'START#{start}#END#{end}'.format(
|
||||
start=start_date.isoformat(),
|
||||
end=end_date.isoformat(),
|
||||
)
|
||||
|
||||
logger.info('Enrollment found', data=enrollment)
|
||||
|
||||
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=BillingConflictError,
|
||||
)
|
||||
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 BillingConflictError:
|
||||
pass
|
||||
|
||||
# Add enrollment entry to billing
|
||||
try:
|
||||
canceled_by = enrollment.get('author')
|
||||
course_id = enrollment['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,
|
||||
)
|
||||
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'],
|
||||
'enrolled_at': enrollment['created_at'],
|
||||
'created_at': now_,
|
||||
}
|
||||
# Add canceled_by if present
|
||||
| (
|
||||
{
|
||||
'author': {
|
||||
'id': canceled_by['user_id'],
|
||||
'name': canceled_by['name'],
|
||||
}
|
||||
}
|
||||
if canceled_by
|
||||
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,
|
||||
keypair={'pk': pk, 'sk': sk},
|
||||
)
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
|
||||
class BillingConflictError(Exception): ...
|
||||
|
||||
|
||||
class BillingNotFoundError(Exception): ...
|
||||
|
||||
|
||||
def _get_course(course_id: str) -> dict | None:
|
||||
with sqlite3.connect(
|
||||
database=SQLITE_DATABASE, detect_types=sqlite3.PARSE_DECLTYPES
|
||||
) as conn:
|
||||
db = Database(conn)
|
||||
rows = db[SQLITE_TABLE].rows_where(
|
||||
"json->>'$.metadata__betaeducacao_id' = ?", [course_id]
|
||||
)
|
||||
|
||||
for row in rows:
|
||||
return row['json']
|
||||
|
||||
return None
|
||||
86
orders-events/app/events/billing/cancel_enrollment.py
Normal file
86
orders-events/app/events/billing/cancel_enrollment.py
Normal file
@@ -0,0 +1,86 @@
|
||||
from datetime import datetime
|
||||
|
||||
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
|
||||
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair, SortKey, TransactKey
|
||||
from layercake.funcs import pick
|
||||
|
||||
from boto3clients import dynamodb_client
|
||||
from config import ENROLLMENT_TABLE, ORDER_TABLE
|
||||
from utils import get_billing_period
|
||||
|
||||
logger = Logger(__name__)
|
||||
enrollment_layer = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client)
|
||||
order_layer = DynamoDBPersistenceLayer(ORDER_TABLE, dynamodb_client)
|
||||
|
||||
|
||||
@event_source(data_class=EventBridgeEvent)
|
||||
@logger.inject_lambda_context
|
||||
def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
|
||||
now_ = now()
|
||||
new_image = event.detail['new_image']
|
||||
enrollment_id = new_image['id']
|
||||
subscription = enrollment_layer.collection.get_items(
|
||||
TransactKey(enrollment_id)
|
||||
+ SortKey('METADATA#SUBSCRIPTION_COVERED')
|
||||
# Post-migration: uncomment the following line
|
||||
# + SortKey('CANCELED', path_spec='canceled_by', rename_key='canceled_by')
|
||||
+ SortKey('CANCELED', path_spec='author', rename_key='canceled_by')
|
||||
)
|
||||
|
||||
created_at: datetime = fromisoformat(new_image['created_at']) # type: ignore
|
||||
start_date, end_date = get_billing_period(
|
||||
billing_day=int(subscription['billing_day']),
|
||||
date_=created_at,
|
||||
)
|
||||
pk = 'BILLING#ORG#{org_id}'.format(org_id=subscription['org_id'])
|
||||
sk = 'START#{start}#END#{end}'.format(
|
||||
start=start_date.isoformat(),
|
||||
end=end_date.isoformat(),
|
||||
)
|
||||
|
||||
if now_.date() > end_date:
|
||||
logger.debug('Enrollment outside the billing period')
|
||||
return False
|
||||
|
||||
try:
|
||||
canceled_by = subscription.get('canceled_by')
|
||||
# Retrieve canceled enrollment data
|
||||
old_enrollment = order_layer.collection.get_item(
|
||||
KeyPair(
|
||||
pk=pk,
|
||||
sk=f'{sk}#ENROLLMENT#{enrollment_id}',
|
||||
),
|
||||
exc_cls=EnrollmentNotFoundError,
|
||||
)
|
||||
|
||||
order_layer.put_item(
|
||||
item={
|
||||
'id': pk,
|
||||
'sk': f'{sk}#ENROLLMENT#{enrollment_id}#CANCELED',
|
||||
'unit_price': old_enrollment['unit_price'] * -1,
|
||||
'created_at': now_,
|
||||
}
|
||||
| pick(('user', 'course', 'enrolled_at'), old_enrollment)
|
||||
# Add created_by if present
|
||||
| ({'author': canceled_by} if canceled_by else {}),
|
||||
# Post-migration: uncomment the following line
|
||||
# | ({'created_by': canceled_by} if canceled_by else {}),
|
||||
cond_expr='attribute_not_exists(sk)',
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
exc,
|
||||
keypair={'pk': pk, 'sk': sk},
|
||||
)
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
|
||||
class EnrollmentNotFoundError(Exception): ...
|
||||
93
orders-events/app/events/billing/close_window.py
Normal file
93
orders-events/app/events/billing/close_window.py
Normal file
@@ -0,0 +1,93 @@
|
||||
import json
|
||||
|
||||
import requests
|
||||
from aws_lambda_powertools import Logger
|
||||
from aws_lambda_powertools.shared.json_encoder import Encoder
|
||||
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, s3_client
|
||||
from config import BILLING_TEMPLATE_URI, BUCKET_NAME, ORDER_TABLE, PAPERFORGE_API
|
||||
|
||||
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:
|
||||
keys = event.detail['keys']
|
||||
now_ = now()
|
||||
# Key pattern `BILLING#ORG#{org_id}`
|
||||
*_, org_id = keys['id'].split('#')
|
||||
# Key pattern `START#{start_date}#END#{end_date}#SCHEDULE#AUTO_CLOSE`
|
||||
_, start_date, _, end_date, *_ = keys['sk'].split('#')
|
||||
|
||||
result = order_layer.collection.query(
|
||||
KeyPair(
|
||||
pk=keys['id'],
|
||||
sk=f'START#{start_date}#END#{end_date}#ENROLLMENT',
|
||||
),
|
||||
limit=150,
|
||||
)
|
||||
|
||||
json_data = json.dumps(
|
||||
{
|
||||
'template_uri': BILLING_TEMPLATE_URI,
|
||||
'args': {
|
||||
'start_date': start_date,
|
||||
'end_date': end_date,
|
||||
'items': result['items'],
|
||||
},
|
||||
},
|
||||
cls=Encoder,
|
||||
)
|
||||
# Send template URI and data to Paperforge API to generate a PDF
|
||||
r = requests.post(PAPERFORGE_API, data=json_data)
|
||||
r.raise_for_status()
|
||||
|
||||
object_key = f'billing/{org_id}/{start_date}_{end_date}.pdf'
|
||||
s3_uri = f's3://{BUCKET_NAME}/{object_key}'
|
||||
|
||||
try:
|
||||
s3_client.put_object(
|
||||
Bucket=BUCKET_NAME,
|
||||
Key=object_key,
|
||||
Body=r.content,
|
||||
ContentType='application/pdf',
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception(exc)
|
||||
raise
|
||||
|
||||
with order_layer.transact_writer() as transact:
|
||||
transact.update(
|
||||
key=KeyPair(
|
||||
pk=keys['id'],
|
||||
sk=f'START#{start_date}#END#{end_date}',
|
||||
),
|
||||
update_expr='SET #status = :status, s3_uri = :s3_uri, \
|
||||
updated_at = :updated_at',
|
||||
expr_attr_names={'#status': 'status'},
|
||||
expr_attr_values={
|
||||
':status': 'CLOSED',
|
||||
':s3_uri': s3_uri,
|
||||
':updated_at': now_,
|
||||
},
|
||||
cond_expr='attribute_exists(sk)',
|
||||
)
|
||||
transact.put(
|
||||
item={
|
||||
'id': keys['id'],
|
||||
'sk': '{sk}#EXECUTED'.format(sk=keys['sk']),
|
||||
'created_at': now_,
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(f'PDF uploaded successfully to {s3_uri}')
|
||||
return True
|
||||
104
orders-events/app/events/billing/send_email_on_closing.py
Normal file
104
orders-events/app/events/billing/send_email_on_closing.py
Normal file
@@ -0,0 +1,104 @@
|
||||
from email.mime.application import MIMEApplication
|
||||
from urllib.parse import urlparse
|
||||
|
||||
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
|
||||
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair
|
||||
from layercake.email_ import Message
|
||||
|
||||
from boto3clients import dynamodb_client, s3_client, sesv2_client
|
||||
from config import EMAIL_SENDER, USER_TABLE
|
||||
|
||||
SUBJECT = (
|
||||
'Relatório de matrículas realizadas entre {start_date} e {end_date} na EDUSEG®'
|
||||
)
|
||||
REPLY_TO = ('Carolina Brand', 'carolina@somosbeta.com.br')
|
||||
BCC = [
|
||||
'sergio@somosbeta.com.br',
|
||||
'carolina@somosbeta.com.br',
|
||||
'tiago@somosbeta.com.br',
|
||||
]
|
||||
MESSAGE = """
|
||||
Oi, tudo bem?<br/><br/>
|
||||
|
||||
Em anexo você encontra o relatório das matrículas realizadas no período de
|
||||
<strong>{start_date}</strong> a <strong>{end_date}</strong>.<br/><br/>
|
||||
|
||||
Qualquer dúvida, estamos à disposição.
|
||||
"""
|
||||
|
||||
logger = Logger(__name__)
|
||||
user_layer = DynamoDBPersistenceLayer(USER_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']
|
||||
# Key pattern `BILLING#ORG#{org_id}`
|
||||
*_, org_id = new_image['id'].split('#')
|
||||
# Key pattern `START#{start_date}#END#{end_date}
|
||||
_, start_date, _, end_date, *_ = new_image['sk'].split('#')
|
||||
|
||||
emailmsg = Message(
|
||||
from_=EMAIL_SENDER,
|
||||
to=_get_admin_emails(org_id),
|
||||
reply_to=REPLY_TO,
|
||||
bcc=BCC,
|
||||
subject=SUBJECT.format(
|
||||
start_date=_locale_dateformat(start_date),
|
||||
end_date=_locale_dateformat(end_date),
|
||||
),
|
||||
)
|
||||
emailmsg.add_alternative(
|
||||
MESSAGE.format(
|
||||
start_date=_locale_dateformat(start_date),
|
||||
end_date=_locale_dateformat(end_date),
|
||||
)
|
||||
)
|
||||
attachment = MIMEApplication(_get_file_bytes(new_image['s3_uri']))
|
||||
attachment.add_header(
|
||||
'Content-Disposition',
|
||||
'attachment',
|
||||
filename=f'{start_date}_{end_date}.pdf',
|
||||
)
|
||||
emailmsg.attach(attachment)
|
||||
|
||||
try:
|
||||
sesv2_client.send_email(
|
||||
Content={
|
||||
'Raw': {
|
||||
'Data': emailmsg.as_bytes(),
|
||||
},
|
||||
}
|
||||
)
|
||||
logger.info('Email sent')
|
||||
except Exception as exc:
|
||||
logger.exception(exc)
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
|
||||
def _get_admin_emails(org_id: str) -> list[tuple[str, str]]:
|
||||
# Post-migration: rename `admins` to `ADMIN`
|
||||
r = user_layer.collection.query(KeyPair(org_id, 'admins'))
|
||||
return [(x['name'], x['email']) for x in r['items']]
|
||||
|
||||
|
||||
def _get_file_bytes(s3_uri: str) -> bytes:
|
||||
parsed = urlparse(s3_uri)
|
||||
bucket = parsed.netloc
|
||||
key = parsed.path.lstrip('/')
|
||||
|
||||
r = s3_client.get_object(Bucket=bucket, Key=key)
|
||||
return r['Body'].read()
|
||||
|
||||
|
||||
def _locale_dateformat(s: str) -> str:
|
||||
dt = fromisoformat(s)
|
||||
if not dt:
|
||||
raise ValueError('Invalid date')
|
||||
return dt.strftime('%d/%m/%Y')
|
||||
56
orders-events/app/events/remove_slots_if_canceled.py
Normal file
56
orders-events/app/events/remove_slots_if_canceled.py
Normal file
@@ -0,0 +1,56 @@
|
||||
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.dynamodb import (
|
||||
DynamoDBPersistenceLayer,
|
||||
KeyPair,
|
||||
)
|
||||
|
||||
from boto3clients import dynamodb_client
|
||||
from config import ENROLLMENT_TABLE, ORDER_TABLE
|
||||
|
||||
logger = Logger(__name__)
|
||||
enrollment_layer = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client)
|
||||
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']
|
||||
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}',
|
||||
pk=f'vacancies#{org_id}',
|
||||
sk=order_id,
|
||||
),
|
||||
limit=100,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Slots found',
|
||||
total_items=len(result['items']),
|
||||
slots=result['items'],
|
||||
)
|
||||
|
||||
with enrollment_layer.batch_writer() as batch:
|
||||
for pair in result['items']:
|
||||
batch.delete_item(
|
||||
Key={
|
||||
'id': {'S': pair['id']},
|
||||
'sk': {'S': pair['sk']},
|
||||
}
|
||||
)
|
||||
|
||||
logger.info('Slots deleted')
|
||||
|
||||
return True
|
||||
4
orders-events/app/events/stopgap/__init__.py
Normal file
4
orders-events/app/events/stopgap/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""
|
||||
Stopgap events. Everything here is a quick fix and should be replaced with
|
||||
proper solutions.
|
||||
"""
|
||||
68
orders-events/app/events/stopgap/remove_slots.py
Normal file
68
orders-events/app/events/stopgap/remove_slots.py
Normal file
@@ -0,0 +1,68 @@
|
||||
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.dynamodb import (
|
||||
DynamoDBPersistenceLayer,
|
||||
KeyPair,
|
||||
)
|
||||
|
||||
from boto3clients import dynamodb_client
|
||||
from config import ENROLLMENT_TABLE, ORDER_TABLE, USER_TABLE
|
||||
|
||||
logger = Logger(__name__)
|
||||
user_layer = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client)
|
||||
order_layer = DynamoDBPersistenceLayer(ORDER_TABLE, dynamodb_client)
|
||||
enrollment_layer = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client)
|
||||
|
||||
|
||||
@event_source(data_class=EventBridgeEvent)
|
||||
@logger.inject_lambda_context
|
||||
def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
|
||||
"""Remove slots if the tenant has a `metadata#billing_policy` and
|
||||
the total is greater than zero."""
|
||||
new_image = event.detail['new_image']
|
||||
data = order_layer.get_item(KeyPair(new_image['id'], '0'))
|
||||
org_id = data['tenant_id']
|
||||
|
||||
policy = user_layer.collection.get_item(
|
||||
KeyPair(pk=org_id, sk='metadata#billing_policy'),
|
||||
raise_on_error=False,
|
||||
default=False,
|
||||
)
|
||||
|
||||
# Skip if billing policy is missing or order is less than or equal to zero
|
||||
if not policy or data['total'] <= 0:
|
||||
logger.info('Missing billing policy')
|
||||
return False
|
||||
|
||||
logger.info(f'Billing policy from Org ID "{org_id}" found', policy=policy)
|
||||
|
||||
result = enrollment_layer.collection.query(
|
||||
KeyPair(
|
||||
f'vacancies#{org_id}',
|
||||
new_image['id'],
|
||||
),
|
||||
limit=100,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Slots found',
|
||||
total_items=len(result['items']),
|
||||
slots=result['items'],
|
||||
)
|
||||
|
||||
with enrollment_layer.batch_writer() as batch:
|
||||
for pair in result['items']:
|
||||
batch.delete_item(
|
||||
Key={
|
||||
'id': {'S': pair['id']},
|
||||
'sk': {'S': pair['sk']},
|
||||
}
|
||||
)
|
||||
|
||||
logger.info('Deleted all slots')
|
||||
|
||||
return True
|
||||
47
orders-events/app/events/stopgap/set_as_paid.py
Normal file
47
orders-events/app/events/stopgap/set_as_paid.py
Normal file
@@ -0,0 +1,47 @@
|
||||
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 to `PAID` if the status is `PENDING` and the total is zero."""
|
||||
new_image = event.detail['new_image']
|
||||
now_ = now()
|
||||
|
||||
with order_layer.transact_writer() as transact:
|
||||
transact.update(
|
||||
key=KeyPair(new_image['id'], '0'),
|
||||
update_expr='SET #status = :status, updated_at = :updated_at',
|
||||
expr_attr_names={
|
||||
'#status': 'status',
|
||||
},
|
||||
expr_attr_values={
|
||||
':status': 'PAID',
|
||||
':updated_at': now_,
|
||||
},
|
||||
)
|
||||
transact.put(
|
||||
item={
|
||||
'id': new_image['id'],
|
||||
'sk': 'paid_at',
|
||||
'paid_at': now_,
|
||||
}
|
||||
)
|
||||
|
||||
return True
|
||||
39
orders-events/app/utils.py
Normal file
39
orders-events/app/utils.py
Normal file
@@ -0,0 +1,39 @@
|
||||
import calendar
|
||||
from datetime import date, timedelta
|
||||
|
||||
|
||||
def get_billing_period(
|
||||
billing_day: int,
|
||||
date_: date,
|
||||
) -> tuple[date, date]:
|
||||
# Determine the anchor month and year
|
||||
if date_.day >= billing_day:
|
||||
anchor_month = date_.month
|
||||
anchor_year = date_.year
|
||||
else:
|
||||
# Move to previous month
|
||||
if date_.month == 1:
|
||||
anchor_month = 12
|
||||
anchor_year = date_.year - 1
|
||||
else:
|
||||
anchor_month = date_.month - 1
|
||||
anchor_year = date_.year
|
||||
|
||||
# Calculate start date
|
||||
_, last_day = calendar.monthrange(anchor_year, anchor_month)
|
||||
start_date = date(anchor_year, anchor_month, min(billing_day, last_day))
|
||||
|
||||
# Calculate next month and year
|
||||
if anchor_month == 12:
|
||||
next_month = 1
|
||||
next_year = anchor_year + 1
|
||||
else:
|
||||
next_month = anchor_month + 1
|
||||
next_year = anchor_year
|
||||
|
||||
# Calculate end date
|
||||
_, next_last_day = calendar.monthrange(next_year, next_month)
|
||||
end_day = min(billing_day, next_last_day)
|
||||
end_date = date(next_year, next_month, end_day) - timedelta(days=1)
|
||||
|
||||
return start_date, end_date
|
||||
34
orders-events/pyproject.toml
Normal file
34
orders-events/pyproject.toml
Normal file
@@ -0,0 +1,34 @@
|
||||
[project]
|
||||
name = "orders-events"
|
||||
version = "0.1.0"
|
||||
description = ""
|
||||
readme = ""
|
||||
requires-python = ">=3.13"
|
||||
dependencies = ["layercake"]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"jsonlines>=4.0.0",
|
||||
"moto[all]>=5.1.9",
|
||||
"pytest>=8.3.4",
|
||||
"pytest-cov>=6.0.0",
|
||||
"ruff>=0.9.1",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
pythonpath = ["app/"]
|
||||
addopts = "--cov --cov-report html -v"
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py311"
|
||||
src = ["app"]
|
||||
|
||||
[tool.ruff.format]
|
||||
quote-style = "single"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I"]
|
||||
|
||||
|
||||
[tool.uv.sources]
|
||||
layercake = { path = "../layercake" }
|
||||
3
orders-events/pyrightconfig.json
Normal file
3
orders-events/pyrightconfig.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extraPaths": ["app/"]
|
||||
}
|
||||
9
orders-events/samconfig.toml
Normal file
9
orders-events/samconfig.toml
Normal file
@@ -0,0 +1,9 @@
|
||||
version = 0.1
|
||||
[default.deploy.parameters]
|
||||
stack_name = "saladeaula-orders-events"
|
||||
resolve_s3 = true
|
||||
s3_prefix = "orders-events"
|
||||
region = "sa-east-1"
|
||||
confirm_changeset = false
|
||||
capabilities = "CAPABILITY_IAM"
|
||||
image_repositories = []
|
||||
289
orders-events/template.yaml
Normal file
289
orders-events/template.yaml
Normal file
@@ -0,0 +1,289 @@
|
||||
AWSTemplateFormatVersion: 2010-09-09
|
||||
Transform: AWS::Serverless-2016-10-31
|
||||
|
||||
Parameters:
|
||||
BucketName:
|
||||
Type: String
|
||||
Default: saladeaula.digital
|
||||
UserTable:
|
||||
Type: String
|
||||
Default: betaeducacao-prod-users_d2o3r5gmm4it7j
|
||||
EnrollmentTable:
|
||||
Type: String
|
||||
Default: betaeducacao-prod-enrollments
|
||||
OrderTable:
|
||||
Type: String
|
||||
Default: betaeducacao-prod-orders
|
||||
CourseTable:
|
||||
Type: String
|
||||
Default: saladeaula_courses
|
||||
|
||||
Globals:
|
||||
Function:
|
||||
CodeUri: app/
|
||||
Runtime: python3.13
|
||||
Tracing: Active
|
||||
Architectures:
|
||||
- x86_64
|
||||
Layers:
|
||||
- !Sub arn:aws:lambda:sa-east-1:336641857101:layer:layercake:98
|
||||
Environment:
|
||||
Variables:
|
||||
TZ: America/Sao_Paulo
|
||||
LOG_LEVEL: DEBUG
|
||||
DYNAMODB_PARTITION_KEY: id
|
||||
POWERTOOLS_LOGGER_SAMPLE_RATE: 0.1
|
||||
POWERTOOLS_LOGGER_LOG_EVENT: true
|
||||
USER_TABLE: !Ref UserTable
|
||||
ORDER_TABLE: !Ref OrderTable
|
||||
ENROLLMENT_TABLE: !Ref EnrollmentTable
|
||||
COURSE_TABLE: !Ref CourseTable
|
||||
BUCKET_NAME: !Ref BucketName
|
||||
|
||||
Resources:
|
||||
EventLog:
|
||||
Type: AWS::Logs::LogGroup
|
||||
Properties:
|
||||
RetentionInDays: 90
|
||||
|
||||
EventBillingAppendEnrollmentFunction:
|
||||
Type: AWS::Serverless::Function
|
||||
Properties:
|
||||
Handler: events.billing.append_enrollment.lambda_handler
|
||||
LoggingConfig:
|
||||
LogGroup: !Ref EventLog
|
||||
Policies:
|
||||
- DynamoDBCrudPolicy:
|
||||
TableName: !Ref OrderTable
|
||||
- DynamoDBCrudPolicy:
|
||||
TableName: !Ref EnrollmentTable
|
||||
- DynamoDBReadPolicy:
|
||||
TableName: !Ref CourseTable
|
||||
Events:
|
||||
Event:
|
||||
Type: EventBridgeRule
|
||||
Properties:
|
||||
Pattern:
|
||||
resources: [!Ref EnrollmentTable]
|
||||
detail-type: [INSERT]
|
||||
detail:
|
||||
new_image:
|
||||
sk: ["METADATA#SUBSCRIPTION_COVERED"]
|
||||
billing_period:
|
||||
- exists: false
|
||||
|
||||
EventBillingCancelEnrollmentFunction:
|
||||
Type: AWS::Serverless::Function
|
||||
Properties:
|
||||
Handler: events.billing.cancel_enrollment.lambda_handler
|
||||
LoggingConfig:
|
||||
LogGroup: !Ref EventLog
|
||||
Policies:
|
||||
- DynamoDBCrudPolicy:
|
||||
TableName: !Ref OrderTable
|
||||
- DynamoDBReadPolicy:
|
||||
TableName: !Ref EnrollmentTable
|
||||
Events:
|
||||
Event:
|
||||
Type: EventBridgeRule
|
||||
Properties:
|
||||
Pattern:
|
||||
resources: [!Ref EnrollmentTable]
|
||||
detail-type: [MODIFY]
|
||||
detail:
|
||||
new_image:
|
||||
sk: ["0"]
|
||||
status: [CANCELED]
|
||||
subscription_covered: [true]
|
||||
old_image:
|
||||
status: [PENDING]
|
||||
|
||||
EventBillingCloseWindowFunction:
|
||||
Type: AWS::Serverless::Function
|
||||
Properties:
|
||||
Handler: events.billing.close_window.lambda_handler
|
||||
Timeout: 26
|
||||
LoggingConfig:
|
||||
LogGroup: !Ref EventLog
|
||||
Policies:
|
||||
- DynamoDBCrudPolicy:
|
||||
TableName: !Ref OrderTable
|
||||
- S3WritePolicy:
|
||||
BucketName: !Ref BucketName
|
||||
Events:
|
||||
Event:
|
||||
Type: EventBridgeRule
|
||||
Properties:
|
||||
Pattern:
|
||||
resources: [!Ref OrderTable]
|
||||
detail-type: [EXPIRE]
|
||||
detail:
|
||||
keys:
|
||||
id:
|
||||
- prefix: BILLING
|
||||
sk:
|
||||
- suffix: SCHEDULE#AUTO_CLOSE
|
||||
|
||||
EventBillingSendEmailOnClosingFunction:
|
||||
Type: AWS::Serverless::Function
|
||||
Properties:
|
||||
Handler: events.billing.send_email_on_closing.lambda_handler
|
||||
Timeout: 26
|
||||
LoggingConfig:
|
||||
LogGroup: !Ref EventLog
|
||||
Policies:
|
||||
- DynamoDBReadPolicy:
|
||||
TableName: !Ref UserTable
|
||||
- S3ReadPolicy:
|
||||
BucketName: !Ref BucketName
|
||||
- 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:
|
||||
Event:
|
||||
Type: EventBridgeRule
|
||||
Properties:
|
||||
Pattern:
|
||||
resources: [!Ref OrderTable]
|
||||
detail-type: [MODIFY]
|
||||
detail:
|
||||
new_image:
|
||||
id:
|
||||
- prefix: BILLING
|
||||
s3_uri:
|
||||
- exists: true
|
||||
status: [CLOSED]
|
||||
old_image:
|
||||
status: [PENDING]
|
||||
|
||||
EventAppendOrgIdFunction:
|
||||
Type: AWS::Serverless::Function
|
||||
Properties:
|
||||
Handler: events.append_org_id.lambda_handler
|
||||
LoggingConfig:
|
||||
LogGroup: !Ref EventLog
|
||||
Policies:
|
||||
- DynamoDBCrudPolicy:
|
||||
TableName: !Ref UserTable
|
||||
- DynamoDBCrudPolicy:
|
||||
TableName: !Ref OrderTable
|
||||
Events:
|
||||
Event:
|
||||
Type: EventBridgeRule
|
||||
Properties:
|
||||
Pattern:
|
||||
resources: [!Ref OrderTable]
|
||||
detail-type: [INSERT]
|
||||
detail:
|
||||
new_image:
|
||||
sk: ["0"]
|
||||
cnpj:
|
||||
- exists: true
|
||||
# Post-migration: rename `tenant_id` to `org_id`
|
||||
tenant_id:
|
||||
- exists: false
|
||||
|
||||
EventAppendUserIdFunction:
|
||||
Type: AWS::Serverless::Function
|
||||
Properties:
|
||||
Handler: events.append_user_id.lambda_handler
|
||||
LoggingConfig:
|
||||
LogGroup: !Ref EventLog
|
||||
Policies:
|
||||
- DynamoDBCrudPolicy:
|
||||
TableName: !Ref UserTable
|
||||
- DynamoDBCrudPolicy:
|
||||
TableName: !Ref OrderTable
|
||||
Events:
|
||||
Event:
|
||||
Type: EventBridgeRule
|
||||
Properties:
|
||||
Pattern:
|
||||
resources: [!Ref OrderTable]
|
||||
detail-type: [INSERT]
|
||||
detail:
|
||||
new_image:
|
||||
sk: ["0"]
|
||||
cpf:
|
||||
- exists: true
|
||||
user_id:
|
||||
- exists: false
|
||||
|
||||
EventRemoveSlotsIfCanceledFunction:
|
||||
Type: AWS::Serverless::Function
|
||||
Properties:
|
||||
Handler: events.remove_slots_if_canceled.lambda_handler
|
||||
LoggingConfig:
|
||||
LogGroup: !Ref EventLog
|
||||
Policies:
|
||||
- DynamoDBWritePolicy:
|
||||
TableName: !Ref OrderTable
|
||||
- DynamoDBCrudPolicy:
|
||||
TableName: !Ref EnrollmentTable
|
||||
Events:
|
||||
Event:
|
||||
Type: EventBridgeRule
|
||||
Properties:
|
||||
Pattern:
|
||||
resources: [!Ref OrderTable]
|
||||
detail-type: [MODIFY]
|
||||
detail:
|
||||
new_image:
|
||||
sk: ["0"]
|
||||
cnpj:
|
||||
- exists: true
|
||||
status: [CANCELED, EXPIRED]
|
||||
|
||||
EventStopgapSetAsPaidFunction:
|
||||
Type: AWS::Serverless::Function
|
||||
Properties:
|
||||
Handler: events.stopgap.set_as_paid.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"]
|
||||
cnpj:
|
||||
- exists: true
|
||||
total: [0]
|
||||
status: [CREATING, PENDING]
|
||||
payment_method: [MANUAL]
|
||||
|
||||
EventStopgapRemoveSlotsFunction:
|
||||
Type: AWS::Serverless::Function
|
||||
Properties:
|
||||
Handler: events.stopgap.remove_slots.lambda_handler
|
||||
LoggingConfig:
|
||||
LogGroup: !Ref EventLog
|
||||
Policies:
|
||||
- DynamoDBReadPolicy:
|
||||
TableName: !Ref UserTable
|
||||
- DynamoDBReadPolicy:
|
||||
TableName: !Ref OrderTable
|
||||
- DynamoDBCrudPolicy:
|
||||
TableName: !Ref EnrollmentTable
|
||||
Events:
|
||||
DynamoDBEvent:
|
||||
Type: EventBridgeRule
|
||||
Properties:
|
||||
Pattern:
|
||||
resources: [!Ref OrderTable]
|
||||
detail:
|
||||
new_image:
|
||||
sk: [generated_items]
|
||||
status: [SUCCESS]
|
||||
0
orders-events/tests/__init__.py
Normal file
0
orders-events/tests/__init__.py
Normal file
76
orders-events/tests/conftest.py
Normal file
76
orders-events/tests/conftest.py
Normal file
@@ -0,0 +1,76 @@
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
import jsonlines
|
||||
import pytest
|
||||
|
||||
PYTEST_TABLE_NAME = 'pytest'
|
||||
PK = 'id'
|
||||
SK = 'sk'
|
||||
|
||||
|
||||
# https://docs.pytest.org/en/7.1.x/reference/reference.html#pytest.hookspec.pytest_configure
|
||||
def pytest_configure():
|
||||
os.environ['TZ'] = 'America/Sao_Paulo'
|
||||
os.environ['DYNAMODB_PARTITION_KEY'] = PK
|
||||
os.environ['DYNAMODB_SORT_KEY'] = SK
|
||||
os.environ['USER_TABLE'] = PYTEST_TABLE_NAME
|
||||
os.environ['COURSE_TABLE'] = PYTEST_TABLE_NAME
|
||||
os.environ['ENROLLMENT_TABLE'] = PYTEST_TABLE_NAME
|
||||
os.environ['ORDER_TABLE'] = PYTEST_TABLE_NAME
|
||||
os.environ['LOG_LEVEL'] = 'DEBUG'
|
||||
os.environ['BUCKET_NAME'] = 'saladeaula.digital'
|
||||
|
||||
|
||||
@dataclass
|
||||
class LambdaContext:
|
||||
function_name: str = 'test'
|
||||
memory_limit_in_mb: int = 128
|
||||
invoked_function_arn: str = 'arn:aws:lambda:eu-west-1:809313241:function:test'
|
||||
aws_request_id: str = '52fdfc07-2182-154f-163f-5f0f9a621d72'
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def lambda_context() -> LambdaContext:
|
||||
return LambdaContext()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dynamodb_client():
|
||||
from boto3clients import dynamodb_client as client
|
||||
|
||||
client.create_table(
|
||||
AttributeDefinitions=[
|
||||
{'AttributeName': PK, 'AttributeType': 'S'},
|
||||
{'AttributeName': SK, 'AttributeType': 'S'},
|
||||
],
|
||||
TableName=PYTEST_TABLE_NAME,
|
||||
KeySchema=[
|
||||
{'AttributeName': PK, 'KeyType': 'HASH'},
|
||||
{'AttributeName': SK, 'KeyType': 'RANGE'},
|
||||
],
|
||||
ProvisionedThroughput={
|
||||
'ReadCapacityUnits': 123,
|
||||
'WriteCapacityUnits': 123,
|
||||
},
|
||||
)
|
||||
|
||||
yield client
|
||||
|
||||
client.delete_table(TableName=PYTEST_TABLE_NAME)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def dynamodb_persistence_layer(dynamodb_client):
|
||||
from layercake.dynamodb import DynamoDBPersistenceLayer
|
||||
|
||||
return DynamoDBPersistenceLayer(PYTEST_TABLE_NAME, dynamodb_client)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def dynamodb_seeds(dynamodb_persistence_layer):
|
||||
with open('tests/seeds.jsonl', 'rb') as fp:
|
||||
reader = jsonlines.Reader(fp)
|
||||
|
||||
for line in reader.iter(type=dict, skip_invalid=True):
|
||||
dynamodb_persistence_layer.put_item(item=line)
|
||||
0
orders-events/tests/events/__init__.py
Normal file
0
orders-events/tests/events/__init__.py
Normal file
0
orders-events/tests/events/billing/__init__.py
Normal file
0
orders-events/tests/events/billing/__init__.py
Normal file
43
orders-events/tests/events/billing/test_append_enrollment.py
Normal file
43
orders-events/tests/events/billing/test_append_enrollment.py
Normal file
@@ -0,0 +1,43 @@
|
||||
from aws_lambda_powertools.utilities.typing import LambdaContext
|
||||
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair
|
||||
|
||||
import events.billing.append_enrollment as app
|
||||
|
||||
|
||||
def test_append_enrollment(
|
||||
dynamodb_seeds,
|
||||
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
|
||||
lambda_context: LambdaContext,
|
||||
):
|
||||
enrollment_id = '945e8672-1d72-45c6-b76c-ac06aa8b52ab'
|
||||
event = {
|
||||
'detail': {
|
||||
'new_image': {
|
||||
'id': enrollment_id,
|
||||
'sk': 'METADATA#SUBSCRIPTION_COVERED',
|
||||
'billing_day': 6,
|
||||
'created_at': '2025-07-23T18:09:22.785678-03:00',
|
||||
'org_id': 'cJtK9SsnJhKPyxESe7g3DG',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert app.lambda_handler(event, lambda_context) # type: ignore
|
||||
|
||||
r = dynamodb_persistence_layer.collection.query(
|
||||
KeyPair('BILLING#ORG#cJtK9SsnJhKPyxESe7g3DG', 'START#2025-05-06#END#2025-06-05')
|
||||
)
|
||||
items = r['items']
|
||||
|
||||
assert items[0]['sk'] == 'START#2025-05-06#END#2025-06-05#SCHEDULE#AUTO_CLOSE'
|
||||
assert (
|
||||
items[1]['sk']
|
||||
== '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')
|
||||
)
|
||||
)
|
||||
69
orders-events/tests/events/billing/test_cancel_enrollment.py
Normal file
69
orders-events/tests/events/billing/test_cancel_enrollment.py
Normal file
@@ -0,0 +1,69 @@
|
||||
from aws_lambda_powertools.utilities.typing import LambdaContext
|
||||
from layercake.dateutils import now
|
||||
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair
|
||||
|
||||
import events.billing.cancel_enrollment as app
|
||||
from utils import get_billing_period
|
||||
|
||||
enrollment_id = '77055ad7-03e1-4b07-98dc-a2f1a90913ba'
|
||||
event = {
|
||||
'detail': {
|
||||
'new_image': {
|
||||
'id': enrollment_id,
|
||||
'sk': '0',
|
||||
'user': {
|
||||
'id': '5OxmMjL-ujoR5IMGegQz',
|
||||
'name': 'Sérgio R Siqueira',
|
||||
},
|
||||
'course': {
|
||||
'id': '123',
|
||||
'name': 'pytest',
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def test_cancel_enrollment(
|
||||
dynamodb_seeds,
|
||||
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
|
||||
lambda_context: LambdaContext,
|
||||
):
|
||||
now_ = now()
|
||||
start_date, end_date = get_billing_period(
|
||||
billing_day=6,
|
||||
date_=now_,
|
||||
)
|
||||
pk = 'BILLING#ORG#cJtK9SsnJhKPyxESe7g3DG'
|
||||
sk = 'START#{start}#END#{end}#ENROLLMENT#{enrollment_id}'.format(
|
||||
start=start_date.isoformat(),
|
||||
end=end_date.isoformat(),
|
||||
enrollment_id=enrollment_id,
|
||||
)
|
||||
# Add up-to-date enrollment item to billing
|
||||
dynamodb_persistence_layer.put_item(
|
||||
item={
|
||||
'id': pk,
|
||||
'sk': sk,
|
||||
'unit_price': 100,
|
||||
'course': {'id': '123', 'name': 'pytest'},
|
||||
'user': {'id': '5OxmMjL-ujoR5IMGegQz', 'name': 'Sérgio R Siqueira'},
|
||||
'enrolled_at': now_,
|
||||
}
|
||||
)
|
||||
|
||||
event['detail']['new_image']['created_at'] = now_.isoformat()
|
||||
assert app.lambda_handler(event, lambda_context) # type: ignore
|
||||
|
||||
r = dynamodb_persistence_layer.collection.query(KeyPair(pk, sk))
|
||||
assert len(r['items']) == 2
|
||||
assert sum(x['unit_price'] for x in r['items']) == 0
|
||||
|
||||
|
||||
def test_cancel_old_enrollment(
|
||||
dynamodb_seeds,
|
||||
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
|
||||
lambda_context: LambdaContext,
|
||||
):
|
||||
event['detail']['new_image']['created_at'] = '2025-06-05T12:13:54.371416+00:00'
|
||||
assert not app.lambda_handler(event, lambda_context) # type: ignore
|
||||
38
orders-events/tests/events/billing/test_close_window.py
Normal file
38
orders-events/tests/events/billing/test_close_window.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from aws_lambda_powertools.utilities.typing import LambdaContext
|
||||
from layercake.dynamodb import (
|
||||
DynamoDBPersistenceLayer,
|
||||
SortKey,
|
||||
TransactKey,
|
||||
)
|
||||
|
||||
import events.billing.close_window as app
|
||||
|
||||
|
||||
def test_close_window(
|
||||
dynamodb_seeds,
|
||||
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
|
||||
lambda_context: LambdaContext,
|
||||
):
|
||||
event = {
|
||||
'detail': {
|
||||
'keys': {
|
||||
'id': 'BILLING#ORG#cJtK9SsnJhKPyxESe7g3DG',
|
||||
'sk': 'START#2025-07-01#END#2025-07-31#SCHEDULE#AUTO_CLOSE',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
assert app.lambda_handler(event, lambda_context) # type: ignore
|
||||
|
||||
r = dynamodb_persistence_layer.collection.get_items(
|
||||
TransactKey('BILLING#ORG#cJtK9SsnJhKPyxESe7g3DG')
|
||||
+ SortKey('START#2025-07-01#END#2025-07-31')
|
||||
+ SortKey('START#2025-07-01#END#2025-07-31#SCHEDULE#AUTO_CLOSE#EXECUTED'),
|
||||
flatten_top=False,
|
||||
)
|
||||
|
||||
assert 's3_uri' in r['START#2025-07-01#END#2025-07-31']
|
||||
assert 'created_at' in r['START#2025-07-01#END#2025-07-31']
|
||||
assert 'updated_at' in r['START#2025-07-01#END#2025-07-31']
|
||||
assert r['START#2025-07-01#END#2025-07-31']['status'] == 'CLOSED'
|
||||
assert 'START#2025-07-01#END#2025-07-31#SCHEDULE#AUTO_CLOSE#EXECUTED' in r
|
||||
@@ -0,0 +1,25 @@
|
||||
from aws_lambda_powertools.utilities.typing import LambdaContext
|
||||
from layercake.dynamodb import DynamoDBPersistenceLayer
|
||||
|
||||
import events.billing.send_email_on_closing as app
|
||||
|
||||
|
||||
def test_send_email_on_closing(
|
||||
monkeypatch,
|
||||
dynamodb_seeds,
|
||||
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
|
||||
lambda_context: LambdaContext,
|
||||
):
|
||||
event = {
|
||||
'detail': {
|
||||
'new_image': {
|
||||
'id': 'BILLING#ORG#cJtK9SsnJhKPyxESe7g3DG',
|
||||
'sk': 'START#2025-07-01#END#2025-07-31',
|
||||
'status': 'CLOSED',
|
||||
's3_uri': 's3://saladeaula.digital/billing/sample.pdf',
|
||||
},
|
||||
}
|
||||
}
|
||||
monkeypatch.setattr(app.sesv2_client, 'send_email', lambda *args, **kwargs: ...)
|
||||
|
||||
assert app.lambda_handler(event, lambda_context) # type: ignore
|
||||
0
orders-events/tests/events/stopgap/__init__.py
Normal file
0
orders-events/tests/events/stopgap/__init__.py
Normal file
30
orders-events/tests/events/stopgap/test_remove_slots.py
Normal file
30
orders-events/tests/events/stopgap/test_remove_slots.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from layercake.dynamodb import PartitionKey
|
||||
|
||||
import events.stopgap.remove_slots as app
|
||||
|
||||
from ...conftest import LambdaContext
|
||||
|
||||
|
||||
def test_remove_slots(
|
||||
dynamodb_seeds,
|
||||
dynamodb_persistence_layer,
|
||||
lambda_context: LambdaContext,
|
||||
):
|
||||
event = {
|
||||
'detail': {
|
||||
'new_image': {
|
||||
'id': '9omWNKymwU5U4aeun6mWzZ',
|
||||
'sk': 'generated_items',
|
||||
'create_date': '2024-07-23T20:43:37.303418-03:00',
|
||||
'status': 'SUCCESS',
|
||||
'scope': 'MILTI_USER',
|
||||
}
|
||||
},
|
||||
}
|
||||
assert app.lambda_handler(event, lambda_context) # type: ignore
|
||||
|
||||
result = dynamodb_persistence_layer.collection.query(
|
||||
PartitionKey('vacancies#cJtK9SsnJhKPyxESe7g3DG')
|
||||
)
|
||||
|
||||
assert len(result['items']) == 0
|
||||
24
orders-events/tests/events/stopgap/test_set_as_paid.py
Normal file
24
orders-events/tests/events/stopgap/test_set_as_paid.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from aws_lambda_powertools.utilities.typing import LambdaContext
|
||||
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair
|
||||
|
||||
import events.stopgap.set_as_paid as app
|
||||
|
||||
|
||||
def test_set_as_paid(
|
||||
dynamodb_seeds,
|
||||
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
|
||||
lambda_context: LambdaContext,
|
||||
):
|
||||
event = {
|
||||
'detail': {
|
||||
'new_image': {
|
||||
'id': '9omWNKymwU5U4aeun6mWzZ',
|
||||
}
|
||||
}
|
||||
}
|
||||
assert app.lambda_handler(event, lambda_context) # type: ignore
|
||||
|
||||
doc = dynamodb_persistence_layer.get_item(
|
||||
key=KeyPair('9omWNKymwU5U4aeun6mWzZ', '0'),
|
||||
)
|
||||
assert doc['status'] == 'PAID'
|
||||
27
orders-events/tests/events/test_append_org_id.py
Normal file
27
orders-events/tests/events/test_append_org_id.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from aws_lambda_powertools.utilities.typing import LambdaContext
|
||||
from layercake.dynamodb import DynamoDBPersistenceLayer, PartitionKey
|
||||
|
||||
import events.append_org_id as app
|
||||
|
||||
|
||||
def test_append_org_id(
|
||||
dynamodb_seeds,
|
||||
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
|
||||
lambda_context: LambdaContext,
|
||||
):
|
||||
event = {
|
||||
'detail': {
|
||||
'new_image': {
|
||||
'id': '9omWNKymwU5U4aeun6mWzZ',
|
||||
'cnpj': '15608435000190',
|
||||
'email': 'sergio@somosbeta.com.br',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert app.lambda_handler(event, lambda_context) # type: ignore
|
||||
|
||||
r = dynamodb_persistence_layer.collection.query(
|
||||
PartitionKey('9omWNKymwU5U4aeun6mWzZ')
|
||||
)
|
||||
assert 2 == len(r['items'])
|
||||
27
orders-events/tests/events/test_append_user_id.py
Normal file
27
orders-events/tests/events/test_append_user_id.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from aws_lambda_powertools.utilities.typing import LambdaContext
|
||||
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair
|
||||
|
||||
import events.append_user_id as app
|
||||
|
||||
|
||||
def test_append_user_id(
|
||||
dynamodb_seeds,
|
||||
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
|
||||
lambda_context: LambdaContext,
|
||||
):
|
||||
event = {
|
||||
'detail': {
|
||||
'new_image': {
|
||||
'id': '9omWNKymwU5U4aeun6mWzZ',
|
||||
'cpf': '07879819908',
|
||||
'email': 'sergio@somosbeta.com.br',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert app.lambda_handler(event, lambda_context) # type: ignore
|
||||
|
||||
r = dynamodb_persistence_layer.collection.get_item(
|
||||
KeyPair('9omWNKymwU5U4aeun6mWzZ', '0')
|
||||
)
|
||||
assert 'user_id' in r
|
||||
28
orders-events/tests/events/test_remove_slots_if_canceled.py
Normal file
28
orders-events/tests/events/test_remove_slots_if_canceled.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from aws_lambda_powertools.utilities.typing import LambdaContext
|
||||
from layercake.dynamodb import DynamoDBPersistenceLayer, PartitionKey
|
||||
|
||||
import events.remove_slots_if_canceled as app
|
||||
|
||||
|
||||
def test_remove_slots_if_canceled(
|
||||
dynamodb_seeds,
|
||||
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
|
||||
lambda_context: LambdaContext,
|
||||
):
|
||||
event = {
|
||||
'detail': {
|
||||
'new_image': {
|
||||
'id': '9omWNKymwU5U4aeun6mWzZ',
|
||||
'status': 'CANCELED',
|
||||
'tenant_id': 'cJtK9SsnJhKPyxESe7g3DG',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert app.lambda_handler(event, lambda_context) # type: ignore
|
||||
|
||||
r = dynamodb_persistence_layer.collection.query(
|
||||
PartitionKey('vacancies#cJtK9SsnJhKPyxESe7g3DG')
|
||||
)
|
||||
|
||||
assert len(r['items']) == 0
|
||||
40
orders-events/tests/seeds.jsonl
Normal file
40
orders-events/tests/seeds.jsonl
Normal file
@@ -0,0 +1,40 @@
|
||||
// Org
|
||||
{"id": "cJtK9SsnJhKPyxESe7g3DG", "sk": "metadata#payment_policy", "due_days": 90}
|
||||
{"id": "cJtK9SsnJhKPyxESe7g3DG", "sk": "metadata#billing_policy", "billing_day": 1, "payment_method": "PIX"}
|
||||
// Org admins
|
||||
{"id": "cJtK9SsnJhKPyxESe7g3DG", "sk": "admins#1234", "create_date": "2025-03-12T16:51:52.632897-03:00", "email": "sergio@somosbeta.com.br", "name": "Sérgio R Siqueira"}
|
||||
|
||||
// Orders
|
||||
{"id": "9omWNKymwU5U4aeun6mWzZ", "sk": "0", "total": 398, "status": "PENDING", "payment_method": "MANUAL", "tenant_id": "cJtK9SsnJhKPyxESe7g3DG"}
|
||||
{"id": "18f934d8-035a-4ebc-9f8b-6c84782b8c73", "sk": "0", "payment_method": "PAID"}
|
||||
{"id": "6a60d026-d383-4707-b093-b6eddea1a24e", "sk": "items", "items": [{"id": "a810dd22-56c0-4d9b-8cd2-7e2ee9c45839", "name": "pytest", "quantity": 1, "unit_price": 109}]}
|
||||
{"id": "a810dd22-56c0-4d9b-8cd2-7e2ee9c45839", "sk": "metadata#betaeducacao", "course_id": "dc1a0428-47bf-4db1-a5da-24be49c9fda6", "create_date": "2025-06-05T12:13:54.371416+00:00"}
|
||||
|
||||
// User data
|
||||
{"id": "5OxmMjL-ujoR5IMGegQz", "sk": "0", "name": "Sérgio R Siqueira"}
|
||||
{"id": "cnpj", "sk": "15608435000190", "user_id": "cJtK9SsnJhKPyxESe7g3DG"}
|
||||
{"id": "cpf", "sk": "07879819908", "user_id": "5OxmMjL-ujoR5IMGegQz"}
|
||||
{"id": "email", "sk": "sergio@somosbeta.com.br", "user_id": "5OxmMjL-ujoR5IMGegQz"}
|
||||
|
||||
// Slots
|
||||
{"id": "vacancies#cJtK9SsnJhKPyxESe7g3DG", "sk": "9omWNKymwU5U4aeun6mWzZ#1"}
|
||||
{"id": "vacancies#cJtK9SsnJhKPyxESe7g3DG", "sk": "9omWNKymwU5U4aeun6mWzZ#2"}
|
||||
{"id": "vacancies#cJtK9SsnJhKPyxESe7g3DG", "sk": "9omWNKymwU5U4aeun6mWzZ#3"}
|
||||
|
||||
// Enrollments
|
||||
{"id": "945e8672-1d72-45c6-b76c-ac06aa8b52ab", "sk": "0", "course": {"id": "123", "name": "pytest"}, "user": {"id": "5OxmMjL-ujoR5IMGegQz", "name": "Sérgio R Siqueira"}, "created_at": "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"}, "created_at": "2025-06-05T12:13:54.371416+00:00"}
|
||||
{"id": "77055ad7-03e1-4b07-98dc-a2f1a90913ba", "sk": "METADATA#SUBSCRIPTION_COVERED", "billing_day": 6, "org_id": "cJtK9SsnJhKPyxESe7g3DG"}
|
||||
{"id": "77055ad7-03e1-4b07-98dc-a2f1a90913ba", "sk": "canceled", "canceled_at": "2025-08-18T15:41:49.927856-03:00", "author": {"id": "123", "name": "Dexter Holland"}}
|
||||
|
||||
// Course
|
||||
{"id": "123", "sk": "0", "access_period": "360", "cert": {"exp_interval": 360}, "created_at": "2024-12-30T00:33:33.088916-03:00", "metadata__konviva_class_id": "194", "metadata__unit_price": 99, "name": "Direção Defensiva (08 horas)", "tenant_id": "*", "updated_at": "2025-07-24T00:00:24.639003-03:00"}
|
||||
{"id": "CUSTOM_PRICING#ORG#cJtK9SsnJhKPyxESe7g3DG", "sk": "COURSE#123", "created_at": "2025-07-24T16:10:09.304073-03:00", "unit_price": "79.2"}
|
||||
|
||||
// Billing
|
||||
{"id": "BILLING#ORG#cJtK9SsnJhKPyxESe7g3DG", "sk": "START#2025-07-01#END#2025-07-31", "created_at": "2025-07-24T15:20:52.464244-03:00", "status": "PENDING"}
|
||||
{"id": "BILLING#ORG#cJtK9SsnJhKPyxESe7g3DG", "sk": "START#2025-07-01#END#2025-07-31#ENROLLMENT#a08c94a2-7ee4-45fd-bfe7-73568c738b8b", "author": {"id": "SMEXYk5MQkKCzknJpxqr8n", "name": "Carolina Brand"}, "course": {"id": "7f7905aa-ec6d-4189-b884-50fa9b1bd0b8", "name": "NR-10 Reciclagem: 08 horas"}, "created_at": "2025-07-24T16:38:33.095216-03:00", "enrolled_at": "2025-07-24T11:26:56.975207-03:00", "unit_price": 169, "user": {"id": "iPWidwn4HsYtikiZD33smV", "name": "William da Silva Nascimento"}}
|
||||
{"id": "BILLING#ORG#cJtK9SsnJhKPyxESe7g3DG", "sk": "START#2025-07-01#END#2025-07-31#ENROLLMENT#ac09e8da-6cb2-4e31-84e7-238df2647a7a", "author": {"id": "SMEXYk5MQkKCzknJpxqr8n", "name": "Carolina Brand"}, "course": {"id": "7f7905aa-ec6d-4189-b884-50fa9b1bd0b8", "name": "NR-10 Reciclagem: 08 horas"}, "created_at": "2025-07-21T16:38:58.694031-03:00", "enrolled_at": "2025-07-21T11:26:56.913746-03:00", "unit_price": 169, "user": {"id": "ca8c9fca-b508-4842-8a48-fd5cc5632ac0", "name": "Geovane Soares De Lima"}}
|
||||
64
orders-events/tests/test_utils.py
Normal file
64
orders-events/tests/test_utils.py
Normal file
@@ -0,0 +1,64 @@
|
||||
from datetime import date
|
||||
|
||||
from utils import get_billing_period
|
||||
|
||||
|
||||
def test_get_billing_period():
|
||||
assert get_billing_period(
|
||||
billing_day=15,
|
||||
date_=date(2023, 3, 20),
|
||||
) == (date(2023, 3, 15), date(2023, 4, 14))
|
||||
|
||||
assert get_billing_period(
|
||||
billing_day=15,
|
||||
date_=date(2023, 3, 10),
|
||||
) == (date(2023, 2, 15), date(2023, 3, 14))
|
||||
|
||||
# Leap year
|
||||
assert get_billing_period(
|
||||
billing_day=30,
|
||||
date_=date(year=2028, month=2, day=1),
|
||||
) == (
|
||||
date(2028, 1, 30),
|
||||
date(2028, 2, 28),
|
||||
)
|
||||
|
||||
# Leap year
|
||||
assert get_billing_period(
|
||||
billing_day=29,
|
||||
date_=date(year=2028, month=3, day=1),
|
||||
) == (
|
||||
date(2028, 2, 29),
|
||||
date(2028, 3, 28),
|
||||
)
|
||||
|
||||
assert get_billing_period(
|
||||
billing_day=28,
|
||||
date_=date(year=2025, month=2, day=28),
|
||||
) == (
|
||||
date(2025, 2, 28),
|
||||
date(2025, 3, 27),
|
||||
)
|
||||
|
||||
assert get_billing_period(
|
||||
billing_day=5,
|
||||
date_=date(year=2025, month=12, day=1),
|
||||
) == (
|
||||
date(2025, 11, 5),
|
||||
date(2025, 12, 4),
|
||||
)
|
||||
|
||||
assert get_billing_period(billing_day=25, date_=date(2025, 12, 14)) == (
|
||||
date(2025, 11, 25),
|
||||
date(2025, 12, 24),
|
||||
)
|
||||
|
||||
assert get_billing_period(billing_day=25, date_=date(2025, 1, 14)) == (
|
||||
date(2024, 12, 25),
|
||||
date(2025, 1, 24),
|
||||
)
|
||||
|
||||
assert get_billing_period(10, date(2024, 1, 5)) == (
|
||||
date(2023, 12, 10),
|
||||
date(2024, 1, 9),
|
||||
)
|
||||
1512
orders-events/uv.lock
generated
Normal file
1512
orders-events/uv.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user