add enrollments to order

This commit is contained in:
2026-01-25 20:47:21 -03:00
parent 3719842ae9
commit 0d1258f666
11 changed files with 303 additions and 64 deletions

View File

@@ -114,7 +114,7 @@ def enroll(
with processor(immediate, enroll_now, ctx) as batch: with processor(immediate, enroll_now, ctx) as batch:
now_out = batch.process() now_out = batch.process()
with processor(later, enroll_later, ctx) as batch: with processor(later, _enroll_later, ctx) as batch:
later_out = batch.process() later_out = batch.process()
def fmt(r): def fmt(r):
@@ -317,7 +317,7 @@ def enroll_now(enrollment: Enrollment, context: Context):
return enrollment return enrollment
def enroll_later(enrollment: Enrollment, context: Context): def _enroll_later(enrollment: Enrollment, context: Context):
now_ = now() now_ = now()
user = enrollment.user user = enrollment.user
course = enrollment.course course = enrollment.course

View File

@@ -14,7 +14,7 @@ from pydantic import UUID4
from api_gateway import JSONResponse from api_gateway import JSONResponse
from boto3clients import dynamodb_client from boto3clients import dynamodb_client
from config import ORDER_TABLE from config import ENROLLMENT_TABLE, ORDER_TABLE
from exceptions import ConflictError, OrderConflictError, OrderNotFoundError from exceptions import ConflictError, OrderConflictError, OrderNotFoundError
from middlewares.authentication_middleware import User as Authenticated from middlewares.authentication_middleware import User as Authenticated
@@ -48,14 +48,24 @@ def get_order(order_id: str):
if not order: if not order:
raise OrderNotFoundError('Order not found') raise OrderNotFoundError('Order not found')
org_id = order.get('org_id')
attempts = dyn.collection.query(KeyPair(order_id, 'TRANSACTION#ATTEMPT#')) attempts = dyn.collection.query(KeyPair(order_id, 'TRANSACTION#ATTEMPT#'))
enrollments = dyn.collection.query(KeyPair(order_id, 'ENROLLMENT#')) enrollments = dyn.collection.query(KeyPair(order_id, 'ENROLLMENT#'))
seats = (
dyn.collection.query(
key=KeyPair(f'SEAT#ORG#{org_id}', f'ORDER#{order_id}'),
table_name=ENROLLMENT_TABLE,
)
if org_id
else {'items': []}
)
return ( return (
order order
| { | {
'payment_attempts': attempts['items'], 'payment_attempts': attempts['items'],
'enrollments': enrollments['items'], 'enrollments': enrollments['items'],
'seats': seats['items'],
} }
# Post-migration (orders): remove the following lines # Post-migration (orders): remove the following lines
| ({'created_at': order['create_date']} if 'create_date' in order else {}) | ({'created_at': order['create_date']} if 'create_date' in order else {})

View File

@@ -356,5 +356,7 @@ def _get_settings(id: str) -> Settings:
if 'due_days' not in r: if 'due_days' not in r:
r['due_days'] = DUE_DAYS r['due_days'] = DUE_DAYS
else:
r['due_days'] = int(r['due_days'])
return cast(Settings, r) return cast(Settings, r)

View File

@@ -11,7 +11,7 @@ from pydantic import FutureDatetime
from api_gateway import JSONResponse from api_gateway import JSONResponse
from boto3clients import dynamodb_client from boto3clients import dynamodb_client
from config import ENROLLMENT_TABLE from config import ENROLLMENT_TABLE
from middlewares.authentication_middleware import User as Authenticated from routes.orgs import billing
from ...enrollments.enroll import Context, Enrollment, Org, Subscription, enroll_now from ...enrollments.enroll import Context, Enrollment, Org, Subscription, enroll_now
@@ -74,24 +74,20 @@ def proceed(
exc_cls=ScheduledNotFoundError, exc_cls=ScheduledNotFoundError,
) )
billing_day = scheduled.get('subscription_billing_day') billing_day = scheduled.get('subscription_billing_day')
ctx = cast( ctx: Context = {
Context, 'created_by': router.context['user'],
{ 'org': Org(id=org_id, name=scheduled['org_name']),
'created_by': router.context['user'], }
'org': Org(id=org_id, name=scheduled['org_name']),
**( if billing_day:
{'subscription': Subscription(billing_day=billing_day)} ctx['subscription'] = Subscription(billing_day=billing_day)
if billing_day
else {}
),
},
)
try: try:
enrollment = enroll_now( enrollment = enroll_now(
Enrollment( Enrollment(
user=scheduled['user'], user=scheduled['user'],
course=scheduled['course'], course=scheduled['course'],
seat=scheduled.get('seat'),
), ),
ctx, ctx,
) )

View File

@@ -0,0 +1,168 @@
import {
BanIcon,
CheckCircle2Icon,
CircleDashedIcon,
ClockIcon,
HelpCircleIcon,
type LucideIcon
} from 'lucide-react'
import { Abbr } from '@repo/ui/components/abbr'
import { DateTime } from '@repo/ui/components/datetime'
import { Avatar, AvatarFallback } from '@repo/ui/components/ui/avatar'
import { Badge } from '@repo/ui/components/ui/badge'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '@repo/ui/components/ui/card'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '@repo/ui/components/ui/table'
import { cn, initials } from '@repo/ui/lib/utils'
import type { Enrollment, Seat } from './route'
const dtOptions: Intl.DateTimeFormatOptions = {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
}
export function Enrollments({
enrollments,
seats
}: {
enrollments: Enrollment[]
seats: Seat[]
}) {
return (
<Card className="lg:max-w-4xl mx-auto">
<CardHeader>
<CardTitle className="text-xl">Matrículas relacionadas</CardTitle>
<CardDescription>
Acompanhe o status e os detalhes de todas as matrículas relacionadas a
esta compra.
</CardDescription>
</CardHeader>
<CardContent>
<Table className="pointer-events-none">
<TableHeader>
<TableRow>
<TableHead>Colaborador</TableHead>
<TableHead>Curso</TableHead>
<TableHead>Status</TableHead>
<TableHead>Executada em</TableHead>
<TableHead>Agendada em</TableHead>
<TableHead>Revogada em</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{enrollments.map(
(
{
user,
course,
status,
executed_at,
scheduled_at,
rollback_at
},
idx
) => {
return (
<TableRow key={idx}>
<TableCell>
<div className="flex gap-2.5 items-center">
<Avatar className="size-10 hidden lg:block">
<AvatarFallback className="border">
{initials(user.name)}
</AvatarFallback>
</Avatar>
<ul>
<li className="font-bold">
<Abbr>{user.name}</Abbr>
</li>
<li className="text-muted-foreground text-sm">
<Abbr>{user.email}</Abbr>
</li>
</ul>
</div>
</TableCell>
<TableCell>
<Abbr>{course.name}</Abbr>
</TableCell>
<TableCell>
<Status status={status} />
</TableCell>
<TableCell>
{executed_at ? (
<DateTime options={dtOptions}>{executed_at}</DateTime>
) : null}
</TableCell>
<TableCell>
{scheduled_at ? (
<DateTime options={dtOptions}>{scheduled_at}</DateTime>
) : null}
</TableCell>
<TableCell>
{rollback_at ? (
<DateTime options={dtOptions}>{rollback_at}</DateTime>
) : null}
</TableCell>
</TableRow>
)
}
)}
</TableBody>
</Table>
</CardContent>
</Card>
)
}
const statuses: Record<string, { icon: LucideIcon; color?: string }> = {
PENDING: {
icon: CircleDashedIcon,
color: 'text-blue-400 [&_svg]:text-blue-500'
},
SCHEDULED: {
icon: ClockIcon,
color: 'text-blue-400 [&_svg]:text-blue-500'
},
EXECUTED: {
icon: CheckCircle2Icon,
color: 'text-green-400 [&_svg]:text-green-500'
},
ROLLBACK: {
icon: BanIcon,
color: 'text-orange-400 [&_svg]:text-orange-500'
}
}
const labels: Record<string, string> = {
PENDING: 'Pendente',
EXECUTED: 'Executado',
SCHEDULED: 'Agendado',
ROLLBACK: 'Revogado'
}
function Status({ status: s }: { status: string }) {
const status = labels[s] ?? s
const { icon: Icon, color } = statuses?.[s] ?? { icon: HelpCircleIcon }
return (
<Badge variant="outline" className={cn(color, 'px-1.5')}>
<Icon className={cn('stroke-2', color)} />
{status}
</Badge>
)
}

View File

@@ -17,7 +17,10 @@ import { useForm } from 'react-hook-form'
import { Link, useRevalidator } from 'react-router' import { Link, useRevalidator } from 'react-router'
import { z } from 'zod' import { z } from 'zod'
import { Abbr } from '@repo/ui/components/abbr'
import { Currency } from '@repo/ui/components/currency' import { Currency } from '@repo/ui/components/currency'
import { DateTime } from '@repo/ui/components/datetime'
import { Badge } from '@repo/ui/components/ui/badge'
import { import {
Breadcrumb, Breadcrumb,
BreadcrumbItem, BreadcrumbItem,
@@ -26,34 +29,13 @@ import {
BreadcrumbPage, BreadcrumbPage,
BreadcrumbSeparator BreadcrumbSeparator
} from '@repo/ui/components/ui/breadcrumb' } from '@repo/ui/components/ui/breadcrumb'
import { Button } from '@repo/ui/components/ui/button'
import { import {
Card, Card,
CardContent, CardContent,
CardHeader, CardHeader,
CardTitle CardTitle
} from '@repo/ui/components/ui/card' } from '@repo/ui/components/ui/card'
import {
Item,
ItemActions,
ItemContent,
ItemGroup,
ItemTitle
} from '@repo/ui/components/ui/item'
import {
Table,
TableBody,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow
} from '@repo/ui/components/ui/table'
import { request as req } from '@repo/util/request'
import { Abbr } from '@repo/ui/components/abbr'
import { DateTime } from '@repo/ui/components/datetime'
import { Badge } from '@repo/ui/components/ui/badge'
import { Button } from '@repo/ui/components/ui/button'
import { import {
Dialog, Dialog,
DialogClose, DialogClose,
@@ -64,6 +46,13 @@ import {
DialogTitle, DialogTitle,
DialogTrigger DialogTrigger
} from '@repo/ui/components/ui/dialog' } from '@repo/ui/components/ui/dialog'
import {
Item,
ItemActions,
ItemContent,
ItemGroup,
ItemTitle
} from '@repo/ui/components/ui/item'
import { Kbd } from '@repo/ui/components/ui/kbd' import { Kbd } from '@repo/ui/components/ui/kbd'
import { import {
Popover, Popover,
@@ -72,12 +61,22 @@ import {
} from '@repo/ui/components/ui/popover' } from '@repo/ui/components/ui/popover'
import { Separator } from '@repo/ui/components/ui/separator' import { Separator } from '@repo/ui/components/ui/separator'
import { Spinner } from '@repo/ui/components/ui/spinner' import { Spinner } from '@repo/ui/components/ui/spinner'
import {
Table,
TableBody,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow
} from '@repo/ui/components/ui/table'
import { cn } from '@repo/ui/lib/utils' import { cn } from '@repo/ui/lib/utils'
import { import {
labels, labels,
statuses, statuses,
type Order as Order_ type Order as Order_
} from '@repo/ui/routes/orders/data' } from '@repo/ui/routes/orders/data'
import { request as req } from '@repo/util/request'
import { import {
CreditCard, CreditCard,
creditCardSchema, creditCardSchema,
@@ -85,6 +84,7 @@ import {
} from '../_.$orgid.enrollments.buy/payment' } from '../_.$orgid.enrollments.buy/payment'
import type { Address } from '../_.$orgid.enrollments.buy/review' import type { Address } from '../_.$orgid.enrollments.buy/review'
import { useWizardStore } from '../_.$orgid.enrollments.buy/store' import { useWizardStore } from '../_.$orgid.enrollments.buy/store'
import { Enrollments } from './enrollments'
export function meta() { export function meta() {
return [ return [
@@ -131,6 +131,24 @@ type Attempts = {
last4: string last4: string
} }
type Course = {
id: string
name: string
}
export type Enrollment = {
status: 'PENDING' | 'EXECUTED' | 'ROLLBACK'
user: { id: string; name: string; email: string }
course: Course
executed_at?: string
rollback_at?: string
scheduled_at?: string
}
export type Seat = {
course: Course
}
type Order = Order_ & { type Order = Order_ & {
items: Item[] items: Item[]
interest_amount: number interest_amount: number
@@ -142,6 +160,8 @@ type Order = Order_ & {
payment_attempts: Attempts[] payment_attempts: Attempts[]
credit_card?: CreditCardProps credit_card?: CreditCardProps
coupon?: string coupon?: string
enrollments?: Enrollment[]
seats?: Seat[]
installments?: number installments?: number
created_by?: User created_by?: User
invoice: Invoice invoice: Invoice
@@ -173,6 +193,8 @@ export default function Route({ loaderData: { order } }: Route.ComponentProps) {
discount, discount,
invoice, invoice,
payment_attempts = [], payment_attempts = [],
enrollments = [],
seats = [],
items = [], items = [],
subtotal subtotal
} = order } = order
@@ -185,7 +207,7 @@ export default function Route({ loaderData: { order } }: Route.ComponentProps) {
useEffect(() => { useEffect(() => {
reset() reset()
}, []) }, [])
console.log(seats)
return ( return (
<div className="space-y-2.5"> <div className="space-y-2.5">
<Breadcrumb> <Breadcrumb>
@@ -320,6 +342,10 @@ export default function Route({ loaderData: { order } }: Route.ComponentProps) {
</Table> </Table>
</CardContent> </CardContent>
</Card> </Card>
{enrollments.length > 0 ? (
<Enrollments enrollments={enrollments} seats={seats} />
) : null}
</div> </div>
) )
} }

View File

@@ -18,7 +18,7 @@ from pydantic import (
) )
from typing_extensions import NotRequired from typing_extensions import NotRequired
from config import DEDUP_WINDOW_OFFSET_DAYS, USER_TABLE from config import DEDUP_WINDOW_OFFSET_DAYS, ORDER_TABLE, USER_TABLE
class User(BaseModel): class User(BaseModel):
@@ -107,6 +107,11 @@ class SeatNotFoundError(Exception):
super().__init__('Seat required') super().__init__('Seat required')
class OrderNotFoundError(Exception):
def __init__(self, msg: str | dict):
super().__init__('Order not found')
def enroll( def enroll(
enrollment: Enrollment, enrollment: Enrollment,
*, *,
@@ -150,6 +155,26 @@ def enroll(
| ({'seat': seat} if seat else {}) | ({'seat': seat} if seat else {})
) )
if seat:
transact.condition(
key=KeyPair(str(seat['order_id']), '0'),
cond_expr='attribute_exists(sk)',
exc_cls=OrderNotFoundError,
table_name=ORDER_TABLE,
)
transact.put(
item={
'id': seat['order_id'],
'sk': f'ENROLLMENT#{enrollment.id}',
'course': course.model_dump(),
'user': user.model_dump(),
'status': 'EXECUTED',
'executed_at': now_,
'created_at': now_,
},
table_name=ORDER_TABLE,
)
# Relationships between this enrollment and its related entities # Relationships between this enrollment and its related entities
for entity in linked_entities: for entity in linked_entities:
# Parent knows the child # Parent knows the child

View File

@@ -683,7 +683,7 @@ wheels = [
[[package]] [[package]]
name = "layercake" name = "layercake"
version = "0.13.1" version = "0.13.4"
source = { directory = "../layercake" } source = { directory = "../layercake" }
dependencies = [ dependencies = [
{ name = "arnparse" }, { name = "arnparse" },

View File

@@ -118,6 +118,8 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
org_id=org_id, org_id=org_id,
) )
logger.debug('Some enrollments failed', failed=failed)
with dyn.transact_writer() as transact: with dyn.transact_writer() as transact:
for x in failed: for x in failed:
reason = _friendly_reason(x.cause['type']) # type: ignore reason = _friendly_reason(x.cause['type']) # type: ignore
@@ -163,7 +165,7 @@ def _release_seats(
) -> None: ) -> None:
now_ = now() now_ = now()
with dyn.transact_writer(table_name=ORDER_TABLE) as transact: with dyn.transact_writer(table_name=ENROLLMENT_TABLE) as transact:
for course in courses: for course in courses:
transact.put( transact.put(
item={ item={
@@ -172,7 +174,6 @@ def _release_seats(
'course': course.model_dump(), 'course': course.model_dump(),
'created_at': now_, 'created_at': now_,
}, },
table_name=ORDER_TABLE,
) )
@@ -263,6 +264,26 @@ def _enroll_now(enrollment: Enrollment, context: Context) -> None:
'seat': {'order_id': order_id}, 'seat': {'order_id': order_id},
} }
) )
# Relationships between this enrollment and its related entities
transact.put(
item={
'id': order_id,
'sk': f'LINKED_ENTITIES#CHILD#ENROLLMENT#{enrollment.id}',
'created_at': now_,
},
cond_expr='attribute_not_exists(sk)',
table_name=ORDER_TABLE,
)
# Child knows the parent
transact.put(
item={
'id': enrollment.id,
'sk': f'LINKED_ENTITIES#PARENT#ORDER#{order_id}',
'created_at': now_,
},
cond_expr='attribute_not_exists(sk)',
)
transact.update( transact.update(
key=KeyPair(order_id, f'ENROLLMENT#{enrollment.id}'), key=KeyPair(order_id, f'ENROLLMENT#{enrollment.id}'),
update_expr='SET #status = :executed, \ update_expr='SET #status = :executed, \

View File

@@ -67,7 +67,7 @@ Resources:
LoggingConfig: LoggingConfig:
LogGroup: !Ref HttpLog LogGroup: !Ref HttpLog
Policies: Policies:
- DynamoDBWritePolicy: - DynamoDBCrudPolicy:
TableName: !Ref OrderTable TableName: !Ref OrderTable
Events: Events:
Post: Post:

View File

@@ -1,10 +1,10 @@
import { import {
CheckCircle2Icon,
ClockAlertIcon,
RotateCcwIcon,
CircleXIcon,
ClockIcon,
BanIcon, BanIcon,
CheckCircle2Icon,
CircleXIcon,
ClockAlertIcon,
ClockIcon,
RotateCcwIcon,
type LucideIcon type LucideIcon
} from 'lucide-react' } from 'lucide-react'
@@ -21,39 +21,30 @@ export type Order = {
email: string email: string
} }
export const statuses: Record< export const statuses: Record<string, { icon: LucideIcon; color?: string }> = {
string,
{ icon: LucideIcon; color?: string; label: string }
> = {
PENDING: { PENDING: {
icon: ClockIcon, icon: ClockIcon,
label: 'Pendente',
color: 'text-blue-400 [&_svg]:text-blue-500' color: 'text-blue-400 [&_svg]:text-blue-500'
}, },
PAID: { PAID: {
icon: CheckCircle2Icon, icon: CheckCircle2Icon,
color: 'text-green-400 [&_svg]:text-green-500', color: 'text-green-400 [&_svg]:text-green-500'
label: 'Pago'
}, },
DECLINED: { DECLINED: {
icon: BanIcon, icon: BanIcon,
color: 'text-red-400 [&_svg]:text-red-500', color: 'text-red-400 [&_svg]:text-red-500'
label: 'Negado'
}, },
EXPIRED: { EXPIRED: {
icon: ClockAlertIcon, icon: ClockAlertIcon,
color: 'text-orange-400 [&_svg]:text-orange-500', color: 'text-orange-400 [&_svg]:text-orange-500'
label: 'Expirado'
}, },
REFUNDED: { REFUNDED: {
icon: RotateCcwIcon, icon: RotateCcwIcon,
color: 'text-orange-400 [&_svg]:text-orange-500', color: 'text-orange-400 [&_svg]:text-orange-500'
label: 'Estornado'
}, },
CANCELED: { CANCELED: {
icon: CircleXIcon, icon: CircleXIcon,
color: 'text-red-400 [&_svg]:text-red-500', color: 'text-red-400 [&_svg]:text-red-500'
label: 'Cancelado'
} }
} }