This commit is contained in:
2026-01-21 21:31:32 -03:00
parent 26c3df876f
commit 37a9b20188
38 changed files with 1009 additions and 532 deletions

View File

@@ -1,5 +1,5 @@
from datetime import date, datetime, time, timedelta from datetime import date, datetime, time, timedelta
from typing import Annotated, TypedDict from typing import Annotated, NotRequired, TypedDict
from uuid import uuid4 from uuid import uuid4
import pytz import pytz
@@ -16,7 +16,12 @@ from pydantic import UUID4, BaseModel, EmailStr, Field, FutureDate
from boto3clients import dynamodb_client from boto3clients import dynamodb_client
from config import DEDUP_WINDOW_OFFSET_DAYS, ENROLLMENT_TABLE, TZ, USER_TABLE from config import DEDUP_WINDOW_OFFSET_DAYS, ENROLLMENT_TABLE, TZ, USER_TABLE
from exceptions import ConflictError, SubscriptionFrozenError, SubscriptionRequiredError from exceptions import (
ConflictError,
SubscriptionConflictError,
SubscriptionFrozenError,
SubscriptionRequiredError,
)
from middlewares.authentication_middleware import User as Authenticated from middlewares.authentication_middleware import User as Authenticated
logger = Logger(__name__) logger = Logger(__name__)
@@ -49,12 +54,30 @@ class Subscription(BaseModel):
billing_day: int billing_day: int
class Seat(BaseModel):
id: str = Field(..., pattern=r'^SEAT#ORG#.+$')
sk: str = Field(..., pattern=r'^ORDER#.+#ENROLLMENT#.+$')
def org_id(self) -> str:
*_, org_id = self.id.split('#')
return org_id
def order_id(self) -> str:
_, order_id, *_ = self.sk.split('#')
return order_id
def enrollment_id(self) -> str:
*_, enrollment_id = self.sk.split('#')
return enrollment_id
class Enrollment(BaseModel): class Enrollment(BaseModel):
id: UUID4 = Field(default_factory=uuid4) id: UUID4 = Field(default_factory=uuid4)
user: User user: User
course: Course course: Course
scheduled_for: FutureDate | None = None scheduled_for: FutureDate | None = None
deduplication_window: DeduplicationWindow | None = None deduplication_window: DeduplicationWindow | None = None
seat: Seat | None = None
class Org(BaseModel): class Org(BaseModel):
@@ -66,30 +89,22 @@ class Org(BaseModel):
def enroll( def enroll(
org_id: Annotated[str | UUID4, Body(embed=True)], org_id: Annotated[str | UUID4, Body(embed=True)],
enrollments: Annotated[tuple[Enrollment, ...], Body(embed=True)], enrollments: Annotated[tuple[Enrollment, ...], Body(embed=True)],
subscription: Annotated[Subscription | None, Body(embed=True)] = None,
): ):
now_ = now() now_ = now()
created_by: Authenticated = router.context['user'] created_by: Authenticated = router.context['user']
org = dyn.collection.get_items( org = dyn.collection.get_item(
KeyPair( KeyPair(
pk=str(org_id), pk=str(org_id),
sk='0', sk='0',
table_name=USER_TABLE, table_name=USER_TABLE,
) )
+ KeyPair(
pk=str(org_id),
sk='METADATA#SUBSCRIPTION',
rename_key='subscription',
table_name=USER_TABLE,
) )
)
if 'subscription' not in org:
raise SubscriptionRequiredError('Organization not subscribed')
ctx = { ctx = {
'org': Org.model_validate(org), 'org': Org.model_validate(org),
'created_by': created_by, 'created_by': created_by,
'subscription': Subscription.model_validate(org['subscription']), 'subscription': subscription,
} }
immediate = [e for e in enrollments if not e.scheduled_for] immediate = [e for e in enrollments if not e.scheduled_for]
@@ -133,8 +148,8 @@ Context = TypedDict(
'Context', 'Context',
{ {
'org': Org, 'org': Org,
'subscription': Subscription,
'created_by': Authenticated, 'created_by': Authenticated,
'subscription': NotRequired[Subscription],
}, },
) )
@@ -144,7 +159,7 @@ def enroll_now(enrollment: Enrollment, context: Context):
user = enrollment.user user = enrollment.user
course = enrollment.course course = enrollment.course
org: Org = context['org'] org: Org = context['org']
subscription: Subscription = context['subscription'] subscription: Subscription | None = context.get('subscription')
created_by: Authenticated = context['created_by'] created_by: Authenticated = context['created_by']
lock_hash = md5_hash(f'{user.id}{course.id}') lock_hash = md5_hash(f'{user.id}{course.id}')
access_expires_at = now_ + timedelta(days=course.access_period) access_expires_at = now_ + timedelta(days=course.access_period)
@@ -160,24 +175,6 @@ def enroll_now(enrollment: Enrollment, context: Context):
) )
with dyn.transact_writer() as transact: with dyn.transact_writer() as transact:
transact.condition(
key=KeyPair(
pk='SUBSCRIPTION',
sk=f'ORG#{org.id}',
),
cond_expr='attribute_exists(sk)',
exc_cls=SubscriptionRequiredError,
table_name=USER_TABLE,
)
transact.condition(
key=KeyPair(
pk='SUBSCRIPTION#FROZEN',
sk=f'ORG#{org.id}',
),
cond_expr='attribute_not_exists(sk)',
exc_cls=SubscriptionFrozenError,
table_name=USER_TABLE,
)
transact.put( transact.put(
item={ item={
'id': enrollment.id, 'id': enrollment.id,
@@ -209,15 +206,6 @@ def enroll_now(enrollment: Enrollment, context: Context):
'created_at': now_, 'created_at': now_,
} }
) )
transact.put(
item={
'id': enrollment.id,
'sk': 'METADATA#SUBSCRIPTION_COVERED',
'org_id': org.id,
'billing_day': subscription.billing_day,
'created_at': now_,
}
)
transact.put( transact.put(
item={ item={
'id': enrollment.id, 'id': enrollment.id,
@@ -248,6 +236,38 @@ def enroll_now(enrollment: Enrollment, context: Context):
exc_cls=DeduplicationConflictError, exc_cls=DeduplicationConflictError,
) )
if subscription:
transact.put(
item={
'id': enrollment.id,
'sk': 'METADATA#SUBSCRIPTION_COVERED',
'org_id': org.id,
'billing_day': subscription.billing_day,
'created_at': now_,
}
)
transact.condition(
key=KeyPair('SUBSCRIPTION', f'ORG#{org.id}'),
cond_expr='attribute_exists(sk)',
exc_cls=SubscriptionRequiredError,
table_name=USER_TABLE,
)
transact.condition(
key=KeyPair(str(org.id), 'METADATA#SUBSCRIPTION'),
cond_expr='billing_day = :billing_day',
expr_attr_values={
':billing_day': subscription.billing_day,
},
exc_cls=SubscriptionConflictError,
table_name=USER_TABLE,
)
transact.condition(
key=KeyPair('SUBSCRIPTION#FROZEN', f'ORG#{org.id}'),
cond_expr='attribute_not_exists(sk)',
exc_cls=SubscriptionFrozenError,
table_name=USER_TABLE,
)
# The deduplication window can be recalculated based on user settings. # The deduplication window can be recalculated based on user settings.
if deduplication_window: if deduplication_window:
transact.put( transact.put(
@@ -267,9 +287,9 @@ def enroll_later(enrollment: Enrollment, context: Context):
user = enrollment.user user = enrollment.user
course = enrollment.course course = enrollment.course
scheduled_for = date_to_midnight(enrollment.scheduled_for) # type: ignore scheduled_for = date_to_midnight(enrollment.scheduled_for) # type: ignore
deduplication_window = enrollment.deduplication_window dedup_window = enrollment.deduplication_window
org: Org = context['org'] org: Org = context['org']
subscription: Subscription = context['subscription'] subscription: Subscription | None = context.get('subscription')
created_by: Authenticated = context['created_by'] created_by: Authenticated = context['created_by']
lock_hash = md5_hash(f'{user.id}{course.id}') lock_hash = md5_hash(f'{user.id}{course.id}')
@@ -277,24 +297,6 @@ def enroll_later(enrollment: Enrollment, context: Context):
pk = f'SCHEDULED#ORG#{org.id}' pk = f'SCHEDULED#ORG#{org.id}'
sk = f'{scheduled_for.isoformat()}#{lock_hash}' sk = f'{scheduled_for.isoformat()}#{lock_hash}'
transact.condition(
key=KeyPair(
pk='SUBSCRIPTION',
sk=f'ORG#{org.id}',
),
cond_expr='attribute_exists(sk)',
exc_cls=SubscriptionRequiredError,
table_name=USER_TABLE,
)
transact.condition(
key=KeyPair(
pk='SUBSCRIPTION#FROZEN',
sk=f'ORG#{org.id}',
),
cond_expr='attribute_not_exists(sk)',
exc_cls=SubscriptionFrozenError,
table_name=USER_TABLE,
)
transact.put( transact.put(
item={ item={
'id': pk, 'id': pk,
@@ -306,13 +308,19 @@ def enroll_later(enrollment: Enrollment, context: Context):
'id': created_by.id, 'id': created_by.id,
'name': created_by.name, 'name': created_by.name,
}, },
'subscription_billing_day': subscription.billing_day,
'ttl': ttl(start_dt=scheduled_for), 'ttl': ttl(start_dt=scheduled_for),
'scheduled_at': now_, 'scheduled_at': now_,
} }
| ( | (
{'dedup_window_offset_days': deduplication_window.offset_days} {'dedup_window_offset_days': dedup_window.offset_days}
if deduplication_window if dedup_window
else {}
)
| (
{
'subscription_billing_day': subscription.billing_day,
}
if subscription
else {} else {}
), ),
) )
@@ -331,6 +339,28 @@ def enroll_later(enrollment: Enrollment, context: Context):
exc_cls=DeduplicationConflictError, exc_cls=DeduplicationConflictError,
) )
if subscription:
transact.condition(
key=KeyPair('SUBSCRIPTION', f'ORG#{org.id}'),
cond_expr='attribute_exists(sk)',
exc_cls=SubscriptionRequiredError,
table_name=USER_TABLE,
)
transact.condition(
key=KeyPair(str(org.id), 'METADATA#SUBSCRIPTION'),
cond_expr='billing_day = :billing_day',
expr_attr_values={
':billing_day': subscription.billing_day,
},
exc_cls=SubscriptionConflictError,
)
transact.condition(
key=KeyPair('SUBSCRIPTION#FROZEN', f'ORG#{org.id}'),
cond_expr='attribute_not_exists(sk)',
exc_cls=SubscriptionFrozenError,
table_name=USER_TABLE,
)
return enrollment return enrollment

