diff --git a/app/Domains/Purchase/Controllers/PurchaseController.php b/app/Domains/Purchase/Controllers/PurchaseController.php index 4de632a..e976891 100644 --- a/app/Domains/Purchase/Controllers/PurchaseController.php +++ b/app/Domains/Purchase/Controllers/PurchaseController.php @@ -19,7 +19,6 @@ class PurchaseController extends Controller { return PurchaseResource::collection( Purchase::query() - ->with(['items.variant.product', 'items.variant.definitions.productAttribute.attribute']) ->where('tenant_codigo', $tenant->codigo) ->where('user_id', $request->user()->id) ->when($request->query('status'), function ($query, $status) { @@ -48,7 +47,7 @@ class PurchaseController extends Controller $compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra); return PurchaseResource::make( - $compra->loadMissing(['items.variant.product', 'items.variant.definitions.productAttribute.attribute']) + $compra->loadMissing($this->purchaseDetailRelations()) ); } @@ -140,4 +139,19 @@ class PurchaseController extends Controller return $purchase; } + + /** + * @return list + */ + protected function purchaseDetailRelations(): array + { + return [ + 'items.variant.product', + 'items.variant.definitions.productAttribute.attribute', + 'items.variant.attachments', + 'cart.items.variant.product', + 'cart.items.variant.definitions.productAttribute.attribute', + 'cart.items.variant.attachments', + ]; + } } diff --git a/app/Domains/Purchase/Resources/PurchaseItemResource.php b/app/Domains/Purchase/Resources/PurchaseItemResource.php index 688bc8c..d26a5bc 100644 --- a/app/Domains/Purchase/Resources/PurchaseItemResource.php +++ b/app/Domains/Purchase/Resources/PurchaseItemResource.php @@ -2,12 +2,13 @@ namespace App\Domains\Purchase\Resources; -use App\Domains\Catalog\Resources\ProductVariantDefinitionResource; +use App\Domains\Cart\Models\CartItem; +use App\Domains\Purchase\Models\PurchaseItem; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; /** - * @mixin \App\Domains\Purchase\Models\PurchaseItem + * @mixin \App\Domains\Purchase\Models\PurchaseItem|\App\Domains\Cart\Models\CartItem */ class PurchaseItemResource extends JsonResource { @@ -18,24 +19,90 @@ class PurchaseItemResource extends JsonResource { $variant = $this->variant; $product = $variant?->product; + $quantity = (int) ($this->cantidad ?? 0); + $unitPrice = $this->resolveUnitPrice(); + $lineTotal = $this->resolveLineTotal($unitPrice, $quantity); return [ 'id' => $this->id, - 'cantidad' => $this->cantidad, - 'precio_unitario' => $this->formatMoney($this->precio_unitario), - 'total' => $this->formatMoney($this->total), + 'quantity' => $quantity, + 'unit_price' => $this->formatMoney($unitPrice), + 'line_total' => $this->formatMoney($lineTotal), 'product' => $product === null ? null : [ 'id' => $product->id, 'nombre' => $product->nombre, 'slug' => $product->slug, + 'imagen' => $this->resolveImageUrl(), ], 'variant' => $variant === null ? null : [ 'id' => $variant->id, - 'definitions' => ProductVariantDefinitionResource::collection($variant->definitions), + 'attributes' => $this->resolveAttributes(), ], ]; } + protected function resolveUnitPrice(): float + { + if ($this->resource instanceof PurchaseItem) { + return (float) ($this->precio_unitario ?? 0); + } + + if ($this->resource instanceof CartItem) { + return (float) ($this->variant?->product?->precio ?? 0); + } + + return 0.0; + } + + protected function resolveLineTotal(float $unitPrice, int $quantity): float + { + if ($this->resource instanceof PurchaseItem) { + return (float) ($this->total ?? 0); + } + + return $unitPrice * $quantity; + } + + protected function resolveImageUrl(): ?string + { + $variant = $this->variant; + + if ($variant === null || ! $variant->relationLoaded('attachments')) { + return null; + } + + $attachment = $variant->attachments->first(); + + if ($attachment === null) { + return null; + } + + return $attachment->getTemporaryUrl(1440); + } + + /** + * @return array + */ + protected function resolveAttributes(): array + { + $variant = $this->variant; + + if ($variant === null || ! $variant->relationLoaded('definitions')) { + return []; + } + + return $variant->definitions + ->map(function ($definition): array { + return [ + 'name' => (string) ($definition->productAttribute?->attribute?->nombre ?? ''), + 'value' => $definition->value, + ]; + }) + ->filter(fn (array $attribute): bool => $attribute['name'] !== '' || $attribute['value'] !== null) + ->values() + ->all(); + } + protected function formatMoney(float|int|string|null $amount): ?string { if ($amount === null) { diff --git a/app/Domains/Purchase/Resources/PurchaseResource.php b/app/Domains/Purchase/Resources/PurchaseResource.php index fdc32fc..d1a8537 100644 --- a/app/Domains/Purchase/Resources/PurchaseResource.php +++ b/app/Domains/Purchase/Resources/PurchaseResource.php @@ -2,8 +2,11 @@ namespace App\Domains\Purchase\Resources; +use App\Domains\Cart\Models\CartItem; +use App\Domains\Purchase\Models\PurchaseItem; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; +use Illuminate\Support\Collection; /** * @mixin \App\Domains\Purchase\Models\Purchase @@ -15,20 +18,18 @@ class PurchaseResource extends JsonResource */ public function toArray(Request $request): array { - $items = $this->resource->relationLoaded('items') - ? $this->resource->getRelation('items') - : collect(); + [$items, $itemsSource] = $this->resolveItems(); $subtotal = $items->isNotEmpty() ? $items->reduce( - fn (float $carry, $item): float => $carry + ((float) $item->precio_unitario * $item->cantidad), + fn (float $carry, PurchaseItem|CartItem $item): float => $carry + $this->resolveItemSubtotal($item), 0.0, ) : (float) ($this->total ?? 0); $total = $items->isNotEmpty() ? $items->reduce( - fn (float $carry, $item): float => $carry + (float) $item->total, + fn (float $carry, PurchaseItem|CartItem $item): float => $carry + $this->resolveItemTotal($item), 0.0, ) : (float) ($this->total ?? 0); @@ -38,18 +39,66 @@ class PurchaseResource extends JsonResource 'cart_id' => $this->cart_id, 'tenant_codigo' => $this->tenant_codigo, 'user_id' => $this->user_id, + 'created_at' => $this->created_at, 'status' => $this->status, 'payment_method' => $this->payment_method, 'dni' => $this->dni, 'telefono' => $this->telefono, 'nombre_apellido' => $this->nombre_apellido, 'email' => $this->email, + 'items_source' => $itemsSource, 'items' => PurchaseItemResource::collection($items), 'subtotal' => $this->formatMoney($subtotal), 'total' => $this->formatMoney($total), ]; } + /** + * @return array{0: Collection, 1: string|null} + */ + protected function resolveItems(): array + { + if (! $this->resource->relationLoaded('items')) { + return [collect(), null]; + } + + $purchaseItems = $this->resource->getRelation('items'); + + if ($purchaseItems->isNotEmpty()) { + return [$purchaseItems, 'purchase']; + } + + if (! $this->resource->relationLoaded('cart')) { + return [collect(), null]; + } + + $cart = $this->resource->getRelation('cart'); + + if ($cart === null || ! $cart->relationLoaded('items')) { + return [collect(), null]; + } + + return [$cart->getRelation('items'), 'cart']; + } + + protected function resolveItemSubtotal(PurchaseItem|CartItem $item): float + { + if ($item instanceof PurchaseItem) { + return (float) $item->precio_unitario * $item->cantidad; + } + + return (float) ($item->variant?->product?->precio ?? 0) * $item->cantidad; + } + + protected function resolveItemTotal(PurchaseItem|CartItem $item): float + { + if ($item instanceof PurchaseItem) { + return (float) ($item->total ?? 0); + } + + return $this->resolveItemSubtotal($item); + } + protected function formatMoney(float|int|string|null $amount): string { return number_format((float) ($amount ?? 0), 2, '.', ''); diff --git a/tests/Feature/Purchase/StorePurchaseTest.php b/tests/Feature/Purchase/StorePurchaseTest.php index 6e91cee..f830af0 100644 --- a/tests/Feature/Purchase/StorePurchaseTest.php +++ b/tests/Feature/Purchase/StorePurchaseTest.php @@ -7,6 +7,7 @@ use App\Domains\Cart\Models\Cart; use App\Domains\Catalog\Models\Product; use App\Domains\Catalog\Models\ProductVariant; use App\Domains\Purchase\Models\Purchase; +use App\Domains\Purchase\Services\CheckoutService; use App\Domains\Tenant\Models\Tenant; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; @@ -76,6 +77,7 @@ class StorePurchaseTest extends TestCase $response->assertJsonPath('data.email', 'juan.perez@example.com'); $response->assertJsonPath('data.tenant_codigo', 'sonder'); $response->assertJsonPath('data.status', Purchase::STATUS_CREATED); + $response->assertJsonPath('data.items_source', null); $response->assertJsonPath('data.items', []); $response->assertJsonPath('data.subtotal', '100.00'); $response->assertJsonPath('data.total', '100.00'); @@ -159,6 +161,150 @@ class StorePurchaseTest extends TestCase ]); } + public function test_purchase_detail_uses_cart_items_for_created_purchase(): void + { + $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); + $user = User::factory()->create([ + 'email' => 'buyer@example.com', + ]); + $variant = $this->createVariantForTenant('sonder', 10, '50.00'); + $purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2); + + $this->actingAs($user, 'sanctum') + ->getJson("/api/tenants/sonder/compras/{$purchase->id}") + ->assertOk() + ->assertJsonPath('data.status', Purchase::STATUS_CREATED) + ->assertJsonPath('data.items_source', 'cart') + ->assertJsonCount(1, 'data.items') + ->assertJsonPath('data.items.0.quantity', 2) + ->assertJsonPath('data.items.0.unit_price', '50.00') + ->assertJsonPath('data.items.0.line_total', '100.00') + ->assertJsonPath('data.items.0.product.id', $variant->product->id) + ->assertJsonPath('data.items.0.product.nombre', $variant->product->nombre) + ->assertJsonPath('data.items.0.product.slug', $variant->product->slug) + ->assertJsonPath('data.items.0.product.imagen', null) + ->assertJsonPath('data.items.0.variant.id', $variant->id) + ->assertJsonPath('data.items.0.variant.attributes', []) + ->assertJsonPath('data.subtotal', '100.00') + ->assertJsonPath('data.total', '100.00'); + } + + public function test_purchase_detail_uses_cart_items_for_pending_payment_purchase(): void + { + $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); + $user = User::factory()->create([ + 'email' => 'buyer@example.com', + ]); + $variant = $this->createVariantForTenant('sonder', 10, '50.00'); + $purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2); + + $purchase->update([ + 'payment_method' => 'transfer', + ]); + + app(CheckoutService::class)->completePurchase($purchase); + + $this->actingAs($user, 'sanctum') + ->getJson("/api/tenants/sonder/compras/{$purchase->id}") + ->assertOk() + ->assertJsonPath('data.status', Purchase::STATUS_PENDING_PAYMENT) + ->assertJsonPath('data.items_source', 'cart') + ->assertJsonCount(1, 'data.items') + ->assertJsonPath('data.items.0.quantity', 2) + ->assertJsonPath('data.items.0.unit_price', '50.00') + ->assertJsonPath('data.items.0.line_total', '100.00') + ->assertJsonPath('data.items.0.product.imagen', null) + ->assertJsonPath('data.items.0.variant.attributes', []) + ->assertJsonPath('data.subtotal', '100.00') + ->assertJsonPath('data.total', '100.00'); + } + + public function test_purchase_detail_uses_purchase_items_for_paid_purchase_even_without_cart(): void + { + $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); + $user = User::factory()->create([ + 'email' => 'buyer@example.com', + ]); + $variant = $this->createVariantForTenant('sonder', 10, '50.00'); + $purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2); + + $purchase->update([ + 'payment_method' => 'transfer', + ]); + + $checkoutService = app(CheckoutService::class); + $purchase = $checkoutService->completePurchase($purchase); + $checkoutService->confirmPurchase($purchase); + $purchase->refresh()->markAsPaid(); + + $this->assertSoftDeleted('carritos', [ + 'id' => $purchase->cart_id, + ]); + + $this->actingAs($user, 'sanctum') + ->getJson("/api/tenants/sonder/compras/{$purchase->id}") + ->assertOk() + ->assertJsonPath('data.status', Purchase::STATUS_PAID) + ->assertJsonPath('data.items_source', 'purchase') + ->assertJsonCount(1, 'data.items') + ->assertJsonPath('data.items.0.quantity', 2) + ->assertJsonPath('data.items.0.unit_price', '50.00') + ->assertJsonPath('data.items.0.line_total', '100.00') + ->assertJsonPath('data.items.0.product.id', $variant->product->id) + ->assertJsonPath('data.items.0.product.imagen', null) + ->assertJsonPath('data.items.0.variant.id', $variant->id) + ->assertJsonPath('data.items.0.variant.attributes', []) + ->assertJsonPath('data.subtotal', '100.00') + ->assertJsonPath('data.total', '100.00'); + } + + public function test_purchase_detail_prefers_purchase_items_when_both_sources_exist(): void + { + $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); + $user = User::factory()->create([ + 'email' => 'buyer@example.com', + ]); + $variant = $this->createVariantForTenant('sonder', 10, '50.00'); + $purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2); + + $purchase->items()->create([ + 'producto_variante_id' => $variant->id, + 'cantidad' => 1, + 'precio_unitario' => '50.00', + 'discount_total' => null, + 'tax_total' => null, + 'total' => '50.00', + ]); + + $this->actingAs($user, 'sanctum') + ->getJson("/api/tenants/sonder/compras/{$purchase->id}") + ->assertOk() + ->assertJsonPath('data.items_source', 'purchase') + ->assertJsonCount(1, 'data.items') + ->assertJsonPath('data.items.0.quantity', 1) + ->assertJsonPath('data.items.0.line_total', '50.00') + ->assertJsonPath('data.subtotal', '50.00') + ->assertJsonPath('data.total', '50.00'); + } + + public function test_purchase_index_returns_empty_items_without_loaded_relations(): void + { + $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); + $user = User::factory()->create([ + 'email' => 'buyer@example.com', + ]); + $variant = $this->createVariantForTenant('sonder', 10, '50.00'); + $this->createCheckoutPurchase($user, 'sonder', $variant, 2); + + $this->actingAs($user, 'sanctum') + ->getJson('/api/tenants/sonder/compras?status=created') + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.items_source', null) + ->assertJsonPath('data.0.items', []) + ->assertJsonPath('data.0.total', '100.00'); + } + public function test_it_rejects_a_cart_from_another_user(): void { $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); @@ -290,6 +436,30 @@ class StorePurchaseTest extends TestCase ])->load('product'); } + protected function createCheckoutPurchase( + User $user, + string $tenantCode, + ProductVariant $variant, + int $quantity, + ): Purchase { + $tenant = Tenant::query()->where('codigo', $tenantCode)->firstOrFail(); + $cart = Cart::query()->create([ + 'tenant_codigo' => $tenantCode, + 'user_id' => $user->id, + 'status' => 'active', + ]); + + $cart->addItem($variant->id, $quantity); + + return app(CheckoutService::class)->startCheckout($tenant, $user->id, [ + 'cart_id' => $cart->id, + 'dni' => '987654321', + 'telefono' => '+54 9 341 555-4321', + 'nombre_apellido' => 'Juan Perez', + 'email' => 'juan.perez@example.com', + ]); + } + protected function createTenant(string $codigo, string $nombre, string $dominio): Tenant { $hdrKey = (string) \Illuminate\Support\Str::uuid();