Subscription Enforcement¶
Visao Geral¶
O sistema de enforcement garante que tenants respeitem os limites contratados na assinatura. Tenants sem Subscription operam sem restricoes (graceful degradation).
Maquina de Estados¶
+------- AddCredits() -------+
| |
v |
+---------+ credits=0 +------------------+ |
| Active |---------->| AiQuotaExhausted |---+
| |<----------| |
+----+-----+ credits>0 +--------+---------+
| |
| MarkPastDue() | MarkPastDue()
v v
+---------+ |
| PastDue |<---------------------+
+----+-----+
|
+-- Reactivate() -> Active (creditos = MonthlyCreditPackage)
|
| 24h expired (background job)
v
+-----------+
| Suspended |
+-----+-----+
|
+-- Reactivate() -> Active (creditos = MonthlyCreditPackage)
|
| 30d expired (background job)
v
+-----------+
| Cancelled |
+-----+-----+
|
+-- Reactivate() -> Active (creditos = MonthlyCreditPackage)
Nota: Reactivate() sempre reseta CreditBalance = MonthlyCreditPackage, independentemente do status anterior (PastDue, Suspended ou Cancelled). Tambem limpa PastDueAt, SuspendedAt e CancelledAt, e define novo periodo de billing.
Guards nas Transicoes¶
| Metodo | Pre-condicao | Efeito |
|---|---|---|
MarkAiQuotaExhausted() |
Status = Active | Status -> AiQuotaExhausted |
MarkPastDue() |
Active ou AiQuotaExhausted | Status -> PastDue |
Suspend() |
Status = PastDue | Status -> Suspended |
Cancel() |
Status = Suspended | Status -> Cancelled, creditos zerados |
Reactivate() |
PastDue, Suspended ou Cancelled | Status -> Active, creditos = MonthlyCreditPackage, novo periodo |
AddCredits() |
Active ou AiQuotaExhausted | Soma creditos, pode reverter AiQuotaExhausted |
RenewPeriod() |
Active ou AiQuotaExhausted | Soma MonthlyCreditPackage ao saldo, novo periodo |
Pontos de Enforcement¶
Guards de Criacao de Recursos¶
| Recurso | Handler | Metodo | Limite |
|---|---|---|---|
| Agent | CreateAgentHandler |
CanCreateAgentAsync |
MaxAgents |
| Instance | CreateInstanceHandler |
CanCreateInstanceAsync |
Instances |
| User | CreateUserHandler |
CanCreateUserAsync |
MaxUsers |
| KB Doc | CreateKnowledgeBlockHandler |
CanCreateKnowledgeDocAsync |
MaxKnowledgeDocs |
| Step | CreateAgentStepHandler |
CanCreateStepAsync |
MaxSteps (tenant-wide) |
| FAQ | CreateFaqItemHandler |
CanCreateFaqAsync |
MaxFaqs |
| Attachment | UploadAttachmentHandler |
CanUploadAttachmentAsync |
MaxAttachmentStorageMb |
| Connect | ConnectInstanceHandler |
CanConnectInstanceAsync |
Status != Suspended/Cancelled |
Credit Gate (Pipeline de IA)¶
- MessageAggregationPipeline:
CheckSubscriptionCreditsStep->CheckAiResponseCapStep(entre LoadHistory e GenerateAiResponse) - DeferredAiResponsePipeline:
CheckDeferredSubscriptionCreditsStep->CheckDeferredAiResponseCapStep(entre LoadHistory e PrepareAiHistory)
Ordem dos steps de protecao:
1. CheckSubscriptionCreditsStep -- calcula EstimatedCreditCost baseado em midia processada, verifica creditos via HasAiCreditsAsync(requiredCredits)
2. CheckAiResponseCapStep -- verifica se a conversa atingiu o limite de respostas IA (configuravel por instancia, default 50, 0 = ilimitado)
3. GenerateAiResponseStep -- gera a resposta IA
4. ConsumeSubscriptionCreditStep -- consome context.EstimatedCreditCost creditos via ConsumeAiCreditAsync(quantity)
5. IncrementAiResponseCountStep -- incrementa Conversation.AiResponseCount
Custo variavel por tipo de operacao (CreditCostConfig):
| Tipo | Creditos | Descricao |
|---|---|---|
| Text (base) | 1 | Custo base por resposta da IA |
| Image | +3 | Adicional por imagem com extracao via Claude Vision |
| Audio | +3 | Adicional por audio com transcricao via Whisper |
Exemplos: 1 texto = 1, 1 imagem = 4 (1+3), 2 imagens = 7 (1+3+3), 1 imagem + 1 audio = 7 (1+3+3).
O custo e calculado por CheckSubscriptionCreditsStep: 1 credito base + 3 por cada item em ProcessedMediaItems com ExtractedText nao-vazio. O limite de extracoes por batch e configuravel por instancia via WhatsAppInstance.MaxMediaPerMessage (fallback: AppConfig.MaxMediaExtractionsPerBatch = 3). O valor e armazenado em context.EstimatedCreditCost e reutilizado por ConsumeSubscriptionCreditStep.
Interacao com flags de midia da instancia:
- Se WhatsAppInstance.AllowImageAnalysis = false, imagens recebidas geram resposta padrao (canned response) sem consumir creditos
- Se WhatsAppInstance.AllowAudioAnalysis = false, audios recebidos geram resposta padrao (canned response) sem consumir creditos
- WhatsAppInstance.MaxMediaPerMessage limita quantas midias sao processadas por mensagem (1-10, null = padrao 3). Midias excedentes sao salvas no storage mas nao extraidas
- Quando a analise esta habilitada, o step de creditos verifica requiredCredits baseado no tipo de midia antes de processar
Quando o cap e atingido, a conversa muda automaticamente para modo Manual e uma mensagem de sistema (SystemEventType.AiCapReached) e registrada. O contador reseta ao transferir Manual->IA ou ao reabrir uma conversa resolvida.
Quando creditos se esgotam no check pre-IA (CheckSubscriptionCreditsStep), a conversa e escalada para manual com mensagem de sistema (SystemEventType.CreditsExhausted).
Quando o ultimo credito e consumido pos-IA (ConsumeSubscriptionCreditStep, balance == 0):
1. ConversationModeSwitcher.SwitchToManualAsync() faz bulk-switch de todas as conversas IA do tenant
2. Conversa tracked e sincronizada manualmente para enviar notificacao SignalR correta
3. Mensagem de sistema (SystemEventType.CreditsExhausted) e registrada
Todas as mensagens de sistema sao persistidas em PersistMessagesStep e nunca enviadas ao WhatsApp.
Consumo atomico via SQL com RETURNING (1 round-trip):
UPDATE subscriptions
SET credit_balance = credit_balance - @quantity, updated_at = @now
WHERE tenant_id = @tenantId
AND credit_balance >= @quantity
AND status IN ('Active', 'AiQuotaExhausted')
RETURNING id, credit_balance, status
O RETURNING retorna o saldo e status atualizados na mesma query, eliminando SELECT separado. O @quantity varia conforme o tipo de operacao (1 para texto, 3 para imagem/audio).
Vinculo CreditTransaction -> Message (duas fases):
1. ConsumeStep cria CreditTransaction com MessageId = null e armazena TransactionId no contexto do pipeline
2. PersistStep salva a mensagem AI no banco, depois vincula via UPDATE credit_transactions SET message_id = @msgId WHERE id = @txId
Essa abordagem existe porque o credito e consumido antes da mensagem ser persistida no pipeline.
Middleware HTTP¶
SubscriptionMiddleware bloqueia acesso a API quando status = Suspended ou Cancelled.
Paths isentos: /api/webhook, /api/auth, /api/subscription, /health, /hubs, /tickerq-dashboard
Background Jobs¶
SubscriptionLifecycleFunction executa a cada hora (0 0 * * * *):
- Grace period (24h): PastDue > 24h -> Suspended
- Desconecta todas as instancias WhatsApp do tenant
-
Guard contra concurrency (admin pode ter reativado)
-
Suspension (30d): Suspended > 30d -> Cancelled
-
Zera creditos + registra CreditTransaction(SuspensionZeroing)
-
Data cleanup (90d): Cancelled > 90d -> limpeza de dados
- Deleta todos os arquivos R2 do tenant via
DeleteByPrefixAsync(lista objetos por prefixo, deleta em batch comDeleteObjectsAsync) - Deleta follow-ups via
ExecuteDeleteAsync(bulk SQL) - Deleta agents via
ExecuteDeleteAsync(cascade: KB, chunks, steps, FAQs, rules, versions, attachments) - Deleta instances via
ExecuteDeleteAsync(cascade: conversations, messages, reactions, escalations, whitelist, blacklist) - Preserva: tenant, users, subscription, credit transactions, LLM usage, subscription events
Todas as etapas de lifecycle (grace period, suspension) usam paginacao com batch size 50 (Take(batchSize) em while loop) para evitar picos de memoria. A limpeza de dados usa ExecuteDeleteAsync (bulk SQL direto no banco, sem carregar entidades em memoria).
Comportamento de Downgrade¶
- Recursos existentes continuam funcionando apos downgrade
- Criacao de novos recursos e bloqueada ate
count < limit - Nenhum recurso e deletado/desativado automaticamente
- Mensagem sugere remover recursos ou aumentar plano
CanDowngradeAsyncvalida preventivamente se os recursos atuais caberiam nos novos limites
Limites Calculados (Modelo Modular)¶
O modelo de precificacao e modular. Os limites sao calculados via RecalculateLimits() com base nas constantes do dominio:
totalAgents = FreeAgentCount(2) + ExtraAgents
MaxAgents = totalAgents
MaxUsers = (Instances x UsersPerInstance(2)) + ExtraUsers
MaxKnowledgeDocs = (totalAgents x KbPerAgent(5)) + (ExtraKbBlocks x KbPerBlock(5))
MaxSteps = (totalAgents x StepsPerAgent(5)) + (ExtraStepBlocks x StepsPerBlock(5))
MaxAttachmentStorageMb = (totalAgents x AttachmentMbPerAgent(100)) + (ExtraAttachmentBlocks x AttachmentMbPerBlock(200))
MaxFaqs = (totalAgents x FaqsPerAgent(100)) + (ExtraFaqBlocks x FaqsPerBlock(100))
Constantes do dominio (Subscription):
| Constante | Valor | Descricao |
|---|---|---|
FreeAgentCount |
2 | Agentes inclusos sem custo adicional |
UsersPerInstance |
2 | Usuarios inclusos por instancia |
KbPerAgent |
5 | Docs de conhecimento inclusos por agente |
StepsPerAgent |
5 | Etapas inclusas por agente |
AttachmentMbPerAgent |
100 | MB de anexos inclusos por agente |
FaqsPerAgent |
100 | FAQs inclusas por agente |
KbPerBlock |
5 | Docs por bloco extra de KB |
StepsPerBlock |
5 | Etapas por bloco extra de steps |
AttachmentMbPerBlock |
200 | MB por bloco extra de anexos |
FaqsPerBlock |
100 | FAQs por bloco extra |
Exemplo: Subscription com 2 instancias, 1 ExtraAgent, 3 ExtraUsers, 2 ExtraKbBlocks:
- totalAgents = 2 + 1 = 3
- MaxAgents = 3
- MaxUsers = (2 x 2) + 3 = 7
- MaxKnowledgeDocs = (3 x 5) + (2 x 5) = 25
- MaxSteps = (3 x 5) + 0 = 15
- MaxAttachmentStorageMb = (3 x 100) + 0 = 300
- MaxFaqs = (3 x 100) + 0 = 300
Nota: MaxSteps e um limite tenant-wide (total de etapas em todos os agentes do tenant), nao por agente.
Recalculados via RecalculateLimits() ao criar subscription ou alterar configuracao (UpdateConfiguration()).
Protecao contra Abuso de Creditos¶
Cooldown Antecipado¶
O AggregationPollingService seta o cooldown Redis imediatamente ao marcar agregacoes como "Processing" (antes de iniciar o pipeline). Isso fecha a janela onde mensagens chegando durante o processamento (~8-10s) criavam novas agregacoes sem cooldown. O CleanupAggregationStep renova o cooldown ao final do processamento.
Cap de Respostas IA por Conversa¶
Configuravel por instancia via WhatsAppInstance.MaxAiResponsesPerConversation:
- null = usa default global (SystemConfig.DefaultMaxAiResponsesPerConversation = 50)
- 0 = ilimitado (opt-out)
- N > 0 = limite de N respostas IA por conversa
Ao atingir o cap, CheckAiResponseCapStep muda a conversa para modo Manual. O contador (Conversation.AiResponseCount) reseta quando:
- Conversa e transferida Manual -> IA (SwitchToAIMode())
- Conversa e reaberta apos resolucao (Reopen())
Performance¶
Cache Redis de Subscription¶
SubscriptionEnforcementService cacheia dados de subscription via ICacheService com TTL de 5 minutos (chave: CacheKeys.SubscriptionKey(tenantId)). Evita round-trips ao banco em guards de criacao de recursos e checks de creditos.
Invalidacao do cache ocorre apenas em mudancas de STATUS:
- ConsumeAiCreditAsync -- apenas quando status muda (Active -> AiQuotaExhausted), nao a cada credito consumido
- UpdateTenantHandler -- apos alterar configuracao da subscription
- SubscriptionLifecycleFunction -- apos transicoes de estado (Suspend, Cancel)
CreditBalance stale e seguro: O SQL atomico (WHERE credit_balance >= @quantity) e a fonte de verdade para consumo. O cache pode ter um saldo desatualizado sem risco -- o DB garante consistencia.
Deduplicacao natural: Middleware e handler na mesma request atingem o cache Redis (~0.1ms), eliminando queries duplicadas ao banco.
Queries Otimizadas¶
- Consumo de creditos:
ConsumeAiCreditAsyncusa PostgreSQLRETURNINGpara UPDATE+SELECT atomico em 1 query - Pipeline de IA:
ValidateAggregationStepeValidateDeferredStepusam query unica (semAsSplitQuery()) -- mais eficiente quando Steps por agent sao poucos (1-10) - Endpoints de consulta: Subqueries em
.Select()consolidam 7 queries em 1
Background Jobs¶
- Paginacao: Etapas de lifecycle (grace period, suspension) processam em batches de 50 registros
- Delecao de dados: Usa
ExecuteDeleteAsync(bulk SQL direto no banco, sem carregar entidades) - Delecao R2:
DeleteByPrefixAsynclista objetos por prefixo e deleta em batch viaDeleteObjectsAsyncdo SDK S3