fix redirect to checkout when the org has not a subscription

This commit is contained in:
2026-01-07 14:30:29 -03:00
parent d222872000
commit ded57b43f0
7 changed files with 173 additions and 113 deletions

View File

@@ -1,10 +1,11 @@
import re import re
from decimal import Decimal from decimal import Decimal
from http import HTTPStatus from http import HTTPStatus
from typing import Annotated, Literal from typing import Any, Literal
from uuid import uuid4
from aws_lambda_powertools.event_handler.api_gateway import Router from aws_lambda_powertools.event_handler.api_gateway import Router
from aws_lambda_powertools.event_handler.openapi.params import Body from layercake.dateutils import now
from layercake.dynamodb import DynamoDBPersistenceLayer from layercake.dynamodb import DynamoDBPersistenceLayer
from layercake.extra_types import CnpjStr, CpfStr, NameStr from layercake.extra_types import CnpjStr, CpfStr, NameStr
from pydantic import ( from pydantic import (
@@ -12,6 +13,7 @@ from pydantic import (
BaseModel, BaseModel,
ConfigDict, ConfigDict,
EmailStr, EmailStr,
Field,
field_validator, field_validator,
model_validator, model_validator,
) )
@@ -20,6 +22,7 @@ from api_gateway import JSONResponse
from boto3clients import dynamodb_client from boto3clients import dynamodb_client
from config import ORDER_TABLE from config import ORDER_TABLE
from routes.enrollments.enroll import Enrollment from routes.enrollments.enroll import Enrollment
from routes.orgs.address import address
router = Router() router = Router()
dyn = DynamoDBPersistenceLayer(ORDER_TABLE, dynamodb_client) dyn = DynamoDBPersistenceLayer(ORDER_TABLE, dynamodb_client)
@@ -62,6 +65,7 @@ class Coupon(BaseModel):
class Checkout(BaseModel): class Checkout(BaseModel):
model_config = ConfigDict(str_strip_whitespace=True) model_config = ConfigDict(str_strip_whitespace=True)
id: UUID4 = Field(default_factory=uuid4)
name: str name: str
email: EmailStr email: EmailStr
address: Address address: Address
@@ -69,11 +73,11 @@ class Checkout(BaseModel):
items: tuple[Item, ...] items: tuple[Item, ...]
enrollments: tuple[Enrollment, ...] | None = None enrollments: tuple[Enrollment, ...] | None = None
coupon: Coupon | None = None coupon: Coupon | None = None
user: User | None = None
org_id: UUID4 | str | None = None org_id: UUID4 | str | None = None
user_id: UUID4 | str | None = None user_id: UUID4 | str | None = None
cnpj: CnpjStr | None = None cnpj: CnpjStr | None = None
cpf: CpfStr | None = None cpf: CpfStr | None = None
created_by: User | None = None
@model_validator(mode='after') @model_validator(mode='after')
def verify_fields(self): def verify_fields(self):
@@ -84,15 +88,67 @@ class Checkout(BaseModel):
if self.org_id is None: if self.org_id is None:
raise ValueError('org_id is missing') raise ValueError('org_id is missing')
if self.user is None: if self.created_by is None:
raise ValueError('user is missing') raise ValueError('created_by is missing')
if self.cpf is not None and self.user_id is None: if self.cpf is not None and self.user_id is None:
raise ValueError('user_id is missing') raise ValueError('user_id is missing')
return self return self
def model_dump(self, **kwargs) -> dict[str, Any]:
return super().model_dump(
exclude_none=True,
exclude={'items', 'address', 'created_by'},
**kwargs,
)
@router.post('/') @router.post('/')
def checkout(body: Annotated[Checkout, Body()]): def checkout(payload: Checkout):
return JSONResponse(status_code=HTTPStatus.CREATED) now_ = now()
order_id = str(payload.id)
address = payload.address
coupon = payload.coupon
with dyn.transact_writer() as transact:
transact.put(
item={
'id': order_id,
'sk': '0',
'total': '',
'discount': '',
'due_date': '',
'created_at': now_,
}
| ({'coupon': coupon.code} if coupon else {})
| payload.model_dump()
)
transact.put(
item={
'id': order_id,
'sk': 'ITEMS',
'items': [],
'created_at': now_,
}
)
transact.put(
item={
'id': order_id,
'sk': 'ADDRESS',
'created_at': now_,
}
| address.model_dump()
)
if coupon:
transact.put(
item={
'id': order_id,
'sk': 'COUPON',
'created_at': now_,
}
| coupon.model_dump()
)
return JSONResponse(body={'id': order_id}, status_code=HTTPStatus.CREATED)

View File

@@ -1,6 +1,8 @@
import json
from http import HTTPMethod, HTTPStatus from http import HTTPMethod, HTTPStatus
from pprint import pprint
from layercake.dynamodb import DynamoDBPersistenceLayer from layercake.dynamodb import DynamoDBPersistenceLayer, PartitionKey
from ...conftest import HttpApiProxy, LambdaContext from ...conftest import HttpApiProxy, LambdaContext
@@ -20,9 +22,9 @@ def test_checkout(
'org_id': 'f6000f79-6e5c-49a0-952f-3bda330ef278', 'org_id': 'f6000f79-6e5c-49a0-952f-3bda330ef278',
'cnpj': '00000000000191', 'cnpj': '00000000000191',
'name': 'Branco do Brasil', 'name': 'Branco do Brasil',
'email': 'sergio@somosbeta.com.br', 'email': 'bb@users.noreply.saladeaula.digital',
'payment_method': 'MANUAL', 'payment_method': 'BANK_SLIP',
'user': { 'created_by': {
'id': '15bacf02-1535-4bee-9022-19d106fd7518', 'id': '15bacf02-1535-4bee-9022-19d106fd7518',
'name': 'Sérgio R Siqueira', 'name': 'Sérgio R Siqueira',
}, },
@@ -46,46 +48,50 @@ def test_checkout(
), ),
lambda_context, lambda_context,
) )
print(r) body = json.loads(r['body'])
print(body)
assert r['statusCode'] == HTTPStatus.CREATED assert r['statusCode'] == HTTPStatus.CREATED
r = dynamodb_persistence_layer.collection.query(PartitionKey(body['id']))
pprint(r['items'])
def test_checkout_from_user(
app, # def test_checkout_from_user(
seeds, # app,
http_api_proxy: HttpApiProxy, # seeds,
dynamodb_persistence_layer: DynamoDBPersistenceLayer, # http_api_proxy: HttpApiProxy,
lambda_context: LambdaContext, # dynamodb_persistence_layer: DynamoDBPersistenceLayer,
): # lambda_context: LambdaContext,
r = app.lambda_handler( # ):
http_api_proxy( # r = app.lambda_handler(
raw_path='/orders', # http_api_proxy(
method=HTTPMethod.POST, # raw_path='/orders',
body={ # method=HTTPMethod.POST,
'user_id': '15bacf02-1535-4bee-9022-19d106fd7518', # body={
'cpf': '07879819908', # 'user_id': '15bacf02-1535-4bee-9022-19d106fd7518',
'name': 'Sérgio R Siqueira', # 'cpf': '07879819908',
'email': 'sergio@somosbeta.com.br', # 'name': 'Sérgio R Siqueira',
'payment_method': 'MANUAL', # 'email': 'sergio@somosbeta.com.br',
'address': { # 'payment_method': 'MANUAL',
'city': 'Curitiba', # 'address': {
'postcode': '81280350', # 'city': 'Curitiba',
'neighborhood': 'Cidade Industrial', # 'postcode': '81280350',
'address1': 'Rua Monsenhor Ivo Zanlorenzi', # 'neighborhood': 'Cidade Industrial',
'address2': 'nº 5190, ap 1802', # 'address1': 'Rua Monsenhor Ivo Zanlorenzi',
'state': 'PR', # 'address2': 'nº 5190, ap 1802',
}, # 'state': 'PR',
'items': [ # },
{ # 'items': [
'id': 'e1c44881-2fe3-484e-ada2-12b6bf5b9398', # {
'name': 'NR-35 Segurança nos Trabalhos em Altura', # 'id': 'e1c44881-2fe3-484e-ada2-12b6bf5b9398',
'quantity': 2, # 'name': 'NR-35 Segurança nos Trabalhos em Altura',
'unit_price': 119, # 'quantity': 2,
} # 'unit_price': 119,
], # }
}, # ],
), # },
lambda_context, # ),
) # lambda_context,
print(r) # )
assert r['statusCode'] == HTTPStatus.CREATED # print(r)
# assert r['statusCode'] == HTTPStatus.CREATED

