diff --git a/app/Domains/Purchase/Controllers/PurchaseController.php b/app/Domains/Purchase/Controllers/PurchaseController.php new file mode 100644 index 0000000..af7085c --- /dev/null +++ b/app/Domains/Purchase/Controllers/PurchaseController.php @@ -0,0 +1,129 @@ +with(['items.variant.product', 'items.variant.definitions']) + ->where('tenant_codigo', $tenant->codigo) + ->where('user_id', $request->user()->id) + ->latest() + ->get() + )->response(); + } + + public function store(StorePurchaseRequest $request, Tenant $tenant): JsonResponse + { + $data = $request->validated(); + $items = $data['items']; + + unset($data['items']); + + $variants = $this->resolveTenantVariants($tenant, $items); + $purchaseItems = $this->buildPurchaseItemsPayload($items, $variants); + + $purchase = DB::transaction(function () use ($request, $tenant, $data, $purchaseItems): Purchase { + /** @var Purchase $purchase */ + $purchase = Purchase::query()->create([ + ...$data, + 'tenant_codigo' => $tenant->codigo, + 'user_id' => $request->user()->id, + ]); + + $purchase->items()->createMany($purchaseItems); + + return $purchase->load(['items.variant.product', 'items.variant.definitions']); + }); + + return PurchaseResource::make($purchase)->response()->setStatusCode(201); + } + + public function show(Request $request, Tenant $tenant, Purchase $compra): PurchaseResource + { + $compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra); + + return PurchaseResource::make( + $compra->loadMissing(['items.variant.product', 'items.variant.definitions']) + ); + } + + /** + * @param array> $items + * @return \Illuminate\Support\Collection + */ + protected function resolveTenantVariants(Tenant $tenant, array $items) + { + $variantIds = collect($items) + ->pluck('producto_variante_id') + ->filter() + ->map(static fn (mixed $id): int => (int) $id) + ->unique() + ->values(); + + $variants = ProductVariant::query() + ->with('product') + ->whereIn('id', $variantIds) + ->whereHas('product', fn ($query) => $query->where('tenant_codigo', $tenant->codigo)) + ->get() + ->keyBy('id'); + + if ($variants->count() !== $variantIds->count()) { + throw ValidationException::withMessages([ + 'items' => 'One or more product variants do not belong to the tenant.', + ]); + } + + return $variants; + } + + /** + * @param array> $items + * @param \Illuminate\Support\Collection $variants + * @return array> + */ + protected function buildPurchaseItemsPayload(array $items, $variants): array + { + return collect($items) + ->map(function (array $item) use ($variants): array { + /** @var ProductVariant $variant */ + $variant = $variants->get((int) $item['producto_variante_id']); + $quantity = (int) $item['cantidad']; + $unitPrice = (float) $variant->precio; + + return [ + 'producto_variante_id' => $variant->getKey(), + 'cantidad' => $quantity, + 'precio_unitario' => $unitPrice, + 'discount_total' => null, + 'tax_total' => null, + 'total' => $unitPrice * $quantity, + ]; + }) + ->all(); + } + + protected function resolveScopedPurchase(Tenant $tenant, int $userId, Purchase $purchase): Purchase + { + if ($purchase->tenant_codigo !== $tenant->codigo || $purchase->user_id !== $userId) { + throw new NotFoundHttpException('Purchase not found for tenant.'); + } + + return $purchase; + } +} diff --git a/app/Domains/Purchase/Models/Purchase.php b/app/Domains/Purchase/Models/Purchase.php new file mode 100644 index 0000000..b69fb0d --- /dev/null +++ b/app/Domains/Purchase/Models/Purchase.php @@ -0,0 +1,56 @@ + 'integer', + ]; + } + + /** + * @return BelongsTo + */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo'); + } + + /** + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * @return HasMany + */ + public function items(): HasMany + { + return $this->hasMany(PurchaseItem::class, 'compra_id'); + } +} diff --git a/app/Domains/Purchase/Models/PurchaseItem.php b/app/Domains/Purchase/Models/PurchaseItem.php new file mode 100644 index 0000000..c06f665 --- /dev/null +++ b/app/Domains/Purchase/Models/PurchaseItem.php @@ -0,0 +1,54 @@ + 'integer', + 'producto_variante_id' => 'integer', + 'cantidad' => 'integer', + 'precio_unitario' => 'decimal:2', + 'discount_total' => 'decimal:2', + 'tax_total' => 'decimal:2', + 'total' => 'decimal:2', + ]; + } + + /** + * @return BelongsTo + */ + public function purchase(): BelongsTo + { + return $this->belongsTo(Purchase::class, 'compra_id'); + } + + /** + * @return BelongsTo + */ + public function variant(): BelongsTo + { + return $this->belongsTo(ProductVariant::class, 'producto_variante_id'); + } +} diff --git a/app/Domains/Purchase/Requests/StorePurchaseRequest.php b/app/Domains/Purchase/Requests/StorePurchaseRequest.php new file mode 100644 index 0000000..b4120e6 --- /dev/null +++ b/app/Domains/Purchase/Requests/StorePurchaseRequest.php @@ -0,0 +1,29 @@ +user() !== null; + } + + /** + * @return array + */ + public function rules(): array + { + return [ + 'status' => ['sometimes', 'string', Rule::in(['pending', 'paid', 'cancelled'])], + 'payment_status' => ['sometimes', 'string', Rule::in(['pending', 'approved', 'rejected'])], + 'payment_method' => ['nullable', 'string', 'max:255'], + 'items' => ['required', 'array', 'min:1'], + 'items.*.producto_variante_id' => ['required', 'integer', 'exists:productos_variantes,id'], + 'items.*.cantidad' => ['required', 'integer', 'min:1'], + ]; + } +} diff --git a/app/Domains/Purchase/Resources/PurchaseItemResource.php b/app/Domains/Purchase/Resources/PurchaseItemResource.php new file mode 100644 index 0000000..8394001 --- /dev/null +++ b/app/Domains/Purchase/Resources/PurchaseItemResource.php @@ -0,0 +1,49 @@ + + */ + public function toArray(Request $request): array + { + $variant = $this->variant; + $product = $variant?->product; + + return [ + 'id' => $this->id, + 'cantidad' => $this->cantidad, + 'precio_unitario' => $this->formatMoney($this->precio_unitario), + 'total' => $this->formatMoney($this->total), + 'product' => $product === null ? null : [ + 'id' => $product->id, + 'nombre' => $product->nombre, + 'slug' => $product->slug, + ], + 'variant' => $variant === null ? null : [ + 'id' => $variant->id, + 'nombre' => $variant->nombre, + 'slug' => $variant->slug, + 'definitions' => ProductVariantDefinitionResource::collection($variant->definitions), + ], + ]; + } + + protected function formatMoney(float|int|string|null $amount): ?string + { + if ($amount === null) { + return null; + } + + return number_format((float) $amount, 2, '.', ''); + } +} diff --git a/app/Domains/Purchase/Resources/PurchaseResource.php b/app/Domains/Purchase/Resources/PurchaseResource.php new file mode 100644 index 0000000..8a51e40 --- /dev/null +++ b/app/Domains/Purchase/Resources/PurchaseResource.php @@ -0,0 +1,49 @@ + + */ + public function toArray(Request $request): array + { + $items = $this->resource->relationLoaded('items') + ? $this->resource->getRelation('items') + : collect(); + + $subtotal = $items->reduce( + fn (float $carry, $item): float => $carry + ((float) $item->precio_unitario * $item->cantidad), + 0.0, + ); + + $total = $items->reduce( + fn (float $carry, $item): float => $carry + (float) $item->total, + 0.0, + ); + + return [ + 'id' => $this->id, + 'tenant_codigo' => $this->tenant_codigo, + 'user_id' => $this->user_id, + 'status' => $this->status, + 'payment_status' => $this->payment_status, + 'payment_method' => $this->payment_method, + 'items' => PurchaseItemResource::collection($items), + 'subtotal' => $this->formatMoney($subtotal), + 'total' => $this->formatMoney($total), + ]; + } + + protected function formatMoney(float|int|string|null $amount): string + { + return number_format((float) ($amount ?? 0), 2, '.', ''); + } +} diff --git a/app/Domains/Purchase/routes/api.php b/app/Domains/Purchase/routes/api.php new file mode 100644 index 0000000..ed5f43b --- /dev/null +++ b/app/Domains/Purchase/routes/api.php @@ -0,0 +1,8 @@ +middleware('auth:sanctum')->group(function (): void { + Route::apiResource('compras', PurchaseController::class)->only(['index', 'store', 'show']); +}); diff --git a/database/migrations/2026_06_25_000420_create_compras_table.php b/database/migrations/2026_06_25_000420_create_compras_table.php new file mode 100644 index 0000000..5b6e634 --- /dev/null +++ b/database/migrations/2026_06_25_000420_create_compras_table.php @@ -0,0 +1,38 @@ +id(); + $table->string('tenant_codigo'); + $table->foreignId('user_id')->nullable()->constrained('users')->cascadeOnUpdate()->nullOnDelete(); + $table->enum('status', ['pending', 'paid', 'cancelled'])->default('pending'); + $table->enum('payment_status', ['pending', 'approved', 'rejected'])->default('pending'); + $table->string('payment_method')->nullable(); + $table->timestamps(); + + $table->foreign('tenant_codigo') + ->references('codigo') + ->on('tenants') + ->cascadeOnUpdate() + ->restrictOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('compras'); + } +}; diff --git a/database/migrations/2026_06_25_000430_create_compra_items_table.php b/database/migrations/2026_06_25_000430_create_compra_items_table.php new file mode 100644 index 0000000..3e3b21f --- /dev/null +++ b/database/migrations/2026_06_25_000430_create_compra_items_table.php @@ -0,0 +1,40 @@ +id(); + $table->foreignId('compra_id')->constrained('compras')->cascadeOnUpdate()->cascadeOnDelete(); + $table->unsignedBigInteger('producto_variante_id'); + $table->unsignedInteger('cantidad'); + $table->decimal('precio_unitario', 10, 2); + $table->decimal('discount_total', 10, 2)->nullable(); + $table->decimal('tax_total', 10, 2)->nullable(); + $table->decimal('total', 10, 2); + $table->timestamps(); + + $table->foreign('producto_variante_id') + ->references('id') + ->on('productos_variantes') + ->cascadeOnUpdate() + ->restrictOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('compra_items'); + } +}; diff --git a/routes/api.php b/routes/api.php index 5433745..7d8479d 100644 --- a/routes/api.php +++ b/routes/api.php @@ -10,4 +10,5 @@ Route::get('/user', function (Request $request) { require __DIR__.'/../app/Domains/Catalog/routes/api.php'; require __DIR__.'/../app/Domains/Cart/routes/api.php'; require __DIR__.'/../app/Domains/StorageTest/routes/api.php'; +require __DIR__.'/../app/Domains/Purchase/routes/api.php'; require __DIR__.'/../app/Domains/Tenant/routes/api.php';