diff --git a/api.saladeaula.digital/app/routes/enrollments/enroll.py b/api.saladeaula.digital/app/routes/enrollments/enroll.py index 778df60..f1d8298 100644 --- a/api.saladeaula.digital/app/routes/enrollments/enroll.py +++ b/api.saladeaula.digital/app/routes/enrollments/enroll.py @@ -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 diff --git a/api.saladeaula.digital/app/routes/orders/__init__.py b/api.saladeaula.digital/app/routes/orders/__init__.py index d1f18f3..2622b8e 100644 --- a/api.saladeaula.digital/app/routes/orders/__init__.py +++ b/api.saladeaula.digital/app/routes/orders/__init__.py @@ -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 {}) diff --git a/api.saladeaula.digital/app/routes/orders/checkout.py b/api.saladeaula.digital/app/routes/orders/checkout.py index 0c76719..0c5a6dd 100644 --- a/api.saladeaula.digital/app/routes/orders/checkout.py +++ b/api.saladeaula.digital/app/routes/orders/checkout.py @@ -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) diff --git a/api.saladeaula.digital/app/routes/orgs/enrollments/scheduled.py b/api.saladeaula.digital/app/routes/orgs/enrollments/scheduled.py index 99e0df1..be0fd0e 100644 --- a/api.saladeaula.digital/app/routes/orgs/enrollments/scheduled.py +++ b/api.saladeaula.digital/app/routes/orgs/enrollments/scheduled.py @@ -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, ) diff --git a/apps/admin.saladeaula.digital/app/routes/_.$orgid.payments.$id._index/enrollments.tsx b/apps/admin.saladeaula.digital/app/routes/_.$orgid.payments.$id._index/enrollments.tsx new file mode 100644 index 0000000..1f062a5 --- /dev/null +++ b/apps/admin.saladeaula.digital/app/routes/_.$orgid.payments.$id._index/enrollments.tsx @@ -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 ( + + + MatrĂ­culas relacionadas + + Acompanhe o status e os detalhes de todas as matrĂ­culas relacionadas a + esta compra. + + + + + + + + Colaborador + Curso + Status + Executada em + Agendada em + Revogada em + + + + {enrollments.map( + ( + { + user, + course, + status, + executed_at, + scheduled_at, + rollback_at + }, + idx + ) => { + return ( + + +
+ + + {initials(user.name)} + + + +
    +
  • + {user.name} +
  • +
  • + {user.email} +
  • +
+
+
+ + {course.name} + + + + + + {executed_at ? ( + {executed_at} + ) : null} + + + {scheduled_at ? ( + {scheduled_at} + ) : null} + + + {rollback_at ? ( + {rollback_at} + ) : null} + +
+ ) + } + )} +
+
+
+
+ ) +} + +const statuses: Record = { + 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 = { + 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 ( + + + {status} + + ) +} diff --git a/apps/admin.saladeaula.digital/app/routes/_.$orgid.payments.$id._index/route.tsx b/apps/admin.saladeaula.digital/app/routes/_.$orgid.payments.$id._index/route.tsx index eed76e1..2c77677 100644 --- a/apps/admin.saladeaula.digital/app/routes/_.$orgid.payments.$id._index/route.tsx +++ b/apps/admin.saladeaula.digital/app/routes/_.$orgid.payments.$id._index/route.tsx @@ -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 (
@@ -320,6 +342,10 @@ export default function Route({ loaderData: { order } }: Route.ComponentProps) { + + {enrollments.length > 0 ? ( + + ) : null}
) } diff --git a/enrollments-events/app/enrollment.py b/enrollments-events/app/enrollment.py index 46c1364..260e899 100644 --- a/enrollments-events/app/enrollment.py +++ b/enrollments-events/app/enrollment.py @@ -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 diff --git a/enrollments-events/uv.lock b/enrollments-events/uv.lock index c27000a..68e2117 100644 --- a/enrollments-events/uv.lock +++ b/enrollments-events/uv.lock @@ -683,7 +683,7 @@ wheels = [ [[package]] name = "layercake" -version = "0.13.1" +version = "0.13.4" source = { directory = "../layercake" } dependencies = [ { name = "arnparse" }, diff --git a/orders-events/app/events/start_fulfillment.py b/orders-events/app/events/start_fulfillment.py index 92868d4..8f91af7 100644 --- a/orders-events/app/events/start_fulfillment.py +++ b/orders-events/app/events/start_fulfillment.py @@ -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, \ diff --git a/orders-events/template.yaml b/orders-events/template.yaml index dc2013e..4ee16c9 100644 --- a/orders-events/template.yaml +++ b/orders-events/template.yaml @@ -67,7 +67,7 @@ Resources: LoggingConfig: LogGroup: !Ref HttpLog Policies: - - DynamoDBWritePolicy: + - DynamoDBCrudPolicy: TableName: !Ref OrderTable Events: Post: diff --git a/packages/ui/src/routes/orders/data.tsx b/packages/ui/src/routes/orders/data.tsx index ce8ba89..e0fe974 100644 --- a/packages/ui/src/routes/orders/data.tsx +++ b/packages/ui/src/routes/orders/data.tsx @@ -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 = { 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' } }