View File

@@ -30,20 +30,8 @@ import { initials } from '@repo/ui/lib/utils'
import { Link } from 'react-router' import { Link } from 'react-router'
import type { Workspace, WorkspaceContextProps } from '@/middleware/workspace' import type { Workspace, WorkspaceContextProps } from '@/middleware/workspace'
import type { Address } from '@/routes/_.$orgid.enrollments.buy/review'
type Subscription = { const WorkspaceContext = createContext<WorkspaceContextProps | null>(null)
billing_day: number
payment_method: 'PIX' | 'BANK_SLIP' | 'MANUAL'
}
const WorkspaceContext = createContext<
| (WorkspaceContextProps & {
subscription: Subscription | null
address: Address | null
})
| null
>(null)
export function useWorksapce() { export function useWorksapce() {
const ctx = use(WorkspaceContext) const ctx = use(WorkspaceContext)
@@ -56,33 +44,12 @@ export function useWorksapce() {
} }
export function WorkspaceProvider({ export function WorkspaceProvider({
activeWorkspace, children,
workspaces, ...props
subscription, }: WorkspaceContextProps & {
address,
children
}: {
activeWorkspace: Workspace
workspaces: Workspace[]
subscription?: Subscription
address?: Address
children: React.ReactNode children: React.ReactNode
}) { }) {
return ( return <WorkspaceContext value={props}>{children}</WorkspaceContext>
<WorkspaceContext
value={{
activeWorkspace,
workspaces,
address: address && Object.keys(address).length > 0 ? address : null,
subscription:
subscription && Object.keys(subscription).length > 0
? subscription
: null
}}
>
{children}
</WorkspaceContext>
)
} }
export function WorkspaceSwitcher() { export function WorkspaceSwitcher() {

View File

@@ -3,6 +3,13 @@ import { type LoaderFunctionArgs, createContext } from 'react-router'
import { userContext } from '@repo/auth/context' import { userContext } from '@repo/auth/context'
import { request as req } from '@repo/util/request' import { request as req } from '@repo/util/request'
import type { Address } from '@/routes/_.$orgid.enrollments.buy/review'
export type Subscription = {
billing_day: number
payment_method: 'PIX' | 'BANK_SLIP' | 'MANUAL'
}
export type Workspace = { export type Workspace = {
id: string id: string
name: string name: string
@@ -12,6 +19,8 @@ export type Workspace = {
export type WorkspaceContextProps = { export type WorkspaceContextProps = {
activeWorkspace: Workspace activeWorkspace: Workspace
workspaces: Workspace[] workspaces: Workspace[]
subscription: Subscription | null
address: Address | null
} }
export const workspaceContext = createContext<WorkspaceContextProps>() export const workspaceContext = createContext<WorkspaceContextProps>()
@@ -43,7 +52,33 @@ export const workspaceMiddleware = async (
({ id }) => id === org_id ({ id }) => id === org_id
) as Workspace ) as Workspace
context.set(workspaceContext, { activeWorkspace, workspaces }) const [subscription, address] = await Promise.all([
req({
url: `/orgs/${activeWorkspace.id}/subscription`,
request,
context
})
.then((r) => r.json())
.then(emptyObjectToNull),
req({
url: `/orgs/${activeWorkspace.id}/address`,
request,
context
})
.then((r) => r.json())
.then(emptyObjectToNull)
])
context.set(workspaceContext, {
activeWorkspace,
workspaces,
subscription,
address
})
return await next() return await next()
} }
const emptyObjectToNull = (data: any) =>
data && Object.keys(data).length === 0 ? null : data

View File

@@ -67,7 +67,6 @@ export default function Route({
loaderData: { enrollments } loaderData: { enrollments }
}: Route.ComponentProps) { }: Route.ComponentProps) {
const { orgid } = useParams() const { orgid } = useParams()
const { subscription } = useWorksapce()
const [searchParams, setSearchParams] = useSearchParams() const [searchParams, setSearchParams] = useSearchParams()
const [selectedRows, setSelectedRows] = useState<Enrollment[]>([]) const [selectedRows, setSelectedRows] = useState<Enrollment[]>([])
const status = searchParams.get('status') const status = searchParams.get('status')
@@ -205,7 +204,7 @@ export default function Route({
<DataTableViewOptions className="flex-1" /> <DataTableViewOptions className="flex-1" />
<Button className="flex-1" asChild> <Button className="flex-1" asChild>
<Link to={subscription ? 'add' : 'buy'}> <Link to="add">
<PlusIcon /> Adicionar <PlusIcon /> Adicionar
</Link> </Link>
</Button> </Button>

View File

@@ -72,6 +72,7 @@ import { ScheduledForInput } from './scheduled-for'
import { CoursePicker } from './course-picker' import { CoursePicker } from './course-picker'
import { UserPicker } from './user-picker' import { UserPicker } from './user-picker'
import { cn } from '@repo/ui/lib/utils' import { cn } from '@repo/ui/lib/utils'
import { workspaceContext } from '@/middleware/workspace'
const emptyRow = { const emptyRow = {
user: undefined, user: undefined,
@@ -84,6 +85,12 @@ export function meta({}: Route.MetaArgs) {
} }
export async function loader({ params, context, request }: Route.LoaderArgs) { export async function loader({ params, context, request }: Route.LoaderArgs) {
const { subscription } = context.get(workspaceContext)
// If there's no subscription for the org, redirect to checkout
if (!subscription) {
throw redirect('../enrollments/buy')
}
const url = new URL(request.url) const url = new URL(request.url)
const submissionId = url.searchParams.get('submission') const submissionId = url.searchParams.get('submission')
const cloudflare = context.get(cloudflareContext) const cloudflare = context.get(cloudflareContext)

View File

@@ -27,32 +27,17 @@ export const middleware: Route.MiddlewareFunction[] = [
workspaceMiddleware workspaceMiddleware
] ]
export async function loader({ params, context, request }: Route.ActionArgs) { export async function loader({ context, request }: Route.ActionArgs) {
const user = context.get(userContext)! const user = context.get(userContext)!
const { activeWorkspace, workspaces } = context.get(workspaceContext) const workspace = context.get(workspaceContext)
const rawCookie = request.headers.get('cookie') || '' const rawCookie = request.headers.get('cookie') || ''
const parsedCookies = cookie.parse(rawCookie) const parsedCookies = cookie.parse(rawCookie)
const { sidebar_state = 'true' } = parsedCookies const { sidebar_state = 'true' } = parsedCookies
const subscription = req({
url: `/orgs/${activeWorkspace.id}/subscription`,
request,
context
}).then((r) => r.json())
const address = req({
url: `/orgs/${activeWorkspace.id}/address`,
request,
context
}).then((r) => r.json())
return { return {
user, user,
activeWorkspace,
workspaces,
sidebar_state, sidebar_state,
subscription, ...workspace
address
} }
} }
@@ -64,9 +49,14 @@ export function shouldRevalidate({
} }
export default function Route({ loaderData }: Route.ComponentProps) { export default function Route({ loaderData }: Route.ComponentProps) {
const { user, activeWorkspace, workspaces, sidebar_state } = loaderData const {
const subscription = use(loaderData.subscription) user,
const address = use(loaderData.address) activeWorkspace,
workspaces,
subscription,
address,
sidebar_state
} = loaderData
useEffect(() => { useEffect(() => {
if (typeof window !== 'undefined' && window.rybbit) { if (typeof window !== 'undefined' && window.rybbit) {