Pular para conteúdo

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 * * * *):

  1. Grace period (24h): PastDue > 24h -> Suspended
  2. Desconecta todas as instancias WhatsApp do tenant
  3. Guard contra concurrency (admin pode ter reativado)

  4. Suspension (30d): Suspended > 30d -> Cancelled

  5. Zera creditos + registra CreditTransaction(SuspensionZeroing)

  6. Data cleanup (90d): Cancelled > 90d -> limpeza de dados

  7. Deleta todos os arquivos R2 do tenant via DeleteByPrefixAsync (lista objetos por prefixo, deleta em batch com DeleteObjectsAsync)
  8. Deleta follow-ups via ExecuteDeleteAsync (bulk SQL)
  9. Deleta agents via ExecuteDeleteAsync (cascade: KB, chunks, steps, FAQs, rules, versions, attachments)
  10. Deleta instances via ExecuteDeleteAsync (cascade: conversations, messages, reactions, escalations, whitelist, blacklist)
  11. 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
  • CanDowngradeAsync valida 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: ConsumeAiCreditAsync usa PostgreSQL RETURNING para UPDATE+SELECT atomico em 1 query
  • Pipeline de IA: ValidateAggregationStep e ValidateDeferredStep usam query unica (sem AsSplitQuery()) -- 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: DeleteByPrefixAsync lista objetos por prefixo e deleta em batch via DeleteObjectsAsync do SDK S3