add enrollments to order
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 {})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
{
|
||||
ctx: 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 {}
|
||||
),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
2
enrollments-events/uv.lock
generated
2
enrollments-events/uv.lock
generated
@@ -683,7 +683,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "layercake"
|
||||
version = "0.13.1"
|
||||
version = "0.13.4"
|
||||
source = { directory = "../layercake" }
|
||||
dependencies = [
|
||||
{ name = "arnparse" },
|
||||
|
||||
@@ -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, \
|
||||
|
||||
@@ -67,7 +67,7 @@ Resources:
|
||||
LoggingConfig:
|
||||
LogGroup: !Ref HttpLog
|
||||
Policies:
|
||||
- DynamoDBWritePolicy:
|
||||
- DynamoDBCrudPolicy:
|
||||
TableName: !Ref OrderTable
|
||||
Events:
|
||||
Post:
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user