add freeze subscription

This commit is contained in:
2026-01-18 12:34:19 -03:00
parent 3f0f7ec1e1
commit ae348377a5
10 changed files with 102 additions and 40 deletions

View File

@@ -34,6 +34,11 @@ class SubscriptionRequiredError(ServiceError):
super().__init__(HTTPStatus.NOT_ACCEPTABLE, msg) super().__init__(HTTPStatus.NOT_ACCEPTABLE, msg)
class SubscriptionFrozenError(ServiceError):
def __init__(self, msg: str | dict):
super().__init__(HTTPStatus.NOT_ACCEPTABLE, msg)
class DeduplicationConflictError(ConflictError): ... class DeduplicationConflictError(ConflictError): ...
@@ -90,15 +95,9 @@ def enroll(
rename_key='subscription', rename_key='subscription',
table_name=USER_TABLE, table_name=USER_TABLE,
) )
+ KeyPair(
pk='SUBSCRIPTION',
sk=f'ORG#{org_id}',
rename_key='subscribed',
table_name=USER_TABLE,
)
) )
if 'subscribed' not in org: if 'subscription' not in org:
raise SubscriptionRequiredError('Organization not subscribed') raise SubscriptionRequiredError('Organization not subscribed')
ctx = { ctx = {
@@ -184,6 +183,15 @@ def enroll_now(enrollment: Enrollment, context: Context):
exc_cls=SubscriptionRequiredError, exc_cls=SubscriptionRequiredError,
table_name=USER_TABLE, table_name=USER_TABLE,
) )
transact.condition(
key=KeyPair(
pk='SUBSCRIPTION#FREEZE',
sk=f'ORG#{org.id}',
),
cond_expr='attribute_not_exists(sk)',
exc_cls=SubscriptionFrozenError,
table_name=USER_TABLE,
)
transact.put( transact.put(
item={ item={
'id': enrollment.id, 'id': enrollment.id,
@@ -292,6 +300,15 @@ def enroll_later(enrollment: Enrollment, context: Context):
exc_cls=SubscriptionRequiredError, exc_cls=SubscriptionRequiredError,
table_name=USER_TABLE, table_name=USER_TABLE,
) )
transact.condition(
key=KeyPair(
pk='SUBSCRIPTION#FREEZE',
sk=f'ORG#{org.id}',
),
cond_expr='attribute_not_exists(sk)',
exc_cls=SubscriptionFrozenError,
table_name=USER_TABLE,
)
transact.put( transact.put(
item={ item={
'id': pk, 'id': pk,

View File

@@ -1,6 +1,6 @@
from aws_lambda_powertools import Logger from aws_lambda_powertools import Logger
from aws_lambda_powertools.event_handler.api_gateway import Router from aws_lambda_powertools.event_handler.api_gateway import Router
from layercake.dynamodb import DynamoDBPersistenceLayer, SortKey, TransactKey from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair, SortKey, TransactKey
from boto3clients import dynamodb_client from boto3clients import dynamodb_client
from config import USER_TABLE from config import USER_TABLE
@@ -44,4 +44,9 @@ def get_org(org_id: str):
+ SortKey('0') + SortKey('0')
+ SortKey('METADATA#ADDRESS', rename_key='address') + SortKey('METADATA#ADDRESS', rename_key='address')
+ SortKey('METADATA#SUBSCRIPTION', rename_key='subscription') + SortKey('METADATA#SUBSCRIPTION', rename_key='subscription')
+ KeyPair(
pk='SUBSCRIPTION#FREEZE',
sk=SortKey(f'ORG#{org_id}'),
rename_key='subscription_freeze',
)
) )

View File

@@ -42,15 +42,6 @@ def add(
cond_expr='attribute_exists(sk)', cond_expr='attribute_exists(sk)',
exc_cls=OrgNotFoundError, exc_cls=OrgNotFoundError,
) )
transact.put(
item={
'id': 'SUBSCRIPTION',
'sk': f'ORG#{org_id}',
'name': name,
'created_at': now_,
},
cond_expr='attribute_not_exists(sk)',
)
transact.put( transact.put(
item={ item={
'id': org_id, 'id': org_id,
@@ -60,5 +51,14 @@ def add(
'created_at': now_, 'created_at': now_,
} }
) )
transact.put(
item={
'id': 'SUBSCRIPTION',
'sk': f'ORG#{org_id}',
'name': name,
'created_at': now_,
},
cond_expr='attribute_not_exists(sk)',
)
return JSONResponse(status_code=HTTPStatus.CREATED) return JSONResponse(status_code=HTTPStatus.CREATED)

View File

@@ -6,6 +6,25 @@ from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair, PartitionKey
from ..conftest import HttpApiProxy, LambdaContext from ..conftest import HttpApiProxy, LambdaContext
def test_get_org(
app,
seeds,
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
http_api_proxy: HttpApiProxy,
lambda_context: LambdaContext,
):
r = app.lambda_handler(
http_api_proxy(
raw_path='/orgs/2a8963fc-4694-4fe2-953a-316d1b10f1f5',
method=HTTPMethod.GET,
),
lambda_context,
)
body = json.loads(r['body'])
assert 'subscription_freeze' in body
def test_add_org( def test_add_org(
app, app,
seeds, seeds,

View File

@@ -38,6 +38,7 @@
{"id": "cnpj", "sk": "00000000000191", "org_id": "6000f79-6e5c-49a0-952f-3bda330ef278"} {"id": "cnpj", "sk": "00000000000191", "org_id": "6000f79-6e5c-49a0-952f-3bda330ef278"}
{"id": "SUBSCRIPTION", "sk": "ORG#2a8963fc-4694-4fe2-953a-316d1b10f1f5"} {"id": "SUBSCRIPTION", "sk": "ORG#2a8963fc-4694-4fe2-953a-316d1b10f1f5"}
{"id": "SUBSCRIPTION", "sk": "ORG#cJtK9SsnJhKPyxESe7g3DG"} {"id": "SUBSCRIPTION", "sk": "ORG#cJtK9SsnJhKPyxESe7g3DG"}
{"id": "SUBSCRIPTION#FROZEN", "sk": "ORG#2a8963fc-4694-4fe2-953a-316d1b10f1f5", "frozen": true, "created_at": "2025-12-24T00:05:27-03:00"}
// CPFs // CPFs
{"id": "cpf", "sk": "07879819908", "user_id": "15bacf02-1535-4bee-9022-19d106fd7518"} {"id": "cpf", "sk": "07879819908", "user_id": "15bacf02-1535-4bee-9022-19d106fd7518"}

View File

@@ -1,7 +1,6 @@
'use client' 'use client'
import { formatCNPJ } from '@brazilian-utils/brazilian-utils' import { formatCNPJ } from '@brazilian-utils/brazilian-utils'
import { IconRosetteDiscountCheckFilled } from '@tabler/icons-react'
import { import {
BadgeCheckIcon, BadgeCheckIcon,
CheckIcon, CheckIcon,

View File

@@ -24,7 +24,9 @@ export type WorkspaceContextProps = {
address: Address | null address: Address | null
} }
export const workspaceContext = createContext<WorkspaceContextProps>() export const workspaceContext = createContext<
WorkspaceContextProps & { blocked: boolean }
>()
export const workspaceMiddleware = async ( export const workspaceMiddleware = async (
{ params, request, context }: LoaderFunctionArgs, { params, request, context }: LoaderFunctionArgs,
@@ -63,7 +65,8 @@ export const workspaceMiddleware = async (
activeWorkspace, activeWorkspace,
workspaces, workspaces,
subscription: org?.['subscription'] || null, subscription: org?.['subscription'] || null,
address: org?.['address'] || null address: org?.['address'] || null,
blocked: 'subscription_freeze' in org
}) })
return await next() return await next()

View File

@@ -8,12 +8,19 @@ import { userContext } from '@repo/auth/context'
import { authMiddleware } from '@repo/auth/middleware/auth' import { authMiddleware } from '@repo/auth/middleware/auth'
import { ModeToggle, ThemedImage } from '@repo/ui/components/dark-mode' import { ModeToggle, ThemedImage } from '@repo/ui/components/dark-mode'
import { NavUser } from '@repo/ui/components/nav-user' import { NavUser } from '@repo/ui/components/nav-user'
import {
AlertDialog,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle
} from '@repo/ui/components/ui/alert-dialog'
import { import {
SidebarInset, SidebarInset,
SidebarProvider, SidebarProvider,
SidebarTrigger SidebarTrigger
} from '@repo/ui/components/ui/sidebar' } from '@repo/ui/components/ui/sidebar'
import { Toaster } from '@repo/ui/components/ui/sonner' import { Toaster } from '@repo/ui/components/ui/sonner'
import { cn } from '@repo/ui/lib/utils'
import { AppSidebar } from '@/components/app-sidebar' import { AppSidebar } from '@/components/app-sidebar'
import { WorkspaceProvider } from '@/components/workspace-switcher' import { WorkspaceProvider } from '@/components/workspace-switcher'
@@ -48,14 +55,7 @@ export function shouldRevalidate({
} }
export default function Route({ loaderData }: Route.ComponentProps) { export default function Route({ loaderData }: Route.ComponentProps) {
const { const { user, sidebar_state, blocked, ...props } = loaderData
user,
activeWorkspace,
workspaces,
subscription,
address,
sidebar_state
} = loaderData
useEffect(() => { useEffect(() => {
if (typeof window !== 'undefined' && window.rybbit) { if (typeof window !== 'undefined' && window.rybbit) {
@@ -68,13 +68,23 @@ export default function Route({ loaderData }: Route.ComponentProps) {
}, []) }, [])
return ( return (
<WorkspaceProvider <WorkspaceProvider {...props}>
activeWorkspace={activeWorkspace} {blocked ? (
workspaces={workspaces} <AlertDialog open={true}>
subscription={subscription} <AlertDialogContent>
address={address} <AlertDialogTitle>Serviço com acesso suspenso</AlertDialogTitle>
<AlertDialogDescription>
Seu acesso está temporariamente bloqueado devido a um pagamento em
atraso. Regularize para continuar usando a plataforma.
</AlertDialogDescription>
</AlertDialogContent>
</AlertDialog>
) : null}
<SidebarProvider
defaultOpen={sidebar_state === 'true'}
className={cn('flex', blocked && 'pointer-events-none')}
> >
<SidebarProvider defaultOpen={sidebar_state === 'true'} className="flex">
<AppSidebar /> <AppSidebar />
<SidebarInset className="relative flex flex-col flex-1 min-w-0"> <SidebarInset className="relative flex flex-col flex-1 min-w-0">

View File

@@ -1,7 +1,7 @@
'use client' 'use client'
import { type ColumnDef } from '@tanstack/react-table' import { type ColumnDef } from '@tanstack/react-table'
import { EllipsisIcon, PencilIcon } from 'lucide-react' import { BadgeCheckIcon, EllipsisIcon, PencilIcon } from 'lucide-react'
import { NavLink } from 'react-router' import { NavLink } from 'react-router'
import { Abbr } from '@repo/ui/components/abbr' import { Abbr } from '@repo/ui/components/abbr'
@@ -38,13 +38,20 @@ export const columns: ColumnDef<Org>[] = [
{ {
header: 'Empresa', header: 'Empresa',
cell: ({ row }) => { cell: ({ row }) => {
const { name, email } = row.original const { name, email, subscription_covered } = row.original
return ( return (
<div className="flex gap-2.5 items-center"> <div className="flex gap-2.5 items-center">
<div className="relative">
{subscription_covered ? (
<BadgeCheckIcon className="fill-blue-500 stroke-white absolute size-4 dark:size-3.5 -top-0 -right-0 z-2" />
) : null}
<Avatar className="size-10 hidden lg:block"> <Avatar className="size-10 hidden lg:block">
<AvatarFallback className="border">{initials(name)}</AvatarFallback> <AvatarFallback className="border">
{initials(name)}
</AvatarFallback>
</Avatar> </Avatar>
</div>
<ul> <ul>
<li className="font-bold"> <li className="font-bold">

View File

@@ -5,4 +5,5 @@ export type Org = {
name: string name: string
email: string email: string
cnpj?: string cnpj?: string
subscription_covered?: boolean
} }