findArchiveBillByIdempotency($originalBillId, $idempotencyKey); if ($existingArchiveId !== null) { return $existingArchiveId; } } try { return (int) DB::transaction(function () use ($originalBillId, $reason, $adminUserId, $idempotencyKey): int { return $this->insertBadDebtWriteOff($originalBillId, $reason, $adminUserId, $idempotencyKey); }); } catch (QueryException $e) { if ( $idempotencyKey !== null && $idempotencyKey !== '' && DatabaseUniqueViolation::matches($e) ) { $existingArchiveId = $this->findArchiveBillByIdempotency($originalBillId, $idempotencyKey); if ($existingArchiveId !== null) { return $existingArchiveId; } } throw $e; } } private function insertBadDebtWriteOff( int $originalBillId, ?string $reason, int $adminUserId, ?string $idempotencyKey, ): int { /** @var object|null $original */ $original = DB::table('settlement_bills')->where('id', $originalBillId)->lockForUpdate()->first(); if ($original === null) { throw new \InvalidArgumentException('bill_not_found'); } $meta = $this->decodeMeta($original->meta_json); $existingArchiveId = (int) ($meta['bad_debt_bill_id'] ?? 0); if ($existingArchiveId > 0) { return $existingArchiveId; } if ($idempotencyKey !== null && $idempotencyKey !== '') { $existingArchiveId = $this->findArchiveBillByIdempotency($originalBillId, $idempotencyKey); if ($existingArchiveId !== null) { return $existingArchiveId; } } if ($this->periodCompletion->isPeriodReadOnly((int) $original->settlement_period_id)) { throw ValidationException::withMessages([ 'period' => ['completed'], ]); } if (! in_array((string) $original->status, ['confirmed', 'partial_paid', 'overdue'], true)) { throw ValidationException::withMessages([ 'bill' => ['not_eligible'], ]); } $unpaid = (int) $original->unpaid_amount; if ($unpaid <= 0) { throw ValidationException::withMessages([ 'bill' => ['no_unpaid'], ]); } if (in_array((string) $original->bill_type, ['adjustment', 'reversal', 'bad_debt'], true)) { throw ValidationException::withMessages([ 'bill' => ['not_eligible'], ]); } $now = now(); $periodId = (int) $original->settlement_period_id; $archiveBillId = (int) DB::table('settlement_bills')->insertGetId([ 'settlement_period_id' => $periodId, 'bill_type' => 'bad_debt', 'owner_type' => (string) $original->owner_type, 'owner_id' => (int) $original->owner_id, 'counterparty_type' => (string) $original->counterparty_type, 'counterparty_id' => (int) $original->counterparty_id, 'gross_win_loss' => 0, 'rebate_amount' => 0, 'adjustment_amount' => -$unpaid, 'platform_rounding_adjustment' => 0, 'net_amount' => 0, 'paid_amount' => 0, 'unpaid_amount' => 0, 'status' => 'settled', 'reversed_bill_id' => $originalBillId, 'meta_json' => json_encode([ 'original_bill_id' => $originalBillId, 'written_off_amount' => $unpaid, 'original_net_amount' => (int) $original->net_amount, ]), 'locked_at' => $now, 'confirmed_at' => $now, 'created_at' => $now, 'updated_at' => $now, ]); DB::table('settlement_adjustments')->insert([ 'settlement_period_id' => $periodId, 'original_bill_id' => $originalBillId, 'result_bill_id' => $archiveBillId, 'adjustment_type' => 'bad_debt', 'amount' => $unpaid, 'reason' => $reason, 'idempotency_key' => $idempotencyKey, 'created_by' => $adminUserId > 0 ? $adminUserId : null, 'created_at' => $now, 'updated_at' => $now, ]); DB::table('settlement_bills')->where('id', $originalBillId)->update([ 'unpaid_amount' => 0, 'status' => 'settled', 'meta_json' => json_encode(array_merge( $meta, [ 'bad_debt_bill_id' => $archiveBillId, 'written_off_amount' => $unpaid, ], )), 'updated_at' => $now, ]); if ((string) $original->owner_type === 'player' && (int) $original->owner_id > 0 && (int) $original->net_amount > 0) { $player = Player::query()->find((int) $original->owner_id); if ($player !== null) { $cumulativePaid = (int) $original->paid_amount + $unpaid; $this->playerCreditService->releaseFromSettlement($player, $cumulativePaid, $originalBillId); } } $this->periodCompletion->syncIfReady($periodId); return $archiveBillId; } private function findArchiveBillByIdempotency(int $originalBillId, string $idempotencyKey): ?int { $resultBillId = DB::table('settlement_adjustments') ->where('original_bill_id', $originalBillId) ->where('adjustment_type', 'bad_debt') ->where('idempotency_key', $idempotencyKey) ->value('result_bill_id'); return $resultBillId !== null ? (int) $resultBillId : null; } /** * @return array */ private function decodeMeta(mixed $metaJson): array { if ($metaJson === null || $metaJson === '') { return []; } if (is_array($metaJson)) { return $metaJson; } $decoded = json_decode((string) $metaJson, true); return is_array($decoded) ? $decoded : []; } }