From 14721ff3067f6bcfc2d7c35a5b9efc0ecde0a38b Mon Sep 17 00:00:00 2001 From: admbusiness Date: Wed, 3 Jun 2026 17:11:42 -0300 Subject: [PATCH] =?UTF-8?q?fix(billing):=20B11=20=E2=80=94=20manual=5Fadju?= =?UTF-8?q?stment=20colidia=20com=20bt=5Fidempotency=5Funique?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sintoma reportado em 03/06/2026: ao adicionar saldo manualmente pela 2ª vez para o mesmo tenant, o admin recebia SQLSTATE 23000: Duplicate entry '2-manual_adjustment-admin_action-1' for key 'bt_idempotency_unique' Causa raiz: o índice UNIQUE (tenant_id, type, reference_type, reference_id) foi criado em 29/05 (P0R-01) para idempotência forte de recharge/reserve/release. Mas BillingService::manualAdjustment usava reference_id = adminUserId. O mesmo admin ajustando 2x o mesmo tenant gerava (2, manual_adjustment, admin_action, 1) duas vezes → constraint violado, ação humana abortada. Fix em 2 partes: 1. Migration 2026_06_03_120000_add_executed_by_to_balance_transactions adiciona coluna nullable executed_by_user_id (FK users, on delete SET NULL) onde fica registrado quem fez o ajuste. 2. BillingService::manualAdjustment gera reference_id único combinando microtime (μs) × 1000 + jitter de 3 dígitos. lockForUpdate no tenant serializa chamadas concorrentes; jitter cobre o caso teórico de dois workers no mesmo microssegundo. Admin executor migra para executed_by_user_id. A semântica de idempotência forte continua valendo para recharge/reserve/ release (sem mudanças neles). manual_adjustment passa a ser intencionalmente repetível (ação humana ad-hoc, não idempotente). Auditoria preservada: - AuditLog::record('billing.manual_adjustment', ..., $adminUserId) já existia e não foi alterado. - BalanceTransaction.executed_by_user_id permite query "todos os ajustes do admin X" sem perder o linkage histórico. - BalanceTransaction::executedBy() relação adicionada no model. Validação: - ManualAdjustmentRepeatableTest novo (2 testes, 8 assertions): * mesmo admin fazendo 3 ajustes seguidos no mesmo tenant * admins diferentes ajustando o mesmo tenant (preserva executor) - Suite billing/pentest completa: 37 testes, 1185 assertions, todos passam - Idempotência de recharge/reserve/release intocada Co-Authored-By: Claude Opus 4.7 (1M context) --- .../backend/app/Models/BalanceTransaction.php | 13 +++- .../app/Services/Billing/BillingService.php | 10 ++- ...dd_executed_by_to_balance_transactions.php | 37 ++++++++++ .../ManualAdjustmentRepeatableTest.php | 68 +++++++++++++++++++ 4 files changed, 124 insertions(+), 4 deletions(-) create mode 100644 new_saas/backend/database/migrations/2026_06_03_120000_add_executed_by_to_balance_transactions.php create mode 100644 new_saas/backend/tests/Feature/Billing/ManualAdjustmentRepeatableTest.php diff --git a/new_saas/backend/app/Models/BalanceTransaction.php b/new_saas/backend/app/Models/BalanceTransaction.php index 570952a8..4ec7ac41 100644 --- a/new_saas/backend/app/Models/BalanceTransaction.php +++ b/new_saas/backend/app/Models/BalanceTransaction.php @@ -21,16 +21,23 @@ class BalanceTransaction extends Model protected $fillable = [ 'tenant_id', 'type', 'amount_cents', 'balance_after_cents', - 'reference_type', 'reference_id', 'monthly_cycle_key', + 'reference_type', 'reference_id', 'executed_by_user_id', 'monthly_cycle_key', 'description', 'meta', ]; protected $casts = [ - 'amount_cents' => 'integer', + 'amount_cents' => 'integer', 'balance_after_cents' => 'integer', - 'meta' => 'array', + 'reference_id' => 'integer', + 'executed_by_user_id' => 'integer', + 'meta' => 'array', ]; + public function executedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'executed_by_user_id'); + } + public function tenant(): BelongsTo { return $this->belongsTo(Tenant::class); diff --git a/new_saas/backend/app/Services/Billing/BillingService.php b/new_saas/backend/app/Services/Billing/BillingService.php index baf2c038..86dd28fe 100644 --- a/new_saas/backend/app/Services/Billing/BillingService.php +++ b/new_saas/backend/app/Services/Billing/BillingService.php @@ -218,13 +218,21 @@ public function manualAdjustment(int $tenantId, int $amountCents, string $reason $tenant->increment('balance_cents', $amountCents); $tenant->refresh(); + // reference_id precisa ser único por transação manual para não + // colidir com bt_idempotency_unique. O admin que executou fica em + // executed_by_user_id (coluna dedicada). Combinamos microtime (μs) + // × 1000 + jitter de 3 dígitos — chance de colisão desprezível + // (lockForUpdate já serializa chamadas no mesmo tenant). + $uniqueRefId = (int) (microtime(true) * 1_000_000) * 1_000 + random_int(0, 999); + $tx = BalanceTransaction::create([ 'tenant_id' => $tenantId, 'type' => 'manual_adjustment', 'amount_cents' => $amountCents, 'balance_after_cents' => $tenant->balance_cents, 'reference_type' => 'admin_action', - 'reference_id' => $adminUserId, + 'reference_id' => $uniqueRefId, + 'executed_by_user_id' => $adminUserId, 'description' => $reason, ]); diff --git a/new_saas/backend/database/migrations/2026_06_03_120000_add_executed_by_to_balance_transactions.php b/new_saas/backend/database/migrations/2026_06_03_120000_add_executed_by_to_balance_transactions.php new file mode 100644 index 00000000..40539f74 --- /dev/null +++ b/new_saas/backend/database/migrations/2026_06_03_120000_add_executed_by_to_balance_transactions.php @@ -0,0 +1,37 @@ +foreignId('executed_by_user_id') + ->nullable() + ->after('reference_id') + ->constrained('users') + ->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('balance_transactions', function (Blueprint $t) { + $t->dropConstrainedForeignId('executed_by_user_id'); + }); + } +}; diff --git a/new_saas/backend/tests/Feature/Billing/ManualAdjustmentRepeatableTest.php b/new_saas/backend/tests/Feature/Billing/ManualAdjustmentRepeatableTest.php new file mode 100644 index 00000000..1a39991e --- /dev/null +++ b/new_saas/backend/tests/Feature/Billing/ManualAdjustmentRepeatableTest.php @@ -0,0 +1,68 @@ +create(['balance_cents' => 100]); + $admin = User::factory()->create(['role' => 'finance']); + $svc = app(BillingService::class); + + $svc->manualAdjustment($tenant->id, 5000, 'primeiro ajuste', $admin->id); + $svc->manualAdjustment($tenant->id, 3000, 'segundo ajuste', $admin->id); + $svc->manualAdjustment($tenant->id, -2000, 'estorno parcial', $admin->id); + + $this->assertSame(100 + 5000 + 3000 - 2000, (int) $tenant->fresh()->balance_cents); + + $rows = BalanceTransaction::withoutGlobalScopes() + ->where('tenant_id', $tenant->id) + ->where('type', 'manual_adjustment') + ->get(); + + $this->assertCount(3, $rows); + // Admin executor preservado em coluna dedicada + $rows->each(fn ($r) => $this->assertSame($admin->id, (int) $r->executed_by_user_id)); + // reference_ids devem ser todos distintos para satisfazer bt_idempotency_unique + $this->assertCount(3, $rows->pluck('reference_id')->unique()); + } + + public function test_different_admins_can_adjust_same_tenant(): void + { + $tenant = Tenant::factory()->create(['balance_cents' => 0]); + $admin1 = User::factory()->create(['role' => 'finance']); + $admin2 = User::factory()->create(['role' => 'superadmin']); + $svc = app(BillingService::class); + + $svc->manualAdjustment($tenant->id, 1000, 'ajuste do admin1', $admin1->id); + $svc->manualAdjustment($tenant->id, 2000, 'ajuste do admin2', $admin2->id); + + $rows = BalanceTransaction::withoutGlobalScopes() + ->where('tenant_id', $tenant->id) + ->orderBy('id') + ->get(); + + $this->assertSame($admin1->id, (int) $rows[0]->executed_by_user_id); + $this->assertSame($admin2->id, (int) $rows[1]->executed_by_user_id); + } +} -- 2.48.1.windows.1