wip checkout

This commit is contained in:
2026-01-09 11:20:56 -03:00
parent e29e81b253
commit 823134f450
18 changed files with 290 additions and 80 deletions

View File

@@ -55,7 +55,7 @@ app.include_router(orgs.admins, prefix='/orgs')
app.include_router(orgs.billing, prefix='/orgs')
app.include_router(orgs.custom_pricing, prefix='/orgs')
app.include_router(orgs.scheduled, prefix='/orgs')
app.include_router(orgs.submission, prefix='/orgs')
app.include_router(orgs.submissions, prefix='/orgs')
app.include_router(orgs.users, prefix='/orgs')
app.include_router(orgs.batch_jobs, prefix='/orgs')

View File

@@ -12,5 +12,4 @@ BUCKET_NAME: str = os.getenv('BUCKET_NAME') # type: ignore
DEDUP_WINDOW_OFFSET_DAYS = 90
PAPERFORGE_API = 'https://paperforge.saladeaula.digital'
INTERNAL_EMAIL_DOMAIN = 'users.noreply.saladeaula.digital'

View File

@@ -1,8 +1,9 @@
from aws_lambda_powertools.event_handler.api_gateway import Router
from aws_lambda_powertools.event_handler.exceptions import (
NotFoundError,
from layercake.dynamodb import (
DynamoDBPersistenceLayer,
SortKey,
TransactKey,
)
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair
from boto3clients import dynamodb_client
from config import ORDER_TABLE
@@ -17,7 +18,12 @@ dyn = DynamoDBPersistenceLayer(ORDER_TABLE, dynamodb_client)
@router.get('/<order_id>')
def get_order(order_id: str):
return dyn.collection.get_item(
KeyPair(order_id, '0'),
exc_cls=NotFoundError,
return dyn.collection.get_items(
TransactKey(order_id)
+ SortKey('0')
+ SortKey('ITEMS')
+ SortKey('ADDRESS')
+ SortKey('PIX')
+ SortKey('NFSE')
+ SortKey('FEE'),
)

View File

@@ -1,5 +1,7 @@
import re
from datetime import date, datetime, timedelta
from decimal import Decimal
from enum import Enum
from functools import reduce
from http import HTTPStatus
from typing import Any, Literal
@@ -34,6 +36,13 @@ dyn = DynamoDBPersistenceLayer(ORDER_TABLE, dynamodb_client)
class CouponNotFoundError(NotFoundError): ...
class PaymentMethod(str, Enum):
PIX = 'PIX'
CREDIT_CARD = 'CREDIT_CARD'
BANK_SLIP = 'BANK_SLIP'
MANUAL = 'MANUAL'
class User(BaseModel):
id: UUID4 | str
name: NameStr
@@ -69,13 +78,16 @@ class Coupon(BaseModel):
class Checkout(BaseModel):
model_config = ConfigDict(str_strip_whitespace=True)
model_config = ConfigDict(
str_strip_whitespace=True,
use_enum_values=True,
)
id: UUID4 = Field(default_factory=uuid4)
name: str
email: EmailStr
address: Address
payment_method: Literal['PIX', 'CREDIT_CARD', 'BANK_SLIP', 'MANUAL']
payment_method: PaymentMethod
items: tuple[Item, ...]
enrollments: tuple[Enrollment, ...] = tuple()
coupon: Coupon | None = None
@@ -129,6 +141,12 @@ def checkout(payload: Checkout):
enrollments = payload.enrollments
coupon = payload.coupon
subtotal = _sum_items(items)
payment_method = payload.payment_method
due_date = (
_calc_due_date(now_, 3)
if payment_method == 'BANK_SLIP'
else now_ + timedelta(hours=1)
)
discount = (
_apply_discount(subtotal, coupon.amount, coupon.type) * -1
if coupon
@@ -145,9 +163,9 @@ def checkout(payload: Checkout):
'subtotal': subtotal,
'total': total,
'discount': discount,
'due_date': due_date,
# Post-migration (orders): rename `create_date` to `created_at`
'create_date': now_,
'due_date': '',
}
| ({'coupon': coupon.code} if coupon else {})
| ({'installments': payload.installments} if payload.installments else {})
@@ -176,6 +194,15 @@ def checkout(payload: Checkout):
item={
'id': order_id,
'sk': 'CREDIT_CARD',
'brand': credit_card.brand,
'last4': credit_card.last4,
'created_at': now_,
}
)
transact.put(
item={
'id': 'TRANSACTION',
'sk': order_id,
'ttl': ttl(start_dt=now_, minutes=5),
'created_at': now_,
}
@@ -208,13 +235,16 @@ def checkout(payload: Checkout):
item={
'id': order_id,
'sk': f'ENROLLMENT#{enrollment.id}',
'status': 'UNPROCESSED',
'status': 'PENDING',
'created_at': now_,
}
| enrollment.model_dump(exclude={'id'})
)
return JSONResponse(body={'id': order_id}, status_code=HTTPStatus.CREATED)
return JSONResponse(
body={'id': order_id},
status_code=HTTPStatus.CREATED,
)
def _sum_items(items: tuple[Item, ...]):
@@ -246,3 +276,20 @@ def _apply_discount(
)
return min(amount, subtotal)
def _calc_due_date(
start_date: datetime,
business_days: int,
holidays: set[date] | None = None,
) -> datetime:
holidays = holidays or set()
current_dt = start_date
while business_days > 0:
current_dt += timedelta(days=1)
if current_dt.weekday() < 5 and current_dt.date() not in holidays:
business_days -= 1
return current_dt

View File

@@ -4,7 +4,7 @@ from .admins import router as admins
from .billing import router as billing
from .custom_pricing import router as custom_pricing
from .enrollments.scheduled import router as scheduled
from .enrollments.submission import router as submission
from .enrollments.submissions import router as submissions
from .users.add import router as users
from .users.batch_jobs import router as batch_jobs
@@ -15,7 +15,7 @@ __all__ = [
'billing',
'custom_pricing',
'scheduled',
'submission',
'submissions',
'users',
'batch_jobs',
]

View File

@@ -24,7 +24,7 @@ class MemberNotFoundError(NotFoundError): ...
@router.get('/<org_id>/admins')
def get_admins(org_id: str):
def admins(org_id: str):
return dyn.collection.query(
# Post-migration: rename `admins` to `ADMIN`
KeyPair(org_id, 'admins#'),

View File

@@ -9,7 +9,7 @@ dyn = DynamoDBPersistenceLayer(COURSE_TABLE, dynamodb_client)
@router.get('/<org_id>/custom-pricing')
def get_custom_pricing(org_id: str):
def custom_pricing(org_id: str):
return dyn.collection.query(
PartitionKey(f'CUSTOM_PRICING#ORG#{org_id}'),
limit=150,

View File

@@ -19,7 +19,7 @@ def submissions(org_id: str):
)
@router.get('/<org_id>/enrollments/<submission_id>/submitted')
@router.get('/<org_id>/enrollments/submissions/<submission_id>')
def submitted(org_id: str, submission_id: str):
return dyn.collection.get_item(
KeyPair(

View File

@@ -0,0 +1,16 @@
from aws_lambda_powertools.event_handler.api_gateway import Router
from layercake.dynamodb import DynamoDBPersistenceLayer, PartitionKey
from boto3clients import dynamodb_client
from config import COURSE_TABLE
router = Router()
dyn = DynamoDBPersistenceLayer(COURSE_TABLE, dynamodb_client)
@router.get('/<org_id>/seats')
def seats(org_id: str):
return dyn.collection.query(
PartitionKey(f'SEAT#ORG#{org_id}'),
limit=150,
)

View File

@@ -1,9 +1,12 @@
import json
from datetime import date, datetime
from http import HTTPMethod, HTTPStatus
from pprint import pprint
from layercake.dynamodb import DynamoDBPersistenceLayer, PartitionKey
from routes.orders.checkout import _calc_due_date
from ...conftest import HttpApiProxy, LambdaContext
@@ -147,3 +150,33 @@ def test_checkout_from_user(
)
print(r)
assert r['statusCode'] == HTTPStatus.CREATED
def test_calc_due_date_skips_weekends_and_holidays():
start_date = datetime(2026, 1, 9, 10, 30) # Friday
business_days = 3
holidays = {
date(2026, 1, 12), # Monday (holiday)
}
result = _calc_due_date(
start_date=start_date,
business_days=business_days,
holidays=holidays,
)
# Mon (12) -> holiday (ignored)
# Tue (13) -> 1
# Wed (14) -> 2
# Thu (15) -> 3 ✅
expected = datetime(2026, 1, 15, 10, 30)
assert result == expected
def test_calc_due_date_only_weekends():
start_date = datetime(2026, 1, 8, 9, 0) # Thursday
result = _calc_due_date(start_date, 1)
assert result == datetime(2026, 1, 9, 9, 0)