diff --git a/app/Domains/Catalog/Models/Inventory.php b/app/Domains/Catalog/Models/Inventory.php index 5ff7f6c..ad20142 100644 --- a/app/Domains/Catalog/Models/Inventory.php +++ b/app/Domains/Catalog/Models/Inventory.php @@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Relations\HasOne; #[Fillable([ 'sold_units', + 'refunded_units', 'reserved_stock', 'real_stock', ])] @@ -23,6 +24,7 @@ class Inventory extends Model protected $attributes = [ 'sold_units' => 0, + 'refunded_units' => 0, 'reserved_stock' => 0, 'real_stock' => 0, ]; @@ -31,6 +33,7 @@ class Inventory extends Model { return [ 'sold_units' => 'integer', + 'refunded_units' => 'integer', 'reserved_stock' => 'integer', 'real_stock' => 'integer', ]; diff --git a/app/Domains/Catalog/Services/VariantReplacementService.php b/app/Domains/Catalog/Services/VariantReplacementService.php index e4422d3..1b744da 100644 --- a/app/Domains/Catalog/Services/VariantReplacementService.php +++ b/app/Domains/Catalog/Services/VariantReplacementService.php @@ -188,6 +188,7 @@ class VariantReplacementService $replacementInventory = Inventory::query()->create([ 'sold_units' => $sourceInventory->sold_units, + 'refunded_units' => $sourceInventory->refunded_units, 'reserved_stock' => $reservedStock, 'real_stock' => $sourceInventory->real_stock, ]); @@ -235,6 +236,7 @@ class VariantReplacementService 'real_stock' => $destinationInventory->real_stock + $sourceInventory->real_stock, 'reserved_stock' => $destinationInventory->reserved_stock + $sourceInventory->reserved_stock, 'sold_units' => $destinationInventory->sold_units + $sourceInventory->sold_units, + 'refunded_units' => $destinationInventory->refunded_units + $sourceInventory->refunded_units, ]); if ($activeLines->isNotEmpty()) { StockReservationLine::query() @@ -245,6 +247,7 @@ class VariantReplacementService 'real_stock' => 0, 'reserved_stock' => 0, 'sold_units' => 0, + 'refunded_units' => 0, ]); } diff --git a/app/Domains/FiestaFutbolInfantil/Services/FoodService.php b/app/Domains/FiestaFutbolInfantil/Services/FoodService.php index 571d1cd..d9a4064 100644 --- a/app/Domains/FiestaFutbolInfantil/Services/FoodService.php +++ b/app/Domains/FiestaFutbolInfantil/Services/FoodService.php @@ -177,6 +177,7 @@ class FoodService $historicalInventory = Inventory::query()->create([ 'sold_units' => $inventory->sold_units, + 'refunded_units' => $inventory->refunded_units, 'reserved_stock' => 0, 'real_stock' => $inventory->real_stock, ]); diff --git a/app/Domains/Purchase/Services/TenantTransactionResetService.php b/app/Domains/Purchase/Services/TenantTransactionResetService.php index be6b7c2..cd1d450 100644 --- a/app/Domains/Purchase/Services/TenantTransactionResetService.php +++ b/app/Domains/Purchase/Services/TenantTransactionResetService.php @@ -65,9 +65,10 @@ class TenantTransactionResetService DB::table('inventories') ->whereIn('id', $scope['inventory_ids']) ->update([ - 'real_stock' => DB::raw('real_stock + sold_units'), + 'real_stock' => DB::raw('real_stock + sold_units - refunded_units'), 'reserved_stock' => 0, 'sold_units' => 0, + 'refunded_units' => 0, ]); return $summary; diff --git a/app/Domains/Ticket/Services/AdminAppTicketService.php b/app/Domains/Ticket/Services/AdminAppTicketService.php index 9960fe3..275f614 100644 --- a/app/Domains/Ticket/Services/AdminAppTicketService.php +++ b/app/Domains/Ticket/Services/AdminAppTicketService.php @@ -3,6 +3,9 @@ namespace App\Domains\Ticket\Services; use App\Domains\Auth\Models\User; +use App\Domains\Catalog\Enums\InventoryPolicy; +use App\Domains\Catalog\Models\Inventory; +use App\Domains\Catalog\Models\Variant; use App\Domains\Purchase\Models\PurchaseItem; use App\Domains\Purchase\Services\PurchaseRefundSummaryService; use App\Domains\Tenant\Models\Tenant; @@ -223,10 +226,57 @@ class AdminAppTicketService 'amount' => number_format($refundAmount, 2, '.', ''), ]); + $this->restoreInventory($ticket); + return $ticket->refresh()->load(self::RELATIONS); }); } + private function restoreInventory(Ticket $ticket): void + { + $catalogItem = $ticket->sourceCatalogItem; + if ($catalogItem === null) { + throw ValidationException::withMessages([ + 'ticket' => 'El ticket no tiene un producto con inventario reponible.', + ]); + } + + // Bundle components need a per-ticket allocation before they can be restored. + if ($catalogItem->isBundle()) { + return; + } + + $inventoryId = $catalogItem->inventory_id; + if ($ticket->source_variant_id !== null) { + $variant = Variant::withTrashed()->find($ticket->source_variant_id); + if ($variant === null) { + throw ValidationException::withMessages(['ticket' => 'No se encontrĂ³ la variante del ticket.']); + } + + // A replacement can move the sellable inventory to a newer variant. + $visited = []; + while ($variant->replaced_by_variant_id !== null) { + if (isset($visited[$variant->id])) { + throw new \LogicException('La cadena de reemplazos de variantes es circular.'); + } + $visited[$variant->id] = true; + $variant = Variant::withTrashed()->findOrFail($variant->replaced_by_variant_id); + } + $inventoryId = $variant->inventory_id; + } + + $inventory = Inventory::query()->lockForUpdate()->find($inventoryId); + if ($inventory === null) { + throw ValidationException::withMessages(['ticket' => 'No se encontrĂ³ el inventario del ticket.']); + } + + if ($catalogItem->inventory_policy === InventoryPolicy::Tracked) { + $inventory->real_stock++; + } + $inventory->refunded_units++; + $inventory->save(); + } + private function refundedAmountForPurchaseItem(PurchaseItem $purchaseItem): float { return round((float) TicketRefund::query() diff --git a/database/migrations/2026_09_16_000000_add_refunded_units_to_inventories.php b/database/migrations/2026_09_16_000000_add_refunded_units_to_inventories.php new file mode 100644 index 0000000..0727aab --- /dev/null +++ b/database/migrations/2026_09_16_000000_add_refunded_units_to_inventories.php @@ -0,0 +1,23 @@ +unsignedBigInteger('refunded_units')->default(0); + }); + + } + + public function down(): void + { + Schema::table('inventories', function (Blueprint $table): void { + $table->dropColumn('refunded_units'); + }); + } +}; diff --git a/tests/Feature/Ticket/AdminAppTicketControllerTest.php b/tests/Feature/Ticket/AdminAppTicketControllerTest.php index b0ae3db..f975205 100644 --- a/tests/Feature/Ticket/AdminAppTicketControllerTest.php +++ b/tests/Feature/Ticket/AdminAppTicketControllerTest.php @@ -186,6 +186,7 @@ class AdminAppTicketControllerTest extends TestCase $this->grantTicketsMenu($tenant); Sanctum::actingAs($admin); [$ticket, $purchaseItem] = $this->createRefundableTicket($tenant, $admin, '100.00'); + $inventory = $ticket->sourceCatalogItem->inventory; $this->postJson("/api/v1/adminapp/tenant/tickets/{$ticket->id}/refund", [ 'refund_type' => 'total', @@ -201,6 +202,9 @@ class AdminAppTicketControllerTest extends TestCase ->assertJsonPath('data.refund.created_by', $admin->nombre_apellido); $this->assertNotNull($ticket->fresh()->refunded_at); + $this->assertSame(1, $inventory->fresh()->real_stock); + $this->assertSame(1, $inventory->fresh()->sold_units); + $this->assertSame(1, $inventory->fresh()->refunded_units); $this->assertDatabaseHas('ticket_refunds', [ 'ticket_id' => $ticket->id, 'purchase_item_id' => $purchaseItem->id, @@ -208,6 +212,13 @@ class AdminAppTicketControllerTest extends TestCase 'type' => TicketRefund::TYPE_TOTAL, 'amount' => '100.00', ]); + + $this->postJson("/api/v1/adminapp/tenant/tickets/{$ticket->id}/refund", [ + 'refund_type' => 'total', + ])->assertUnprocessable(); + $this->assertSame(1, $inventory->fresh()->real_stock); + $this->assertSame(1, $inventory->fresh()->refunded_units); + $this->assertSame(1, $purchaseItem->ticketRefunds()->count()); } public function test_it_partially_refunds_a_ticket_using_the_tenant_percentage(): void @@ -222,6 +233,7 @@ class AdminAppTicketControllerTest extends TestCase $this->grantTicketsMenu($tenant); Sanctum::actingAs($admin); [$ticket, $purchaseItem] = $this->createRefundableTicket($tenant, $admin, '100.00'); + $inventory = $ticket->sourceCatalogItem->inventory; $this->postJson("/api/v1/adminapp/tenant/tickets/{$ticket->id}/refund", [ 'refund_type' => 'partial', @@ -238,6 +250,47 @@ class AdminAppTicketControllerTest extends TestCase 'type' => TicketRefund::TYPE_PARTIAL, 'amount' => '25.50', ]); + $this->assertSame(1, $inventory->fresh()->real_stock); + $this->assertSame(1, $inventory->fresh()->refunded_units); + } + + public function test_existing_refunds_do_not_increment_the_new_refunded_units_counter(): void + { + $tenant = $this->createTenant('ticket-historical-refund'); + $tenant->update([ + 'allow_ticket_refund' => true, + 'allow_ticket_total_refund' => true, + ]); + $admin = $this->createAdminAppUser($tenant); + $this->grantTicketsMenu($tenant); + Sanctum::actingAs($admin); + [$historicalTicket, $purchaseItem] = $this->createRefundableTicket($tenant, $admin, '100.00'); + $inventory = $historicalTicket->sourceCatalogItem->inventory; + $inventory->update(['sold_units' => 2]); + $purchaseItem->update(['cantidad' => 2, 'total' => '200.00']); + $historicalTicket->markAsRefunded(); + $historicalTicket->save(); + TicketRefund::query()->create([ + 'ticket_id' => $historicalTicket->id, + 'purchase_item_id' => $purchaseItem->id, + 'type' => TicketRefund::TYPE_TOTAL, + 'amount' => '100.00', + ]); + + $this->assertSame(0, $inventory->fresh()->refunded_units); + $this->assertSame(0, $inventory->fresh()->real_stock); + + $newTicket = $this->createTicket($tenant, $admin, [ + 'source_purchase_item_id' => $purchaseItem->id, + 'source_catalog_item_id' => $historicalTicket->source_catalog_item_id, + ]); + $this->postJson("/api/v1/adminapp/tenant/tickets/{$newTicket->id}/refund", [ + 'refund_type' => TicketRefund::TYPE_TOTAL, + ])->assertOk(); + + $this->assertSame(2, $inventory->fresh()->sold_units); + $this->assertSame(1, $inventory->fresh()->refunded_units); + $this->assertSame(1, $inventory->fresh()->real_stock); } public function test_it_records_different_refund_types_for_tickets_from_the_same_purchase_item(): void @@ -1089,8 +1142,10 @@ class AdminAppTicketControllerTest extends TestCase /** @return array{Ticket, PurchaseItem} */ private function createRefundableTicket(Tenant $tenant, User $admin, string $amount): array { + $inventory = Inventory::query()->create(['sold_units' => 1]); $catalogItem = CatalogItem::query()->create([ 'tenant_code' => $tenant->codigo, + 'inventory_id' => $inventory->id, 'slug' => 'ticket-reembolsable-'.Str::uuid(), 'nombre' => 'Ticket reembolsable', 'precio' => $amount,