diff --git a/app/Domains/Purchase/Events/PurchasePaid.php b/app/Domains/Purchase/Events/PurchasePaid.php new file mode 100644 index 0000000..3eed891 --- /dev/null +++ b/app/Domains/Purchase/Events/PurchasePaid.php @@ -0,0 +1,14 @@ +update([ - 'status' => self::STATUS_PAID, - ]); + DB::transaction(function (): void { + $currentStatus = self::query() + ->whereKey($this->getKey()) + ->lockForUpdate() + ->value('status'); + + if ($currentStatus === self::STATUS_PAID) { + $this->status = self::STATUS_PAID; + + return; + } + + $this->update([ + 'status' => self::STATUS_PAID, + ]); + + PurchasePaid::dispatch($this); + }); } } diff --git a/app/Domains/Ticket/Exceptions/TicketGenerationException.php b/app/Domains/Ticket/Exceptions/TicketGenerationException.php index 2a9c58e..0e86f1f 100644 --- a/app/Domains/Ticket/Exceptions/TicketGenerationException.php +++ b/app/Domains/Ticket/Exceptions/TicketGenerationException.php @@ -3,6 +3,8 @@ namespace App\Domains\Ticket\Exceptions; use App\Domains\Catalog\Models\CatalogItem; +use App\Domains\Purchase\Models\Purchase; +use App\Domains\Purchase\Models\PurchaseItem; use RuntimeException; class TicketGenerationException extends RuntimeException @@ -26,4 +28,14 @@ class TicketGenerationException extends RuntimeException { return new self("El producto {$catalogItem->id} alcanzó su fecha máxima de uso."); } + + public static function purchaseWithoutUser(Purchase $purchase): self + { + return new self("La compra {$purchase->id} no tiene un usuario asociado."); + } + + public static function catalogItemNotFound(PurchaseItem $purchaseItem): self + { + return new self("No se encontró el producto de la línea de compra {$purchaseItem->id}."); + } } diff --git a/app/Domains/Ticket/Listeners/GenerateTicketsForPaidPurchase.php b/app/Domains/Ticket/Listeners/GenerateTicketsForPaidPurchase.php new file mode 100644 index 0000000..990fc31 --- /dev/null +++ b/app/Domains/Ticket/Listeners/GenerateTicketsForPaidPurchase.php @@ -0,0 +1,62 @@ +purchase + ->newQuery() + ->with(['user', 'items']) + ->findOrFail($event->purchase->getKey()); + $user = $purchase->user; + + foreach ($purchase->items as $purchaseItem) { + $catalogItem = CatalogItem::query() + ->where('tenant_code', $purchase->tenant_codigo) + ->find($purchaseItem->source_catalog_item_id); + + if ($catalogItem === null) { + throw TicketGenerationException::catalogItemNotFound($purchaseItem); + } + + if (! $this->requiresTickets($catalogItem)) { + continue; + } + + if ($user === null) { + throw TicketGenerationException::purchaseWithoutUser($purchase); + } + + $this->ticketGenerator->generate( + $catalogItem, + $user, + $purchaseItem->cantidad, + ); + } + } + + private function requiresTickets(CatalogItem $catalogItem): bool + { + if (! $catalogItem->isBundle()) { + return $catalogItem->has_tickets; + } + + return $catalogItem->bundleComponents() + ->whereHas( + 'catalogItem', + fn ($query) => $query->where('has_tickets', true), + ) + ->exists(); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 8a804f3..68d963e 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,7 +2,10 @@ namespace App\Providers; +use App\Domains\Purchase\Events\PurchasePaid; +use App\Domains\Ticket\Listeners\GenerateTicketsForPaidPurchase; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Support\Facades\Event; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider @@ -20,6 +23,8 @@ class AppServiceProvider extends ServiceProvider */ public function boot(): void { + Event::listen(PurchasePaid::class, GenerateTicketsForPaidPurchase::class); + Builder::macro('paginateFromRequest', function (int $defaultPerPage = 15, int $maxPerPage = 100, ?int $page = null) { /** @var Builder $this */ $perPage = (int) request()->query('per_page', $defaultPerPage); diff --git a/tests/Feature/Ticket/TicketGeneratorServiceTest.php b/tests/Feature/Ticket/TicketGeneratorServiceTest.php index 88496fe..990cf18 100644 --- a/tests/Feature/Ticket/TicketGeneratorServiceTest.php +++ b/tests/Feature/Ticket/TicketGeneratorServiceTest.php @@ -7,9 +7,11 @@ use App\Domains\Attachable\Models\Attachment; use App\Domains\Auth\Models\User; use App\Domains\Catalog\Enums\CatalogItemType; use App\Domains\Catalog\Models\CatalogItem; +use App\Domains\Purchase\Models\Purchase; use App\Domains\Tenant\Models\Tenant; use App\Domains\Ticket\Exceptions\TicketGenerationException; use App\Domains\Ticket\Services\TicketGeneratorService; +use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Carbon; use Illuminate\Support\Str; @@ -121,6 +123,48 @@ class TicketGeneratorServiceTest extends TestCase } } + public function test_marking_a_purchase_as_paid_generates_its_tickets_once(): void + { + $item = $this->createTicketableItem('paid-ticket'); + $purchase = $this->createPurchase($item, 2); + $purchase->setRelation('items', new EloquentCollection); + + $purchase->markAsPaid(); + + $this->assertSame(Purchase::STATUS_PAID, $purchase->status); + $this->assertDatabaseCount('tickets', 2); + + $purchase->markAsPaid(); + + $this->assertDatabaseCount('tickets', 2); + } + + public function test_marking_a_purchase_as_paid_ignores_items_without_tickets(): void + { + $item = $this->createTicketableItem('regular-product'); + $item->update(['has_tickets' => false]); + $purchase = $this->createPurchase($item->fresh(), 1); + + $purchase->markAsPaid(); + + $this->assertSame(Purchase::STATUS_PAID, $purchase->status); + $this->assertDatabaseCount('tickets', 0); + } + + public function test_paid_status_is_rolled_back_when_ticket_generation_fails(): void + { + $item = $this->createTicketableItem('expired-paid-ticket', maximumUseDate: now()); + $purchase = $this->createPurchase($item, 1); + + try { + $purchase->markAsPaid(); + $this->fail('La generación debería haber fallado.'); + } catch (TicketGenerationException) { + $this->assertSame(Purchase::STATUS_PENDING_PAYMENT, $purchase->fresh()->status); + $this->assertDatabaseCount('tickets', 0); + } + } + private function createTicketableItem( string $slug, mixed $minimumUseDate = null, @@ -151,6 +195,35 @@ class TicketGeneratorServiceTest extends TestCase ]); } + private function createPurchase(CatalogItem $catalogItem, int $quantity): Purchase + { + $purchase = Purchase::query()->create([ + 'tenant_codigo' => $this->tenant->codigo, + 'user_id' => $this->user->id, + 'status' => Purchase::STATUS_PENDING_PAYMENT, + 'payment_method' => 'transfer', + 'total' => 10 * $quantity, + ]); + + $purchase->items()->create([ + 'source_catalog_item_id' => $catalogItem->id, + 'source_variant_id' => null, + 'image_attachment_id' => null, + 'nombre' => $catalogItem->nombre, + 'descripcion' => $catalogItem->descripcion, + 'slug' => $catalogItem->slug, + 'item_nombre' => $catalogItem->nombre, + 'variant_attributes' => [], + 'cantidad' => $quantity, + 'precio_unitario' => 10, + 'discount_total' => null, + 'tax_total' => null, + 'total' => 10 * $quantity, + ]); + + return $purchase; + } + private function createTenant(): Tenant { $header = Attachment::query()->create([