View File

@@ -69,14 +69,14 @@ class Address(BaseModel):
class Item(BaseModel): class Item(BaseModel):
id: UUID4 id: UUID4
name: str name: str
unit_price: Decimal unit_price: Decimal = Field(..., ge=1)
quantity: int = 1 quantity: int = Field(1, ge=1)
class Coupon(BaseModel): class Coupon(BaseModel):
code: str code: str
type: Literal['PERCENT', 'FIXED'] type: Literal['PERCENT', 'FIXED']
amount: Decimal amount: Decimal = Field(..., ge=1)
class Checkout(BaseModel): class Checkout(BaseModel):

View File

@@ -20,7 +20,7 @@ dev = [
[tool.pytest.ini_options] [tool.pytest.ini_options]
pythonpath = ["app/"] pythonpath = ["app/"]
addopts = "--cov --cov-report html -v" addopts = "--cov=app --cov-report html"
[tool.ruff] [tool.ruff]
target-version = "py311" target-version = "py311"

View File

@@ -19,6 +19,9 @@ def test_enroll(
method=HTTPMethod.POST, method=HTTPMethod.POST,
body={ body={
'org_id': '2a8963fc-4694-4fe2-953a-316d1b10f1f5', 'org_id': '2a8963fc-4694-4fe2-953a-316d1b10f1f5',
'subscription': {
'billing_day': 6,
},
'enrollments': [ 'enrollments': [
{ {
'id': '44ff9ac1-a7cd-447b-a284-53cdc5929d7f', 'id': '44ff9ac1-a7cd-447b-a284-53cdc5929d7f',
@@ -64,8 +67,6 @@ def test_enroll(
submission = dynamodb_persistence_layer.get_item(KeyPair(body['id'], body['sk'])) submission = dynamodb_persistence_layer.get_item(KeyPair(body['id'], body['sk']))
assert submission['sk'] == body['sk'] assert submission['sk'] == body['sk']
print(body)
enrolled = dynamodb_persistence_layer.collection.query( enrolled = dynamodb_persistence_layer.collection.query(
PartitionKey('d0349bbe-cef3-44f7-b20e-3cb4476ab4c5') PartitionKey('d0349bbe-cef3-44f7-b20e-3cb4476ab4c5')
) )

View File

@@ -243,7 +243,9 @@ function List({ items, search }) {
<Abbr>{created_by ? created_by.name : 'N/A'}</Abbr> <Abbr>{created_by ? created_by.name : 'N/A'}</Abbr>
</TableCell> </TableCell>
<TableCell> <TableCell>
<DateTime>{enrolled_at}</DateTime> <DateTime options={{ hour: '2-digit', minute: '2-digit' }}>
{enrolled_at}
</DateTime>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Currency>{unit_price}</Currency> <Currency>{unit_price}</Currency>
@@ -281,7 +283,9 @@ function List({ items, search }) {
<Abbr>{canceled_by ? canceled_by.name : 'N/A'}</Abbr> <Abbr>{canceled_by ? canceled_by.name : 'N/A'}</Abbr>
</TableCell> </TableCell>
<TableCell> <TableCell>
<DateTime>{created_at}</DateTime> <DateTime options={{ hour: '2-digit', minute: '2-digit' }}>
{created_at}
</DateTime>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Currency>{unit_price}</Currency> <Currency>{unit_price}</Currency>

View File

@@ -1,27 +1,21 @@
import {
use,
useState,
useMemo,
forwardRef,
type InputHTMLAttributes
} from 'react'
import { useToggle } from 'ahooks' import { useToggle } from 'ahooks'
import Fuse from 'fuse.js' import Fuse from 'fuse.js'
import { import {
ChevronsUpDownIcon,
CheckIcon,
BookIcon,
ArrowDownAZIcon, ArrowDownAZIcon,
ArrowUpAZIcon ArrowUpAZIcon,
BookIcon,
CheckIcon,
ChevronsUpDownIcon
} from 'lucide-react' } from 'lucide-react'
import { cn } from '@repo/ui/lib/utils'
import { Button } from '@repo/ui/components/ui/button'
import { import {
InputGroup, forwardRef,
InputGroupAddon, use,
InputGroupInput useMemo,
} from '@repo/ui/components/ui/input-group' useState,
type InputHTMLAttributes
} from 'react'
import { Button } from '@repo/ui/components/ui/button'
import { import {
Command, Command,
CommandEmpty, CommandEmpty,
@@ -30,11 +24,17 @@ import {
CommandItem, CommandItem,
CommandList CommandList
} from '@repo/ui/components/ui/command' } from '@repo/ui/components/ui/command'
import {
InputGroup,
InputGroupAddon,
InputGroupInput
} from '@repo/ui/components/ui/input-group'
import { import {
Popover, Popover,
PopoverContent, PopoverContent,
PopoverTrigger PopoverTrigger
} from '@repo/ui/components/ui/popover' } from '@repo/ui/components/ui/popover'
import { cn } from '@repo/ui/lib/utils'
import { type Course } from './data' import { type Course } from './data'
@@ -114,7 +114,7 @@ export const CoursePicker = forwardRef<HTMLInputElement, CoursePickerProps>(
<div className="flex"> <div className="flex">
<div className="flex-1"> <div className="flex-1">
<CommandInput <CommandInput
placeholder="Curso" placeholder="Digite para pesquisar"
autoComplete="off" autoComplete="off"
onValueChange={setSearch} onValueChange={setSearch}
/> />

View File

@@ -1,5 +1,5 @@
import { z } from 'zod'
import { format } from 'date-fns' import { format } from 'date-fns'
import { z } from 'zod'
export const MAX_ITEMS = 50 export const MAX_ITEMS = 50

View File

@@ -11,7 +11,6 @@ import {
PlusIcon, PlusIcon,
Trash2Icon Trash2Icon
} from 'lucide-react' } from 'lucide-react'
import { pick } from 'ramda'
import { Fragment, use, useEffect, type ReactNode } from 'react' import { Fragment, use, useEffect, type ReactNode } from 'react'
import { Controller, useFieldArray, useForm } from 'react-hook-form' import { Controller, useFieldArray, useForm } from 'react-hook-form'
import { Link, redirect, useFetcher, useParams } from 'react-router' import { Link, redirect, useFetcher, useParams } from 'react-router'
@@ -115,13 +114,14 @@ export async function loader({ params, context, request }: Route.LoaderArgs) {
export async function action({ params, request, context }: Route.ActionArgs) { export async function action({ params, request, context }: Route.ActionArgs) {
const { orgid: org_id } = params const { orgid: org_id } = params
const { subscription } = context.get(workspaceContext)
const body = (await request.json()) as object const body = (await request.json()) as object
const r = await req({ const r = await req({
url: `enrollments`, url: `enrollments`,
headers: new Headers({ 'Content-Type': 'application/json' }), headers: new Headers({ 'Content-Type': 'application/json' }),
method: HttpMethod.POST, method: HttpMethod.POST,
body: JSON.stringify({ org_id, ...body }), body: JSON.stringify({ org_id, subscription, ...body }),
request, request,
context context
}) })
@@ -181,7 +181,10 @@ export default function Route({
'enrollments', 'enrollments',
enrolled enrolled
.filter(({ status }) => status === 'fail') .filter(({ status }) => status === 'fail')
.map(({ input_record }) => pick(['course', 'user'], input_record)) .map(({ input_record: { course, user } }) => ({
user,
course: { ...course, unit_price: 0 }
}))
) )
}, [enrolled, setValue]) }, [enrolled, setValue])

View File

@@ -1,19 +1,19 @@
import type { ControllerFieldState } from 'react-hook-form'
import { XIcon, CheckIcon, AlertTriangleIcon, UserIcon } from 'lucide-react'
import { formatCPF } from '@brazilian-utils/brazilian-utils' import { formatCPF } from '@brazilian-utils/brazilian-utils'
import { AlertTriangleIcon, CheckIcon, UserIcon, XIcon } from 'lucide-react'
import type { ControllerFieldState } from 'react-hook-form'
import { cn, initials } from '@repo/ui/lib/utils'
import { Avatar, AvatarFallback } from '@repo/ui/components/ui/avatar'
import { Abbr } from '@repo/ui/components/abbr' import { Abbr } from '@repo/ui/components/abbr'
import { Spinner } from '@repo/ui/components/ui/spinner' import { SearchFilter } from '@repo/ui/components/search-filter'
import { Avatar, AvatarFallback } from '@repo/ui/components/ui/avatar'
import { CommandItem } from '@repo/ui/components/ui/command'
import { import {
InputGroup, InputGroup,
InputGroupAddon, InputGroupAddon,
InputGroupButton, InputGroupButton,
InputGroupInput InputGroupInput
} from '@repo/ui/components/ui/input-group' } from '@repo/ui/components/ui/input-group'
import { CommandItem } from '@repo/ui/components/ui/command' import { Spinner } from '@repo/ui/components/ui/spinner'
import { SearchFilter } from '@repo/ui/components/search-filter' import { cn, initials } from '@repo/ui/lib/utils'
import type { User } from './data' import type { User } from './data'
@@ -35,6 +35,7 @@ export function UserPicker({
align="start" align="start"
onChange={onChange} onChange={onChange}
onSearch={onSearch} onSearch={onSearch}
placeholder="Digite para pesquisar"
render={({ id, name, email, cpf, onSelect, onClose }) => ( render={({ id, name, email, cpf, onSelect, onClose }) => (
<CommandItem <CommandItem
key={id} key={id}

View File

@@ -14,7 +14,7 @@ Globals:
Architectures: Architectures:
- x86_64 - x86_64
Layers: Layers:
- !Sub arn:aws:lambda:sa-east-1:336641857101:layer:layercake:104 - !Sub arn:aws:lambda:sa-east-1:336641857101:layer:layercake:106
Environment: Environment:
Variables: Variables:
TZ: America/Sao_Paulo TZ: America/Sao_Paulo

View File

@@ -9,6 +9,7 @@ from aws_lambda_powertools.event_handler.api_gateway import (
) )
from aws_lambda_powertools.logging import correlation_paths from aws_lambda_powertools.logging import correlation_paths
from aws_lambda_powertools.utilities.typing import LambdaContext from aws_lambda_powertools.utilities.typing import LambdaContext
from layercake.dateutils import now
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair
from boto3clients import dynamodb_client, s3_client from boto3clients import dynamodb_client, s3_client
@@ -53,8 +54,14 @@ def postback():
dyn.update_item( dyn.update_item(
key=KeyPair(enrollment_id, '0'), key=KeyPair(enrollment_id, '0'),
cond_expr='attribute_exists(sk)', cond_expr='attribute_exists(sk)',
update_expr='SET cert.s3_uri = :s3_uri', update_expr='SET cert.s3_uri = :s3_uri, \
expr_attr_values={':s3_uri': s3_uri}, cert.signed = :true, \
updated_at = :now',
expr_attr_values={
':s3_uri': s3_uri,
':true': True,
':now': now(),
},
) )
return Response(status_code=HTTPStatus.NO_CONTENT) return Response(status_code=HTTPStatus.NO_CONTENT)

View File

@@ -7,7 +7,9 @@ from aws_lambda_powertools.utilities.data_classes import (
event_source, event_source,
) )
from aws_lambda_powertools.utilities.typing import LambdaContext from aws_lambda_powertools.utilities.typing import LambdaContext
from layercake.dateutils import now
from layercake.dynamodb import DynamoDBPersistenceLayer from layercake.dynamodb import DynamoDBPersistenceLayer
from layercake.funcs import omit
from layercake.strutils import first_word from layercake.strutils import first_word
from boto3clients import dynamodb_client, s3_client from boto3clients import dynamodb_client, s3_client
@@ -52,7 +54,15 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
}, },
], ],
) )
logger.debug(r)
dyn.put_item(
item={
'id': new_image['id'],
'sk': 'METADATA#DOCUSEAL',
'snapshot': omit(('fields',), r),
'created_at': now(),
}
)
return True return True

View File

@@ -21,11 +21,12 @@ dyn = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client)
@logger.inject_lambda_context @logger.inject_lambda_context
def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool: def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
old_image = event.detail['old_image'] old_image = event.detail['old_image']
sk = old_image['sk']
_, lock_hash = sk.split('#')
now_ = now() now_ = now()
# Key pattern `SCHEDULED#ORG#{org_id}` # Key pattern `SCHEDULED#ORG#{org_id}`
*_, org_id = old_image['id'].split('#') *_, org_id = old_image['id'].split('#')
# Key pattern `{YYYY-MM-DD HH:MM:SS.mmmmmm}#{lock_hash}`
sk = old_image['sk']
_, lock_hash = sk.split('#')
offset_days = old_image.get('dedup_window_offset_days') offset_days = old_image.get('dedup_window_offset_days')
billing_day = old_image.get('subscription_billing_day') billing_day = old_image.get('subscription_billing_day')
created_by = old_image.get('created_by') created_by = old_image.get('created_by')

View File

@@ -1,32 +0,0 @@
# from uuid import uuid4
from aws_durable_execution_sdk_python import DurableContext, durable_execution
from aws_lambda_powertools import Logger
from aws_lambda_powertools.utilities.data_classes import (
EventBridgeEvent,
event_source,
)
# from layercake.dateutils import now
logger = Logger(__name__)
@event_source(data_class=EventBridgeEvent)
@durable_execution
def lambda_handler(event: EventBridgeEvent, context: DurableContext) -> bool:
# new_image = event.detail['new_image']
# now_ = now()
# org_id = ''
# order_id = new_image['id']
# docx = {
# 'id': f'SEAT#ORG#{org_id}',
# 'sk': f'ORDER#{order_id}#ENROLLMENT#{uuid4()}',
# 'course': {},
# 'created_at': now_,
logger.info(event)
# }
return True

View File

@@ -19,7 +19,7 @@ dev = [
[tool.pytest.ini_options] [tool.pytest.ini_options]
pythonpath = ["app/"] pythonpath = ["app/"]
addopts = "--cov --cov-report html -v" addopts = "--cov=app --cov-report html"
[tool.ruff] [tool.ruff]
target-version = "py311" target-version = "py311"

View File

@@ -1,4 +1,4 @@
AWSTemplateFormatVersion: 2010-09-09 AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31 Transform: AWS::Serverless-2016-10-31
Parameters: Parameters:
@@ -21,11 +21,11 @@ Parameters:
Globals: Globals:
Function: Function:
CodeUri: app/ CodeUri: app/
Runtime: python3.13 Runtime: python3.14
Architectures: Architectures:
- x86_64 - x86_64
Layers: Layers:
- !Sub arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:layer:layercake:104 - !Sub arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:layer:layercake:106
Environment: Environment:
Variables: Variables:
TZ: America/Sao_Paulo TZ: America/Sao_Paulo
@@ -179,8 +179,10 @@ Resources:
detail: detail:
new_image: new_image:
# Post-migration: uncomment the following lines # Post-migration: uncomment the following lines
# sk: [SLOT] # sk: [FULFILLMENT]
# mode: [STANDALONE] # status: [IN_PROGRESS]
# user_id:
# - exists: true
sk: [generated_items] sk: [generated_items]
scope: [SINGLE_USER] scope: [SINGLE_USER]
status: [PENDING] status: [PENDING]
@@ -194,7 +196,7 @@ Resources:
Policies: Policies:
- DynamoDBCrudPolicy: - DynamoDBCrudPolicy:
TableName: !Ref EnrollmentTable TableName: !Ref EnrollmentTable
- DynamoDBReadPolicy: - DynamoDBCrudPolicy:
TableName: !Ref UserTable TableName: !Ref UserTable
Events: Events:
DynamoDBEvent: DynamoDBEvent:
@@ -217,6 +219,8 @@ Resources:
Policies: Policies:
- DynamoDBCrudPolicy: - DynamoDBCrudPolicy:
TableName: !Ref EnrollmentTable TableName: !Ref EnrollmentTable
- DynamoDBCrudPolicy:
TableName: !Ref UserTable
Events: Events:
DynamoDBEvent: DynamoDBEvent:
Type: EventBridgeRule Type: EventBridgeRule
@@ -234,6 +238,7 @@ Resources:
old_image: old_image:
status: [IN_PROGRESS] status: [IN_PROGRESS]
# DEPRECATED
EventAllocateSlotsFunction: EventAllocateSlotsFunction:
Type: AWS::Serverless::Function Type: AWS::Serverless::Function
Properties: Properties:
@@ -256,38 +261,10 @@ Resources:
detail-type: [INSERT] detail-type: [INSERT]
detail: detail:
new_image: new_image:
# Post-migration: uncomment the following lines
# sk: [SLOT]
# mode: [BATCH]
sk: [generated_items] sk: [generated_items]
scope: [MULTI_USER] scope: [MULTI_USER]
status: [PENDING] status: [PENDING]
EventAllocateSeatsFunction:
Type: AWS::Serverless::Function
Properties:
Handler: events.allocate_seats.lambda_handler
LoggingConfig:
LogGroup: !Ref EventLog
Policies:
- DynamoDBCrudPolicy:
TableName: !Ref OrderTable
- DynamoDBCrudPolicy:
TableName: !Ref EnrollmentTable
- DynamoDBReadPolicy:
TableName: !Ref CourseTable
Events:
DynamoDBEvent:
Type: EventBridgeRule
Properties:
Pattern:
resources: [!Ref OrderTable]
detail-type: [INSERT]
detail:
new_image:
sk: [SEATS_ALLOCATION]
status: [PENDING]
SesPolicy: SesPolicy:
Type: AWS::IAM::ManagedPolicy Type: AWS::IAM::ManagedPolicy
Properties: Properties:
@@ -395,8 +372,7 @@ Resources:
Type: AWS::Serverless::Function Type: AWS::Serverless::Function
Properties: Properties:
Handler: events.issue_cert.lambda_handler Handler: events.issue_cert.lambda_handler
Tracing: Active Timeout: 12
Timeout: 10
LoggingConfig: LoggingConfig:
LogGroup: !Ref EventLog LogGroup: !Ref EventLog
Policies: Policies:
@@ -417,6 +393,9 @@ Resources:
sk: ['0'] sk: ['0']
new_image: new_image:
status: [COMPLETED] status: [COMPLETED]
cert:
issued_at:
- exists: false
old_image: old_image:
status: [IN_PROGRESS] status: [IN_PROGRESS]
@@ -424,11 +403,12 @@ Resources:
Type: AWS::Serverless::Function Type: AWS::Serverless::Function
Properties: Properties:
Handler: events.ask_to_sign.lambda_handler Handler: events.ask_to_sign.lambda_handler
Tracing: Active
Timeout: 12 Timeout: 12
Policies: Policies:
- S3ReadPolicy: - S3ReadPolicy:
BucketName: !Ref BucketName BucketName: !Ref BucketName
- DynamoDBCrudPolicy:
TableName: !Ref EnrollmentTable
LoggingConfig: LoggingConfig:
LogGroup: !Ref EventLog LogGroup: !Ref EventLog
Events: Events:

File diff suppressed because it is too large Load Diff

View File

@@ -26,7 +26,7 @@ Globals:
Architectures: Architectures:
- x86_64 - x86_64
Layers: Layers:
- !Sub arn:aws:lambda:sa-east-1:336641857101:layer:layercake:104 - !Sub arn:aws:lambda:sa-east-1:336641857101:layer:layercake:106
Environment: Environment:
Variables: Variables:
TZ: America/Sao_Paulo TZ: America/Sao_Paulo

View File

@@ -14,7 +14,7 @@ Globals:
Architectures: Architectures:
- x86_64 - x86_64
Layers: Layers:
- !Sub arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:layer:layercake:104 - !Sub arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:layer:layercake:106
Environment: Environment:
Variables: Variables:
TZ: America/Sao_Paulo TZ: America/Sao_Paulo

View File

@@ -20,7 +20,7 @@ Globals:
Architectures: Architectures:
- x86_64 - x86_64
Layers: Layers:
- !Sub arn:aws:lambda:sa-east-1:336641857101:layer:layercake:104 - !Sub arn:aws:lambda:sa-east-1:336641857101:layer:layercake:106
Environment: Environment:
Variables: Variables:
TZ: America/Sao_Paulo TZ: America/Sao_Paulo

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "layercake" name = "layercake"
version = "0.12.0" version = "0.13.1"
description = "Packages shared dependencies to optimize deployment and ensure consistency across functions." description = "Packages shared dependencies to optimize deployment and ensure consistency across functions."
readme = "README.md" readme = "README.md"
authors = [ authors = [
@@ -30,6 +30,8 @@ dependencies = [
"authlib>=1.6.5", "authlib>=1.6.5",
"python-calamine>=0.5.4", "python-calamine>=0.5.4",
"cloudflare>=4.3.1", "cloudflare>=4.3.1",
"aws-durable-execution-sdk-python>=1.1.1",
"pydantic-core>=2.41.5",
] ]
[dependency-groups] [dependency-groups]

View File

@@ -14,11 +14,11 @@ Resources:
CompatibleArchitectures: CompatibleArchitectures:
- x86_64 - x86_64
CompatibleRuntimes: CompatibleRuntimes:
- python3.12
- python3.13 - python3.13
- python3.14
RetentionPolicy: Delete RetentionPolicy: Delete
Metadata: Metadata:
BuildMethod: python3.13 BuildMethod: python3.14
BuildArchitecture: x86_64 BuildArchitecture: x86_64
Outputs: Outputs:

36
layercake/uv.lock generated
View File

@@ -63,6 +63,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f8/aa/5082412d1ee302e9e7d80b6949bc4d2a8fa1149aaab610c5fc24709605d6/authlib-1.6.5-py2.py3-none-any.whl", hash = "sha256:3e0e0507807f842b02175507bdee8957a1d5707fd4afb17c32fb43fee90b6e3a", size = 243608, upload-time = "2025-10-02T13:36:07.637Z" }, { url = "https://files.pythonhosted.org/packages/f8/aa/5082412d1ee302e9e7d80b6949bc4d2a8fa1149aaab610c5fc24709605d6/authlib-1.6.5-py2.py3-none-any.whl", hash = "sha256:3e0e0507807f842b02175507bdee8957a1d5707fd4afb17c32fb43fee90b6e3a", size = 243608, upload-time = "2025-10-02T13:36:07.637Z" },
] ]
[[package]]
name = "aws-durable-execution-sdk-python"
version = "1.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "boto3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/63/9d/f3646d325d6c5ce3ad143d6e916d046ea94685c17ec6991331f6a233e187/aws_durable_execution_sdk_python-1.1.1.tar.gz", hash = "sha256:3812b60a72ab5c5fd9c1c1ffeca96260a9a79910b2c1fe4cb47c758b7768b1ce", size = 266978, upload-time = "2026-01-12T23:32:16.828Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cf/98/c7ff676db3306ac59733d971167c69a9d86fef0fa410eafde270b2f80380/aws_durable_execution_sdk_python-1.1.1-py3-none-any.whl", hash = "sha256:d724cb5e59ba1dbfce9228b527daac88a24001bc00bac9e3adb6f5b79a283f11", size = 89799, upload-time = "2026-01-12T23:32:15.057Z" },
]
[[package]] [[package]]
name = "aws-encryption-sdk" name = "aws-encryption-sdk"
version = "4.0.3" version = "4.0.3"
@@ -140,16 +152,16 @@ wheels = [
[[package]] [[package]]
name = "boto3" name = "boto3"
version = "1.41.5" version = "1.42.31"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "botocore" }, { name = "botocore" },
{ name = "jmespath" }, { name = "jmespath" },
{ name = "s3transfer" }, { name = "s3transfer" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/5b/81/450cd4143864959264a3d80f9246175a20de8c1e50ec889c710eaa28cdd9/boto3-1.41.5.tar.gz", hash = "sha256:bc7806bee681dfdff2fe2b74967b107a56274f1e66ebe4d20dc8eee1ea408d17", size = 111594, upload-time = "2025-11-26T20:27:47.021Z" } sdist = { url = "https://files.pythonhosted.org/packages/a4/da/d81abc51bd35c2d2154f1caa0040843ada7df1689ea17d51c116c793b8f0/boto3-1.42.31.tar.gz", hash = "sha256:b2038fc5dbcd6746a16ada8d55fe73659b8cf95c7b6aeb63fe782e831485edaa", size = 112803, upload-time = "2026-01-20T21:04:45.292Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/3c/56/f47a80254ed4991cce9a2f6d8ae8aafbc8df1c3270e966b2927289e5a12f/boto3-1.41.5-py3-none-any.whl", hash = "sha256:bb278111bfb4c33dca8342bda49c9db7685e43debbfa00cc2a5eb854dd54b745", size = 139344, upload-time = "2025-11-26T20:27:45.571Z" }, { url = "https://files.pythonhosted.org/packages/fd/4d/2a2830424f11d575f7ca2abdff889138f212c7dd3f1dde2b2994a779a5ab/boto3-1.42.31-py3-none-any.whl", hash = "sha256:7f04b4cd7c375e4d88cc2cba3022c40805012ce8f57468b82cedb1bcd6b3a58a", size = 140572, upload-time = "2026-01-20T21:04:43.112Z" },
] ]
[[package]] [[package]]
@@ -178,16 +190,16 @@ essential = [
[[package]] [[package]]
name = "botocore" name = "botocore"
version = "1.41.5" version = "1.42.31"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "jmespath" }, { name = "jmespath" },
{ name = "python-dateutil" }, { name = "python-dateutil" },
{ name = "urllib3" }, { name = "urllib3" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/90/22/7fe08c726a2e3b11a0aef8bf177e83891c9cb2dc1809d35c9ed91a9e60e6/botocore-1.41.5.tar.gz", hash = "sha256:0367622b811597d183bfcaab4a350f0d3ede712031ce792ef183cabdee80d3bf", size = 14668152, upload-time = "2025-11-26T20:27:38.026Z" } sdist = { url = "https://files.pythonhosted.org/packages/ab/78/4fd91ed2f9d4b500680f33c714b7716fc37690083a8c8d3e94177cbc811e/botocore-1.42.31.tar.gz", hash = "sha256:62f2c31e229df625612dd4d7c72618948e4064436d71a647102f36fcddfa0f4d", size = 14895682, upload-time = "2026-01-20T21:04:32.999Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/4e/4e/21cd0b8f365449f1576f93de1ec8718ed18a7a3bc086dfbdeb79437bba7a/botocore-1.41.5-py3-none-any.whl", hash = "sha256:3fef7fcda30c82c27202d232cfdbd6782cb27f20f8e7e21b20606483e66ee73a", size = 14337008, upload-time = "2025-11-26T20:27:35.208Z" }, { url = "https://files.pythonhosted.org/packages/9c/c3/6898ecbfc140754fc90702c43e63c2b13017cac345cd3015df404cfeb3e9/botocore-1.42.31-py3-none-any.whl", hash = "sha256:021346ad57cc3018acf4a46edc1f649b9818b33c07a08674ce1c36e9edbb5859", size = 14569714, upload-time = "2026-01-20T21:04:29.495Z" },
] ]
[[package]] [[package]]
@@ -824,11 +836,12 @@ wheels = [
[[package]] [[package]]
name = "layercake" name = "layercake"
version = "0.12.0" version = "0.13.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "arnparse" }, { name = "arnparse" },
{ name = "authlib" }, { name = "authlib" },
{ name = "aws-durable-execution-sdk-python" },
{ name = "aws-lambda-powertools", extra = ["all"] }, { name = "aws-lambda-powertools", extra = ["all"] },
{ name = "cloudflare" }, { name = "cloudflare" },
{ name = "dictdiffer" }, { name = "dictdiffer" },
@@ -841,6 +854,7 @@ dependencies = [
{ name = "psycopg", extra = ["binary"] }, { name = "psycopg", extra = ["binary"] },
{ name = "pycpfcnpj" }, { name = "pycpfcnpj" },
{ name = "pydantic", extra = ["email"] }, { name = "pydantic", extra = ["email"] },
{ name = "pydantic-core" },
{ name = "pydantic-extra-types" }, { name = "pydantic-extra-types" },
{ name = "python-calamine" }, { name = "python-calamine" },
{ name = "python-multipart" }, { name = "python-multipart" },
@@ -866,6 +880,7 @@ dev = [
requires-dist = [ requires-dist = [
{ name = "arnparse", specifier = ">=0.0.2" }, { name = "arnparse", specifier = ">=0.0.2" },
{ name = "authlib", specifier = ">=1.6.5" }, { name = "authlib", specifier = ">=1.6.5" },
{ name = "aws-durable-execution-sdk-python", specifier = ">=1.1.1" },
{ name = "aws-lambda-powertools", extras = ["all"], specifier = ">=3.23.0" }, { name = "aws-lambda-powertools", extras = ["all"], specifier = ">=3.23.0" },
{ name = "cloudflare", specifier = ">=4.3.1" }, { name = "cloudflare", specifier = ">=4.3.1" },
{ name = "dictdiffer", specifier = ">=0.9.0" }, { name = "dictdiffer", specifier = ">=0.9.0" },
@@ -878,6 +893,7 @@ requires-dist = [
{ name = "psycopg", extras = ["binary"], specifier = ">=3.2.9" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.2.9" },
{ name = "pycpfcnpj", specifier = ">=1.8" }, { name = "pycpfcnpj", specifier = ">=1.8" },
{ name = "pydantic", extras = ["email"], specifier = ">=2.10.6" }, { name = "pydantic", extras = ["email"], specifier = ">=2.10.6" },
{ name = "pydantic-core", specifier = ">=2.41.5" },
{ name = "pydantic-extra-types", specifier = ">=2.10.3" }, { name = "pydantic-extra-types", specifier = ">=2.10.3" },
{ name = "python-calamine", specifier = ">=0.5.4" }, { name = "python-calamine", specifier = ">=0.5.4" },
{ name = "python-multipart", specifier = ">=0.0.20" }, { name = "python-multipart", specifier = ">=0.0.20" },
@@ -1935,14 +1951,14 @@ wheels = [
[[package]] [[package]]
name = "s3transfer" name = "s3transfer"
version = "0.15.0" version = "0.16.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "botocore" }, { name = "botocore" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/ca/bb/940d6af975948c1cc18f44545ffb219d3c35d78ec972b42ae229e8e37e08/s3transfer-0.15.0.tar.gz", hash = "sha256:d36fac8d0e3603eff9b5bfa4282c7ce6feb0301a633566153cbd0b93d11d8379", size = 152185, upload-time = "2025-11-20T20:28:56.327Z" } sdist = { url = "https://files.pythonhosted.org/packages/05/04/74127fc843314818edfa81b5540e26dd537353b123a4edc563109d8f17dd/s3transfer-0.16.0.tar.gz", hash = "sha256:8e990f13268025792229cd52fa10cb7163744bf56e719e0b9cb925ab79abf920", size = 153827, upload-time = "2025-12-01T02:30:59.114Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/5f/e1/5ef25f52973aa12a19cf4e1375d00932d7fb354ffd310487ba7d44225c1a/s3transfer-0.15.0-py3-none-any.whl", hash = "sha256:6f8bf5caa31a0865c4081186689db1b2534cef721d104eb26101de4b9d6a5852", size = 85984, upload-time = "2025-11-20T20:28:55.046Z" }, { url = "https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe", size = 86830, upload-time = "2025-12-01T02:30:57.729Z" },
] ]
[[package]] [[package]]

View File

@@ -13,6 +13,7 @@ from aws_lambda_powertools.logging import correlation_paths
from aws_lambda_powertools.utilities.typing import LambdaContext from aws_lambda_powertools.utilities.typing import LambdaContext
from layercake.dateutils import now from layercake.dateutils import now
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair
from layercake.funcs import pick
from boto3clients import dynamodb_client from boto3clients import dynamodb_client
from config import ORDER_TABLE from config import ORDER_TABLE
@@ -29,10 +30,12 @@ class OrderNotFoundError(NotFoundError): ...
class InvoiceNotFoundError(NotFoundError): ... class InvoiceNotFoundError(NotFoundError): ...
class StatusAttr(Enum): class StatusTimestampAttr(Enum):
# Post-migration (orders): uncomment the following lines # Post-migration (orders): uncomment the following 2 lines
# PAID = 'paid_at' # PAID = 'paid_at'
# EXTERNALLY_PAID = 'paid_at' # EXTERNALLY_PAID = 'paid_at'
# Post-migration (orders): remove the following 2 lines
EXTERNALLY_PAID = 'payment_date' EXTERNALLY_PAID = 'payment_date'
PAID = 'payment_date' PAID = 'payment_date'
@@ -41,19 +44,24 @@ class StatusAttr(Enum):
EXPIRED = 'expired_at' EXPIRED = 'expired_at'
def _status_attr(status: str) -> StatusAttr | None: def _timestamp_attr_for_status(status: str) -> str | None:
try: try:
return StatusAttr[status] return StatusTimestampAttr[status].value
except KeyError: except KeyError:
return None return None
def _friendly_status(status: str) -> str: def _friendly_status(status: str) -> str:
if 'status' == 'EXTERNALLY_PAID': if status == 'EXTERNALLY_PAID':
return 'PAID' return 'PAID'
return status return status
def _get_order_owner(order_id: str) -> dict:
r = dyn.get_item(KeyPair(order_id, '0'))
return pick(('user_id', 'org_id'), r)
@app.post('/<order_id>/postback') @app.post('/<order_id>/postback')
@tracer.capture_method @tracer.capture_method
def postback(order_id: str): def postback(order_id: str):
@@ -62,31 +70,33 @@ def postback(order_id: str):
now_ = now() now_ = now()
event = decoded_body['event'] event = decoded_body['event']
status = decoded_body.get('data[status]', '').upper() raw_status = decoded_body.get('data[status]', '').upper()
status_attr = _status_attr(status) status = _friendly_status(raw_status)
timestamp_attr = _timestamp_attr_for_status(raw_status)
if event != 'invoice.status_changed' or not status_attr: if event != 'invoice.status_changed' or not timestamp_attr:
return Response(status_code=HTTPStatus.NO_CONTENT) logger.debug('Event not acceptable', order_id=order_id)
return Response(status_code=HTTPStatus.NOT_ACCEPTABLE)
with dyn.transact_writer() as transact: with dyn.transact_writer() as transact:
transact.update( transact.update(
key=KeyPair(order_id, '0'), key=KeyPair(order_id, '0'),
update_expr='SET #status = :status, \ update_expr='SET #status = :status, \
#status_attr = :now, \ #ts_attr = :now, \
updated_at = :now', updated_at = :now',
cond_expr='attribute_exists(sk)', cond_expr='attribute_exists(sk)',
expr_attr_names={ expr_attr_names={
'#status': 'status', '#status': 'status',
'#status_attr': status_attr.value, '#ts_attr': timestamp_attr,
}, },
expr_attr_values={ expr_attr_values={
':status': _friendly_status(status), ':status': status,
':now': now_, ':now': now_,
}, },
exc_cls=OrderNotFoundError, exc_cls=OrderNotFoundError,
) )
if status == 'EXTERNALLY_PAID': if raw_status == 'EXTERNALLY_PAID':
transact.update( transact.update(
key=KeyPair(order_id, 'INVOICE'), key=KeyPair(order_id, 'INVOICE'),
cond_expr='attribute_exists(sk)', cond_expr='attribute_exists(sk)',
@@ -99,6 +109,51 @@ def postback(order_id: str):
exc_cls=InvoiceNotFoundError, exc_cls=InvoiceNotFoundError,
) )
if status == 'PAID':
try:
dyn.put_item(
item={
'id': order_id,
'sk': 'FULFILLMENT',
'status': 'IN_PROGRESS',
'created_at': now_,
**_get_order_owner(order_id),
},
cond_expr='attribute_not_exists(sk)',
)
except Exception:
pass
if status in ('CANCELED', 'REFUNDED'):
try:
with dyn.transact_writer() as transact:
transact.condition(
key=KeyPair(order_id, 'FULFILLMENT'),
cond_expr=(
'attribute_exists(sk) '
'AND #status <> :in_progress '
'AND #status <> :rollback'
),
expr_attr_names={
'#status': 'status',
},
expr_attr_values={
':in_progress': 'IN_PROGRESS',
':rollback': 'ROLLBACK',
},
)
transact.put(
item={
'id': order_id,
'sk': 'FULFILLMENT#ROLLBACK',
'created_at': now_,
},
cond_expr='attribute_not_exists(sk)',
)
logger.debug('Fulfillment rollback event created', order_id=order_id)
except Exception:
logger.debug('Fulfillment rollback event already exists', order_id=order_id)
return Response(status_code=HTTPStatus.NO_CONTENT) return Response(status_code=HTTPStatus.NO_CONTENT)

View File

@@ -90,6 +90,8 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
item={ item={
'id': pk, 'id': pk,
'sk': f'{sk}#SCHEDULE#AUTO_CLOSE', 'sk': f'{sk}#SCHEDULE#AUTO_CLOSE',
# Post-migration: uncomment the following line
# 'sk': f'{sk}#SCHEDULED#AUTO_CLOSE',
'ttl': ttl( 'ttl': ttl(
start_dt=datetime.combine(end_period, time()) start_dt=datetime.combine(end_period, time())
+ timedelta(days=1) + timedelta(days=1)

View File

@@ -80,10 +80,7 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
cond_expr='attribute_not_exists(sk)', cond_expr='attribute_not_exists(sk)',
) )
except Exception as exc: except Exception as exc:
logger.exception( logger.exception(exc, keypair={'id': pk, 'sk': sk})
exc,
keypair={'id': pk, 'sk': sk},
)
return False return False
else: else:
return True return True

View File

@@ -107,8 +107,8 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
transact.put( transact.put(
item={ item={
'id': order_id, 'id': order_id,
'sk': 'SCHEDULE#SELF_DESTRUCTION', 'sk': 'SCHEDULED#SELF_DESTRUCTION',
'ttl': ttl(start_dt=now_, days=14), 'ttl': ttl(start_dt=now_, days=7),
'created_at': now_, 'created_at': now_,
} }
) )

View File

@@ -23,7 +23,11 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
order_id = keys['id'] order_id = keys['id']
r = dyn.collection.query(PartitionKey(order_id), limit=150) r = dyn.collection.query(PartitionKey(order_id), limit=150)
logger.info('Records found', total_items=len(r['items']), records=r['items']) logger.info(
msg='Records found',
total_items=len(r['items']),
records=r['items'],
)
with dyn.batch_writer() as batch: with dyn.batch_writer() as batch:
for pair in r['items']: for pair in r['items']:

View File

@@ -0,0 +1,78 @@
import pprint
from dataclasses import asdict, dataclass
from uuid import uuid4
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, KeyChain, KeyPair
from boto3clients import dynamodb_client
from config import ENROLLMENT_TABLE
logger = Logger(__name__)
dyn = DynamoDBPersistenceLayer(ENROLLMENT_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']
enrollments = dyn.collection.query(
key=KeyPair(order_id, 'ENROLLMENT#'),
).get('items', [])
if not enrollments:
items = dyn.collection.get_item(
KeyPair(order_id, 'ITEMS'),
raise_on_error=False,
default=[],
)
pprint.pp(items)
# docx = {
# 'id': f'SEAT#ORG#{org_id}',
# 'sk': f'ORDER#{order_id}#ENROLLMENT#{uuid4()}',
# 'course': {},
# 'created_at': now_,
# }
pprint.pp(enrollments)
return True
# Se houver matriculas
# -> com: scheduled_for
# -> tenta agendar, se não joga para vagas
# -> tenta matriculas, se falhar, joga para vagas
# se não houver vagas, gera as vagas.
@dataclass(frozen=True)
class Course:
id: str
name: str
access_period: int
def _get_courses(ids: set) -> tuple[Course, ...]:
pairs = tuple(KeyPair(idx, '0') for idx in ids)
r = dyn.collection.get_items(
KeyChain(pairs),
flatten_top=False,
)
courses = tuple(
Course(
id=idx,
name=obj['name'],
access_period=obj['access_period'],
)
for idx, obj in r.items()
)
return courses

View File

@@ -17,7 +17,7 @@ dev = [
[tool.pytest.ini_options] [tool.pytest.ini_options]
pythonpath = ["app/"] pythonpath = ["app/"]
addopts = "--cov --cov-report html -v" addopts = "--cov=app --cov-report html"
[tool.ruff] [tool.ruff]
target-version = "py311" target-version = "py311"

View File

@@ -21,12 +21,12 @@ Parameters:
Globals: Globals:
Function: Function:
CodeUri: app/ CodeUri: app/
Runtime: python3.13 Runtime: python3.14
Tracing: Active Tracing: Active
Architectures: Architectures:
- x86_64 - x86_64
Layers: Layers:
- !Sub arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:layer:layercake:104 - !Sub arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:layer:layercake:106
Environment: Environment:
Variables: Variables:
TZ: America/Sao_Paulo TZ: America/Sao_Paulo
@@ -189,7 +189,7 @@ Resources:
Type: AWS::Serverless::Function Type: AWS::Serverless::Function
Properties: Properties:
Handler: events.billing.close_window.lambda_handler Handler: events.billing.close_window.lambda_handler
Timeout: 12 Timeout: 30
LoggingConfig: LoggingConfig:
LogGroup: !Ref EventLog LogGroup: !Ref EventLog
Policies: Policies:
@@ -209,6 +209,8 @@ Resources:
id: id:
- prefix: BILLING - prefix: BILLING
sk: sk:
# Post-migration: uncomment the following line
# - suffix: SCHEDULED#AUTO_CLOSE
- suffix: SCHEDULE#AUTO_CLOSE - suffix: SCHEDULE#AUTO_CLOSE
EventBillingSendEmailOnClosingFunction: EventBillingSendEmailOnClosingFunction:
@@ -247,10 +249,39 @@ Resources:
old_image: old_image:
status: [PENDING] status: [PENDING]
EventStartFulfillmentFunction:
Type: AWS::Serverless::Function
Properties:
Handler: events.start_fulfillment.lambda_handler
Timeout: 30
LoggingConfig:
LogGroup: !Ref EventLog
Policies:
- DynamoDBCrudPolicy:
TableName: !Ref OrderTable
- DynamoDBCrudPolicy:
TableName: !Ref EnrollmentTable
- DynamoDBReadPolicy:
TableName: !Ref CourseTable
Events:
DynamoDBEvent:
Type: EventBridgeRule
Properties:
Pattern:
resources: [!Ref OrderTable]
detail-type: [INSERT]
detail:
new_image:
sk: [FULFILLMENT]
status: [IN_PROGRESS]
org_id:
- exists: true
EventRunSelfDestructionFunction: EventRunSelfDestructionFunction:
Type: AWS::Serverless::Function Type: AWS::Serverless::Function
Properties: Properties:
Handler: events.run_self_destruction.lambda_handler Handler: events.run_self_destruction.lambda_handler
Timeout: 30
LoggingConfig: LoggingConfig:
LogGroup: !Ref EventLog LogGroup: !Ref EventLog
Policies: Policies:
@@ -265,7 +296,7 @@ Resources:
detail-type: [EXPIRE] detail-type: [EXPIRE]
detail: detail:
keys: keys:
sk: ['SCHEDULE#SELF_DESTRUCTION'] sk: ['SCHEDULED#SELF_DESTRUCTION']
# DEPRECATED # DEPRECATED
EventAppendOrgIdFunction: EventAppendOrgIdFunction:
@@ -322,6 +353,7 @@ Resources:
user_id: user_id:
- exists: false - exists: false
# DEPRECATED
EventRemoveSlotsIfCanceledFunction: EventRemoveSlotsIfCanceledFunction:
Type: AWS::Serverless::Function Type: AWS::Serverless::Function
Properties: Properties:

View File

@@ -2,6 +2,7 @@ import base64
import json import json
import os import os
from dataclasses import dataclass from dataclasses import dataclass
from decimal import Decimal
from http import HTTPMethod from http import HTTPMethod
from urllib.parse import urlencode from urllib.parse import urlencode
@@ -155,7 +156,10 @@ def dynamodb_persistence_layer(dynamodb_client):
@pytest.fixture() @pytest.fixture()
def dynamodb_seeds(dynamodb_persistence_layer): def dynamodb_seeds(dynamodb_persistence_layer):
with open('tests/seeds.jsonl', 'rb') as fp: with open('tests/seeds.jsonl', 'rb') as fp:
reader = jsonlines.Reader(fp) reader = jsonlines.Reader(
fp,
loads=lambda s: json.loads(s, parse_float=Decimal), # type: ignore
)
for line in reader.iter(type=dict, skip_invalid=True): for line in reader.iter(type=dict, skip_invalid=True):
dynamodb_persistence_layer.put_item(item=line) dynamodb_persistence_layer.put_item(item=line)

View File

@@ -0,0 +1,42 @@
from aws_lambda_powertools.utilities.typing import LambdaContext
from layercake.dynamodb import DynamoDBPersistenceLayer
import events.start_fulfillment as app
def test_fulfillment_enrollments(
dynamodb_seeds,
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
lambda_context: LambdaContext,
):
event = {
'detail': {
'new_image': {
'id': '9b9441d2-4ae3-4b50-8cb6-71e872d4492a',
'sk': 'FULFILLMENT',
'org_id': 'cJtK9SsnJhKPyxESe7g3DG',
'status': 'IN_PROGRESS',
}
}
}
assert app.lambda_handler(event, lambda_context) # type: ignore
def test_fulfillment_items(
dynamodb_seeds,
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
lambda_context: LambdaContext,
):
event = {
'detail': {
'new_image': {
'id': '9f7fa055-7c0b-418a-b023-77477d1895b9',
'sk': 'FULFILLMENT',
'org_id': 'cJtK9SsnJhKPyxESe7g3DG',
'status': 'IN_PROGRESS',
}
}
}
assert app.lambda_handler(event, lambda_context) # type: ignore

View File

@@ -10,9 +10,18 @@
{"id": "6a60d026-d383-4707-b093-b6eddea1a24e", "sk": "ITEMS", "items": [{"id": "a810dd22-56c0-4d9b-8cd2-7e2ee9c45839", "name": "pytest", "quantity": 1, "unit_price": 109}]} {"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"} {"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"}
{"id": "2849f1d5-f4f1-411e-8497-ec3a40afc0ab", "sk": "0" "payment_method": "BANK_SLIP", "status": "PENDING", "total": 178.2, "due_date": "", "email": "org+15608435000190@users.noreply.saladeaula.digital", "name": "Beta Educação", "coupon": "10OFF", "discount": -19.8, "create_date": "2026-01-07T19:09:54.193859-03:00", "updated_at": "2026-01-07T19:09:54.871374-03:00", "org_id": "cJtK9SsnJhKPyxESe7g3DG", "subtotal": 198, "tenant_id": "cJtK9SsnJhKPyxESe7g3DG", "cnpj": "15608435000190"} {"id": "2849f1d5-f4f1-411e-8497-ec3a40afc0ab", "sk": "0", "payment_method": "BANK_SLIP", "status": "PENDING", "total": 178.2, "due_date": "", "email": "org+15608435000190@users.noreply.saladeaula.digital", "name": "Beta Educação", "coupon": "10OFF", "discount": -19.8, "create_date": "2026-01-07T19:09:54.193859-03:00", "updated_at": "2026-01-07T19:09:54.871374-03:00", "org_id": "cJtK9SsnJhKPyxESe7g3DG", "subtotal": 198, "tenant_id": "cJtK9SsnJhKPyxESe7g3DG", "cnpj": "15608435000190"}
{"id": "2849f1d5-f4f1-411e-8497-ec3a40afc0ab", "sk": "ITEMS", "items": [ { "name": "CIPA Grau de Risco 1", "id": "3c27ea9c-9464-46a1-9717-8c1441793186", "quantity": 1, "unit_price": 99 }, { "name": "CIPA Grau de Risco 2", "id": "99bb3b60-4ded-4a8e-937c-ba2d78ec6454", "quantity": 1, "unit_price": 99 } ], "created_at": "2026-01-07T19:09:54.193859-03:00"} {"id": "2849f1d5-f4f1-411e-8497-ec3a40afc0ab", "sk": "ITEMS", "items": [ { "name": "CIPA Grau de Risco 1", "id": "3c27ea9c-9464-46a1-9717-8c1441793186", "quantity": 1, "unit_price": 99 }, { "name": "CIPA Grau de Risco 2", "id": "99bb3b60-4ded-4a8e-937c-ba2d78ec6454", "quantity": 1, "unit_price": 99 } ], "created_at": "2026-01-07T19:09:54.193859-03:00"}
{"id": "2849f1d5-f4f1-411e-8497-ec3a40afc0ab", "sk": "ADDRESS", "city": "São José", "postcode": "88101001", "state": "SC", "created_at": "2026-01-07T19:09:54.193859-03:00", "address1": "Avenida Presidente Kennedy" "address2": "", "neighborhood": "Campinas"} {"id": "2849f1d5-f4f1-411e-8497-ec3a40afc0ab", "sk": "ADDRESS", "city": "São José", "postcode": "88101001", "state": "SC", "created_at": "2026-01-07T19:09:54.193859-03:00", "address1": "Avenida Presidente Kennedy", "address2": "", "neighborhood": "Campinas"}
// Seeds for Order
// file: tests/events/test_start_fulfillment.py
{"id": "9b9441d2-4ae3-4b50-8cb6-71e872d4492a", "sk": "ITEMS", "items": [ { "name": "Combate a Incêndio", "id": "4866c068-577a-45b0-b41a-41a7dc6b9ab7", "quantity": 2, "unit_price": 99 } ], "created_at": "2026-01-20T13:05:52.737256-03:00"}
{"id": "9b9441d2-4ae3-4b50-8cb6-71e872d4492a", "sk": "ENROLLMENT#7d3f5457-8533-4f27-a0a4-ffa209a93f7d", "course": { "name": "Combate a Incêndio", "id": "4866c068-577a-45b0-b41a-41a7dc6b9ab7", "access_period": 365 }, "user": { "name": "Maitê L Siqueira", "cpf": "02186829991", "id": "87606a7f-de56-4198-a91d-b6967499d382", "email": "osergiosiqueira+maite@gmail.com" }, "created_at": "2026-01-20T13:05:52.737256-03:00", "status": "PENDING"}
{"id": "9b9441d2-4ae3-4b50-8cb6-71e872d4492a", "sk": "ENROLLMENT#9576855e-b259-4f3e-8315-1612a5cb8c36", "user": { "name": "Sérgio Rafael de Siqueira", "cpf": "07879819908", "id": "5OxmMjL-ujoR5IMGegQz", "email": "sergio@somosbeta.com.br" }, "course": { "name": "Combate a Incêndio", "id": "4866c068-577a-45b0-b41a-41a7dc6b9ab7", "access_period": 365 }, "created_at": "2026-01-20T13:05:52.737256-03:00", "status": "PENDING"}
// Seeds for Order
{"id": "9f7fa055-7c0b-418a-b023-77477d1895b9", "sk": "ITEMS", "items": [ { "name": "Combate a Incêndio", "id": "4866c068-577a-45b0-b41a-41a7dc6b9ab7", "quantity": 2, "unit_price": 99 } ], "created_at": "2026-01-20T13:05:52.737256-03:00"}
// Seeds for Iugu // Seeds for Iugu
// file: tests/test_app.py // file: tests/test_app.py

View File

@@ -1,6 +1,6 @@
from http import HTTPMethod, HTTPStatus from http import HTTPMethod, HTTPStatus
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair from layercake.dynamodb import DynamoDBPersistenceLayer, SortKey, TransactKey
from .conftest import HttpApiProxy, LambdaContext from .conftest import HttpApiProxy, LambdaContext
@@ -36,6 +36,11 @@ def test_postback(
) )
assert r['statusCode'] == HTTPStatus.NO_CONTENT assert r['statusCode'] == HTTPStatus.NO_CONTENT
order = dynamodb_persistence_layer.get_item(KeyPair(order_id, '0')) order = dynamodb_persistence_layer.collection.get_items(
TransactKey(order_id)
+ SortKey('0')
+ SortKey('FULFILLMENT', rename_key='fulfillment')
)
assert 'fulfillment' in order
assert order['status'] == 'PAID' assert order['status'] == 'PAID'

18
orders-events/uv.lock generated
View File

@@ -62,6 +62,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/51/321e821856452f7386c4e9df866f196720b1ad0c5ea1623ea7399969ae3b/authlib-1.6.6-py2.py3-none-any.whl", hash = "sha256:7d9e9bc535c13974313a87f53e8430eb6ea3d1cf6ae4f6efcd793f2e949143fd", size = 244005, upload-time = "2025-12-12T08:01:40.209Z" }, { url = "https://files.pythonhosted.org/packages/54/51/321e821856452f7386c4e9df866f196720b1ad0c5ea1623ea7399969ae3b/authlib-1.6.6-py2.py3-none-any.whl", hash = "sha256:7d9e9bc535c13974313a87f53e8430eb6ea3d1cf6ae4f6efcd793f2e949143fd", size = 244005, upload-time = "2025-12-12T08:01:40.209Z" },
] ]
[[package]]
name = "aws-durable-execution-sdk-python"
version = "1.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "boto3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/63/9d/f3646d325d6c5ce3ad143d6e916d046ea94685c17ec6991331f6a233e187/aws_durable_execution_sdk_python-1.1.1.tar.gz", hash = "sha256:3812b60a72ab5c5fd9c1c1ffeca96260a9a79910b2c1fe4cb47c758b7768b1ce", size = 266978, upload-time = "2026-01-12T23:32:16.828Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cf/98/c7ff676db3306ac59733d971167c69a9d86fef0fa410eafde270b2f80380/aws_durable_execution_sdk_python-1.1.1-py3-none-any.whl", hash = "sha256:d724cb5e59ba1dbfce9228b527daac88a24001bc00bac9e3adb6f5b79a283f11", size = 89799, upload-time = "2026-01-12T23:32:15.057Z" },
]
[[package]] [[package]]
name = "aws-encryption-sdk" name = "aws-encryption-sdk"
version = "4.0.3" version = "4.0.3"
@@ -746,11 +758,12 @@ wheels = [
[[package]] [[package]]
name = "layercake" name = "layercake"
version = "0.12.0" version = "0.13.1"
source = { directory = "../layercake" } source = { directory = "../layercake" }
dependencies = [ dependencies = [
{ name = "arnparse" }, { name = "arnparse" },
{ name = "authlib" }, { name = "authlib" },
{ name = "aws-durable-execution-sdk-python" },
{ name = "aws-lambda-powertools", extra = ["all"] }, { name = "aws-lambda-powertools", extra = ["all"] },
{ name = "cloudflare" }, { name = "cloudflare" },
{ name = "dictdiffer" }, { name = "dictdiffer" },
@@ -763,6 +776,7 @@ dependencies = [
{ name = "psycopg", extra = ["binary"] }, { name = "psycopg", extra = ["binary"] },
{ name = "pycpfcnpj" }, { name = "pycpfcnpj" },
{ name = "pydantic", extra = ["email"] }, { name = "pydantic", extra = ["email"] },
{ name = "pydantic-core" },
{ name = "pydantic-extra-types" }, { name = "pydantic-extra-types" },
{ name = "python-calamine" }, { name = "python-calamine" },
{ name = "python-multipart" }, { name = "python-multipart" },
@@ -777,6 +791,7 @@ dependencies = [
requires-dist = [ requires-dist = [
{ name = "arnparse", specifier = ">=0.0.2" }, { name = "arnparse", specifier = ">=0.0.2" },
{ name = "authlib", specifier = ">=1.6.5" }, { name = "authlib", specifier = ">=1.6.5" },
{ name = "aws-durable-execution-sdk-python", specifier = ">=1.1.1" },
{ name = "aws-lambda-powertools", extras = ["all"], specifier = ">=3.23.0" }, { name = "aws-lambda-powertools", extras = ["all"], specifier = ">=3.23.0" },
{ name = "cloudflare", specifier = ">=4.3.1" }, { name = "cloudflare", specifier = ">=4.3.1" },
{ name = "dictdiffer", specifier = ">=0.9.0" }, { name = "dictdiffer", specifier = ">=0.9.0" },
@@ -789,6 +804,7 @@ requires-dist = [
{ name = "psycopg", extras = ["binary"], specifier = ">=3.2.9" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.2.9" },
{ name = "pycpfcnpj", specifier = ">=1.8" }, { name = "pycpfcnpj", specifier = ">=1.8" },
{ name = "pydantic", extras = ["email"], specifier = ">=2.10.6" }, { name = "pydantic", extras = ["email"], specifier = ">=2.10.6" },
{ name = "pydantic-core", specifier = ">=2.41.5" },
{ name = "pydantic-extra-types", specifier = ">=2.10.3" }, { name = "pydantic-extra-types", specifier = ">=2.10.3" },
{ name = "python-calamine", specifier = ">=0.5.4" }, { name = "python-calamine", specifier = ">=0.5.4" },
{ name = "python-multipart", specifier = ">=0.0.20" }, { name = "python-multipart", specifier = ">=0.0.20" },

View File

@@ -8,7 +8,7 @@ Globals:
Architectures: Architectures:
- x86_64 - x86_64
Layers: Layers:
- !Sub arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:layer:layercake:104 - !Sub arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:layer:layercake:106
Environment: Environment:
Variables: Variables:
LOG_LEVEL: DEBUG LOG_LEVEL: DEBUG

View File

@@ -17,7 +17,7 @@ Globals:
Architectures: Architectures:
- x86_64 - x86_64
Layers: Layers:
- !Sub arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:layer:layercake:104 - !Sub arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:layer:layercake:106
Environment: Environment:
Variables: Variables:
TZ: America/Sao_Paulo TZ: America/Sao_Paulo