renamem orders

This commit is contained in:
2025-10-13 14:31:29 -03:00
parent 8c750e00d0
commit 466ff824dd
60 changed files with 165 additions and 59 deletions

View 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')

View 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'

Binary file not shown.

View File

View 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

View 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

View 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

View 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): ...

View 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

View 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')

View 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

View File

@@ -0,0 +1,4 @@
"""
Stopgap events. Everything here is a quick fix and should be replaced with
proper solutions.
"""

View 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

View 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

View 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