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:
now_out = batch.process()
with processor(later, enroll_later, ctx) as batch:
with processor(later, _enroll_later, ctx) as batch:
later_out = batch.process()
def fmt(r):
@@ -317,7 +317,7 @@ def enroll_now(enrollment: Enrollment, context: Context):
return enrollment
def enroll_later(enrollment: Enrollment, context: Context):
def _enroll_later(enrollment: Enrollment, context: Context):
now_ = now()
user = enrollment.user
course = enrollment.course

View File

@@ -14,7 +14,7 @@ from pydantic import UUID4
from api_gateway import JSONResponse
from boto3clients import dynamodb_client
from config import ORDER_TABLE
from config import ENROLLMENT_TABLE, ORDER_TABLE
from exceptions import ConflictError, OrderConflictError, OrderNotFoundError
from middlewares.authentication_middleware import User as Authenticated
@@ -48,14 +48,24 @@ def get_order(order_id: str):
if not order:
raise OrderNotFoundError('Order not found')
org_id = order.get('org_id')
attempts = dyn.collection.query(KeyPair(order_id, 'TRANSACTION#ATTEMPT#'))
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 (
order
| {
'payment_attempts': attempts['items'],
'enrollments': enrollments['items'],
'seats': seats['items'],
}
# Post-migration (orders): remove the following lines
| ({'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:
r['due_days'] = DUE_DAYS
else:
r['due_days'] = int(r['due_days'])
return cast(Settings, r)

View File

@@ -11,7 +11,7 @@ from pydantic import FutureDatetime
from api_gateway import JSONResponse
from boto3clients import dynamodb_client
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
@@ -74,24 +74,20 @@ def proceed(
exc_cls=ScheduledNotFoundError,
)
billing_day = scheduled.get('subscription_billing_day')
ctx = cast(
Context,
{
'created_by': router.context['user'],
'org': Org(id=org_id, name=scheduled['org_name']),
**(
{'subscription': Subscription(billing_day=billing_day)}
if billing_day
else {}
),
},
)
ctx: Context = {
'created_by': router.context['user'],
'org': Org(id=org_id, name=scheduled['org_name']),
}
if billing_day:
ctx['subscription'] = Subscription(billing_day=billing_day)
try:
enrollment = enroll_now(
Enrollment(
user=scheduled['user'],
course=scheduled['course'],
seat=scheduled.get('seat'),
),
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 { z } from 'zod'
import { Abbr } from '@repo/ui/components/abbr'
import { Currency } from '@repo/ui/components/currency'
import { DateTime } from '@repo/ui/components/datetime'
import { Badge } from '@repo/ui/components/ui/badge'
import {
Breadcrumb,
BreadcrumbItem,
@@ -26,34 +29,13 @@ import {
BreadcrumbPage,
BreadcrumbSeparator
} from '@repo/ui/components/ui/breadcrumb'
import { Button } from '@repo/ui/components/ui/button'
import {
Card,
CardContent,
CardHeader,
CardTitle
} 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 {
Dialog,
DialogClose,
@@ -64,6 +46,13 @@ import {
DialogTitle,
DialogTrigger
} 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 {
Popover,
@@ -72,12 +61,22 @@ import {
} from '@repo/ui/components/ui/popover'
import { Separator } from '@repo/ui/components/ui/separator'
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 {
labels,
statuses,
type Order as Order_
} from '@repo/ui/routes/orders/data'
import { request as req } from '@repo/util/request'
import {
CreditCard,
creditCardSchema,
@@ -85,6 +84,7 @@ import {
} from '../_.$orgid.enrollments.buy/payment'
import type { Address } from '../_.$orgid.enrollments.buy/review'
import { useWizardStore } from '../_.$orgid.enrollments.buy/store'
import { Enrollments } from './enrollments'
export function meta() {
return [
@@ -131,6 +131,24 @@ type Attempts = {
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_ & {
items: Item[]
interest_amount: number
@@ -142,6 +160,8 @@ type Order = Order_ & {
payment_attempts: Attempts[]
credit_card?: CreditCardProps
coupon?: string
enrollments?: Enrollment[]
seats?: Seat[]
installments?: number
created_by?: User
invoice: Invoice
@@ -173,6 +193,8 @@ export default function Route({ loaderData: { order } }: Route.ComponentProps) {
discount,
invoice,
payment_attempts = [],
enrollments = [],
seats = [],
items = [],
subtotal
} = order
@@ -185,7 +207,7 @@ export default function Route({ loaderData: { order } }: Route.ComponentProps) {
useEffect(() => {
reset()
}, [])
console.log(seats)
return (
<div className="space-y-2.5">
<Breadcrumb>
@@ -320,6 +342,10 @@ export default function Route({ loaderData: { order } }: Route.ComponentProps) {
</Table>
</CardContent>
</Card>
{enrollments.length > 0 ? (
<Enrollments enrollments={enrollments} seats={seats} />
) : null}
</div>
)
}

View File

@@ -18,7 +18,7 @@ from pydantic import (
)
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):
@@ -107,6 +107,11 @@ class SeatNotFoundError(Exception):
super().__init__('Seat required')
class OrderNotFoundError(Exception):
def __init__(self, msg: str | dict):
super().__init__('Order not found')
def enroll(
enrollment: Enrollment,
*,
@@ -150,6 +155,26 @@ def enroll(
| ({'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
for entity in linked_entities:
# Parent knows the child

View File

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

View File

@@ -118,6 +118,8 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
org_id=org_id,
)
logger.debug('Some enrollments failed', failed=failed)
with dyn.transact_writer() as transact:
for x in failed:
reason = _friendly_reason(x.cause['type']) # type: ignore
@@ -163,7 +165,7 @@ def _release_seats(
) -> None:
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:
transact.put(
item={
@@ -172,7 +174,6 @@ def _release_seats(
'course': course.model_dump(),
'created_at': now_,
},
table_name=ORDER_TABLE,
)
@@ -263,6 +264,26 @@ def _enroll_now(enrollment: Enrollment, context: Context) -> None:
'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(
key=KeyPair(order_id, f'ENROLLMENT#{enrollment.id}'),
update_expr='SET #status = :executed, \

View File

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

View File

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