From 4a4b88ffe4b94bc4fe11b5495398528937e1811d Mon Sep 17 00:00:00 2001 From: Developer Date: Wed, 1 Jul 2026 16:38:49 +0800 Subject: [PATCH] feat: partner store financial settlements, qr replacements, and tenant resolution fix --- .../V1/Admin/AdminPartnerStoreController.php | 145 +++++- app/Models/PartnerStore.php | 5 + app/Models/StoreInventoryAdjustment.php | 49 ++ app/Models/StoreSettlement.php | 51 ++ app/Services/Store/StoreOperations.php | 145 +++++- bootstrap/app.php | 2 +- database/factories/QrCodeBatchFactory.php | 23 + database/factories/QrCodeFactory.php | 26 ++ database/factories/StoreSaleFactory.php | 25 + database/factories/TenantFactory.php | 24 + ..._081139_create_store_settlements_table.php | 37 ++ ...d_replacement_for_id_to_qr_codes_table.php | 29 ++ ...eate_store_inventory_adjustments_table.php | 27 ++ .../views/admin/partner-stores.blade.php | 440 ++++++++++++++++-- routes/api.php | 6 + .../V1/Store/PartnerStoreSettlementTest.php | 142 ++++++ .../Feature/Api/V1/Store/PartnerStoreTest.php | 81 ++++ 17 files changed, 1203 insertions(+), 54 deletions(-) create mode 100644 app/Models/StoreInventoryAdjustment.php create mode 100644 app/Models/StoreSettlement.php create mode 100644 database/factories/QrCodeBatchFactory.php create mode 100644 database/factories/QrCodeFactory.php create mode 100644 database/factories/StoreSaleFactory.php create mode 100644 database/factories/TenantFactory.php create mode 100644 database/migrations/2026_07_01_081139_create_store_settlements_table.php create mode 100644 database/migrations/2026_07_01_081204_add_replacement_for_id_to_qr_codes_table.php create mode 100644 database/migrations/2026_07_01_100000_create_store_inventory_adjustments_table.php create mode 100644 tests/Feature/Api/V1/Store/PartnerStoreSettlementTest.php diff --git a/app/Http/Controllers/Api/V1/Admin/AdminPartnerStoreController.php b/app/Http/Controllers/Api/V1/Admin/AdminPartnerStoreController.php index 39f1d61..c032fcc 100644 --- a/app/Http/Controllers/Api/V1/Admin/AdminPartnerStoreController.php +++ b/app/Http/Controllers/Api/V1/Admin/AdminPartnerStoreController.php @@ -6,6 +6,8 @@ use App\Http\Controllers\Api\V1\ApiController; use App\Http\Resources\PartnerStoreResource; use App\Models\Household; use App\Models\PartnerStore; +use App\Models\QrCode; +use App\Models\StoreInventoryAdjustment; use App\Services\Store\StoreOperations; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -116,6 +118,8 @@ class AdminPartnerStoreController extends ApiController $store, (int) $data['quantity'], (int) $data['wholesale_price_centavos'], + null, + $request->user() ); return $this->created([ @@ -158,6 +162,76 @@ class AdminPartnerStoreController extends ApiController ], 'Sale recorded'); } + public function reportIssue(Request $request, PartnerStore $store): JsonResponse + { + $data = $request->validate([ + 'serial' => ['required', 'string', 'exists:qr_codes,serial'], + 'reason' => ['required', 'string', 'max:255'], + ]); + + try { + $this->ops->reportDefective( + $store, + $data['serial'], + $data['reason'], + $request->user()->id + ); + } catch (\DomainException $e) { + return $this->fail($e->getMessage(), null, 422); + } + + return $this->ok([ + 'inventory_balance' => $store->fresh()->load('inventory')->inventory?->current_code_balance ?? 0, + ], 'Defective QR reported and inventory adjusted'); + } + + public function adjustInventory(Request $request, PartnerStore $store): JsonResponse + { + $data = $request->validate([ + 'quantity' => ['required', 'integer', 'not_in:0'], + 'reason' => ['required', 'string', 'max:255'], + ]); + + $this->ops->manualAdjustment( + $store, + (int) $data['quantity'], + $data['reason'], + $request->user()->id + ); + + return $this->ok([ + 'inventory_balance' => $store->fresh()->load('inventory')->inventory?->current_code_balance ?? 0, + ], 'Inventory adjusted manually'); + } + + public function inventoryLog(Request $request, PartnerStore $store): JsonResponse + { + $logs = StoreInventoryAdjustment::query() + ->where('store_id', $store->id) + ->with(['user', 'qrCode']) + ->latest('id') + ->paginate(15); + + return $this->ok( + $logs->map(fn ($l) => [ + 'id' => $l->id, + 'type' => $l->type, + 'quantity' => $l->quantity, + 'qr_serial' => $l->qrCode?->serial ?? '—', + 'reason' => $l->reason ?? '—', + 'adjusted_by' => $l->user?->full_name ?? 'System', + 'created_at' => $l->created_at->toIso8601String(), + ]), + null, + [ + 'page' => $logs->currentPage(), + 'per_page' => $logs->perPage(), + 'total' => $logs->total(), + 'last_page' => $logs->lastPage(), + ] + ); + } + public function purchases(Request $request, PartnerStore $store): JsonResponse { $purchases = $store->purchases() @@ -210,18 +284,85 @@ class AdminPartnerStoreController extends ApiController ); } + public function recordPayment(Request $request, PartnerStore $store): JsonResponse + { + $data = $request->validate([ + 'amount_pesos' => ['required', 'numeric', 'min:0.01'], + 'payment_method' => ['required', 'string', 'in:cash,gcash,maya,card'], + 'reference_number' => ['nullable', 'string', 'max:100'], + 'notes' => ['nullable', 'string', 'max:500'], + ]); + + $settlement = $this->ops->recordPayment( + $store, + (int) round($data['amount_pesos'] * 100), + $data['payment_method'], + $data['reference_number'] ?? null, + $data['notes'] ?? null, + $request->user()->id + ); + + return $this->ok([ + 'id' => $settlement->id, + 'balance_due_pesos' => number_format($this->ops->calculateBalanceDue($store) / 100, 2), + ], 'Payment recorded successfully'); + } + + public function settlementHistory(Request $request, PartnerStore $store): JsonResponse + { + $history = $store->settlements() + ->with('recorder') + ->latest('settled_at') + ->paginate(15); + + return $this->ok( + $history->map(fn ($s) => [ + 'id' => $s->id, + 'amount_pesos' => number_format($s->amount_centavos / 100, 2), + 'method' => strtoupper($s->payment_method), + 'reference' => $s->reference_number ?? '—', + 'recorded_by' => $s->recorder?->full_name ?? '—', + 'settled_at' => $s->settled_at->toIso8601String(), + ]), + null, + [ + 'page' => $history->currentPage(), + 'per_page' => $history->perPage(), + 'total' => $history->total(), + 'last_page' => $history->lastPage(), + ] + ); + } + + public function printReplacement(Request $request, PartnerStore $store, QrCode $qrCode): JsonResponse + { + // Safety check: ensure the QR belongs to this store and is a replacement + if ($qrCode->assigned_to_store_id !== $store->id) { + return $this->fail('Unauthorized access to this QR code', null, 403); + } + + // In a real app, this might return a PDF or a signed URL to a PDF. + // For now, we'll return the data needed to render a printable QR. + return $this->ok([ + 'serial' => $qrCode->serial, + 'qr_data' => route('qr.verify', ['serial' => $qrCode->serial]), + ]); + } + public function analytics(PartnerStore $store): JsonResponse { $totalWholesaleCost = (int) $store->purchases()->sum('wholesale_price_centavos'); $totalRetailRevenue = (int) $store->sales()->sum('retail_price_centavos'); $totalCommission = (int) $store->sales()->sum('commission_centavos'); - $grossProfit = $totalRetailRevenue - $totalWholesaleCost; + $totalSettled = (int) $store->settlements()->sum('amount_centavos'); + $balanceDue = $this->ops->calculateBalanceDue($store); return $this->ok([ 'total_wholesale_cost_pesos' => number_format($totalWholesaleCost / 100, 2), 'total_retail_revenue_pesos' => number_format($totalRetailRevenue / 100, 2), 'total_commission_pesos' => number_format($totalCommission / 100, 2), - 'gross_profit_pesos' => number_format($grossProfit / 100, 2), + 'total_settled_pesos' => number_format($totalSettled / 100, 2), + 'balance_due_pesos' => number_format($balanceDue / 100, 2), 'total_issued_codes' => (int) $store->purchases()->sum('quantity'), 'total_sold_codes' => (int) $store->sales()->sum('quantity'), ]); diff --git a/app/Models/PartnerStore.php b/app/Models/PartnerStore.php index 1689f6a..336c116 100644 --- a/app/Models/PartnerStore.php +++ b/app/Models/PartnerStore.php @@ -76,4 +76,9 @@ class PartnerStore extends Model { return $this->hasMany(StoreSale::class, 'store_id'); } + + public function settlements(): HasMany + { + return $this->hasMany(StoreSettlement::class, 'store_id'); + } } diff --git a/app/Models/StoreInventoryAdjustment.php b/app/Models/StoreInventoryAdjustment.php new file mode 100644 index 0000000..f011b3f --- /dev/null +++ b/app/Models/StoreInventoryAdjustment.php @@ -0,0 +1,49 @@ + 'integer', + ]; + } + + public function store(): BelongsTo + { + return $this->belongsTo(PartnerStore::class, 'store_id'); + } + + public function qrCode(): BelongsTo + { + return $this->belongsTo(QrCode::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'adjusted_by_user_id'); + } +} diff --git a/app/Models/StoreSettlement.php b/app/Models/StoreSettlement.php new file mode 100644 index 0000000..70d5b6a --- /dev/null +++ b/app/Models/StoreSettlement.php @@ -0,0 +1,51 @@ + 'integer', + 'settled_at' => 'datetime', + ]; + } + + protected static function booted(): void + { + static::creating(function (self $s): void { + if (empty($s->uuid)) { + $s->uuid = (string) Str::uuid(); + } + }); + } + + public function store(): BelongsTo + { + return $this->belongsTo(PartnerStore::class, 'store_id'); + } + + public function recorder(): BelongsTo + { + return $this->belongsTo(User::class, 'recorded_by_user_id'); + } +} diff --git a/app/Services/Store/StoreOperations.php b/app/Services/Store/StoreOperations.php index 56aa17f..6c13232 100644 --- a/app/Services/Store/StoreOperations.php +++ b/app/Services/Store/StoreOperations.php @@ -9,11 +9,15 @@ use App\Models\QrCodeBatch; use App\Models\StoreInventory; use App\Models\StorePurchase; use App\Models\StoreSale; +use App\Models\StoreSettlement; +use App\Models\User; use App\Notifications\CodesPurchased; use App\Services\Qr\BatchGenerator; use App\States\QrCode\Active; use App\States\QrCode\Allocated; use App\States\QrCode\Unassigned; +use App\Models\StoreInventoryAdjustment; +use App\States\QrCode\Voided; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Notification; @@ -31,14 +35,15 @@ class StoreOperations int $quantity, int $wholesalePriceCentavos, ?\DateTimeInterface $paidAt = null, + $userId = null, ): StorePurchase { - return DB::transaction(function () use ($store, $quantity, $wholesalePriceCentavos, $paidAt) { + return DB::transaction(function () use ($store, $quantity, $wholesalePriceCentavos, $paidAt, $userId) { $batch = $this->batches->generate( quantity: $quantity, purpose: QrCodeBatch::PURPOSE_STORE, targetArea: null, targetStoreId: $store->id, - createdBy: null, + createdBy: $userId, notes: "Wholesale to store {$store->business_name}", ); @@ -61,11 +66,8 @@ class StoreOperations 'paid_at' => $paidAt ?? $now, ]); - $inventory = StoreInventory::firstOrCreate(['store_id' => $store->id], ['current_code_balance' => 0]); - $inventory->forceFill([ - 'current_code_balance' => $inventory->current_code_balance + $quantity, - 'last_updated_at' => $now, - ])->save(); + $this->logAdjustment($store, StoreInventoryAdjustment::TYPE_PURCHASE, $quantity, null, "Wholesale purchase: Batch #{$batch->batch_number}", $userId); + $this->updateBalance($store, $quantity); return $purchase; }); @@ -115,11 +117,8 @@ class StoreOperations 'sold_at' => $now, ]); - $inventory = StoreInventory::firstOrCreate(['store_id' => $store->id], ['current_code_balance' => 0]); - $inventory->forceFill([ - 'current_code_balance' => max(0, $inventory->current_code_balance - $quantity), - 'last_updated_at' => $now, - ])->save(); + $this->logAdjustment($store, StoreInventoryAdjustment::TYPE_SALE, -$quantity, null, "Sold to Household #{$household->id}", null); + $this->updateBalance($store, -$quantity); // Notify the household head — fire after commit so the receiver // sees the persisted state. @@ -133,4 +132,126 @@ class StoreOperations return $sale; }); } + + /** + * Mark a specific code as defective and adjust inventory. + * Generates an immediate replacement QR code for the store. + */ + public function reportDefective(PartnerStore $store, string $serial, string $reason, $userId = null): QrCode + { + return DB::transaction(function () use ($store, $serial, $reason, $userId) { + $qrCode = QrCode::where('serial', $serial) + ->where('assigned_to_store_id', $store->id) + ->where('status', Allocated::$name) + ->lockForUpdate() + ->first(); + + if (!$qrCode) { + throw new \DomainException("QR code {$serial} not found in store inventory or already used."); + } + + // 1. Void the defective code + $qrCode->status->transitionTo(Voided::class); + $qrCode->forceFill([ + 'metadata' => array_merge($qrCode->metadata ?? [], [ + 'voided_at' => now()->toIso8601String(), + 'voided_by_id' => $userId instanceof User ? $userId->id : $userId, + 'void_reason' => $reason, + 'source' => 'store_report', + ]), + 'assigned_to_store_id' => null, + ])->save(); + + $this->logAdjustment($store, StoreInventoryAdjustment::TYPE_DEFECTIVE, -1, $qrCode->id, "Defective: {$reason}", $userId); + $this->updateBalance($store, -1); + + // 2. Generate replacement + $batch = $this->batches->generate( + quantity: 1, + purpose: QrCodeBatch::PURPOSE_STORE, + targetStoreId: $store->id, + createdBy: $userId instanceof User ? $userId : User::find($userId), + notes: "Replacement for defective {$serial}", + ); + + $replacement = QrCode::where('batch_id', $batch->id)->first(); + $replacement->update([ + 'status' => Allocated::$name, + 'assigned_to_store_id' => $store->id, + 'allocated_at' => now(), + 'replacement_for_id' => $qrCode->id, + ]); + + $this->logAdjustment($store, StoreInventoryAdjustment::TYPE_MANUAL, 1, $replacement->id, "Replacement for {$serial}", $userId); + $this->updateBalance($store, 1); + + return $replacement; + }); + } + + /** + * Record a financial settlement (payment) from a store. + */ + public function recordPayment( + PartnerStore $store, + int $amountCentavos, + string $method, + ?string $reference = null, + ?string $notes = null, + $userId = null + ): StoreSettlement { + return StoreSettlement::create([ + 'store_id' => $store->id, + 'amount_centavos' => $amountCentavos, + 'payment_method' => $method, + 'reference_number' => $reference, + 'notes' => $notes, + 'recorded_by_user_id' => $userId instanceof User ? $userId->id : $userId, + 'settled_at' => now(), + ]); + } + + /** + * Calculate the current outstanding balance for the store (Consignment model). + * Balance = (Total Retail - Total Commission) - Total Settled + */ + public function calculateBalanceDue(PartnerStore $store): int + { + $netPayable = $store->sales()->sum(DB::raw('retail_price_centavos - commission_centavos')); + $totalPaid = $store->settlements()->sum('amount_centavos'); + + return max(0, (int) $netPayable - (int) $totalPaid); + } + + /** + * Manual inventory adjustment by admin. + */ + public function manualAdjustment(PartnerStore $store, int $quantity, string $reason, $userId): void + { + DB::transaction(function () use ($store, $quantity, $reason, $userId) { + $this->logAdjustment($store, StoreInventoryAdjustment::TYPE_MANUAL, $quantity, null, $reason, $userId); + $this->updateBalance($store, $quantity); + }); + } + + private function logAdjustment(PartnerStore $store, string $type, int $quantity, ?int $qrCodeId, ?string $reason, $userId): void + { + StoreInventoryAdjustment::create([ + 'store_id' => $store->id, + 'type' => $type, + 'quantity' => $quantity, + 'qr_code_id' => $qrCodeId, + 'reason' => $reason, + 'adjusted_by_user_id' => $userId instanceof \App\Models\User ? $userId->id : $userId, + ]); + } + + private function updateBalance(PartnerStore $store, int $delta): void + { + $inventory = StoreInventory::firstOrCreate(['store_id' => $store->id], ['current_code_balance' => 0]); + $inventory->forceFill([ + 'current_code_balance' => max(0, $inventory->current_code_balance + $delta), + 'last_updated_at' => now(), + ])->save(); + } } diff --git a/bootstrap/app.php b/bootstrap/app.php index 92112e4..1c651cf 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -35,7 +35,7 @@ return Application::configure(basePath: dirname(__DIR__)) // Run tenant resolution on every API request — public lookups // need it too so the bookkeeping is consistent. $middleware->prependToGroup('api', QueryTokenAuth::class); - $middleware->appendToGroup('api', ResolveTenant::class); + $middleware->prependToGroup('api', ResolveTenant::class); $middleware->redirectGuestsTo(function (Request $request) { return $request->is('api/*') ? null : null; diff --git a/database/factories/QrCodeBatchFactory.php b/database/factories/QrCodeBatchFactory.php new file mode 100644 index 0000000..7145427 --- /dev/null +++ b/database/factories/QrCodeBatchFactory.php @@ -0,0 +1,23 @@ + \App\Models\Tenant::factory(), + 'batch_number' => (string) rand(1000, 9999), + 'quantity' => 100, + 'purpose' => 'store_inventory', + 'created_by_admin_id' => \App\Models\User::factory(), + ]; + } +} diff --git a/database/factories/QrCodeFactory.php b/database/factories/QrCodeFactory.php new file mode 100644 index 0000000..03d8c81 --- /dev/null +++ b/database/factories/QrCodeFactory.php @@ -0,0 +1,26 @@ + \App\Models\Tenant::factory(), + 'serial' => $serial, + 'barcode_value' => $serial, + 'batch_id' => QrCodeBatch::factory(), + 'status' => 'unassigned', + 'metadata' => [], + ]; + } +} diff --git a/database/factories/StoreSaleFactory.php b/database/factories/StoreSaleFactory.php new file mode 100644 index 0000000..8664d3a --- /dev/null +++ b/database/factories/StoreSaleFactory.php @@ -0,0 +1,25 @@ + PartnerStore::factory(), + 'household_id' => Household::factory(), + 'quantity' => 1, + 'retail_price_centavos' => 1000, + 'commission_centavos' => 100, + 'sold_at' => now(), + ]; + } +} diff --git a/database/factories/TenantFactory.php b/database/factories/TenantFactory.php new file mode 100644 index 0000000..3322fd6 --- /dev/null +++ b/database/factories/TenantFactory.php @@ -0,0 +1,24 @@ + (string) Str::uuid(), + 'code' => 'T' . rand(100, 999), + 'name' => $this->faker->company(), + 'timezone' => 'Asia/Manila', + 'theme_color' => '#10B981', + 'status' => 'active', + ]; + } +} diff --git a/database/migrations/2026_07_01_081139_create_store_settlements_table.php b/database/migrations/2026_07_01_081139_create_store_settlements_table.php new file mode 100644 index 0000000..74af175 --- /dev/null +++ b/database/migrations/2026_07_01_081139_create_store_settlements_table.php @@ -0,0 +1,37 @@ +id(); + $table->uuid('uuid')->unique(); + $table->foreignId('store_id')->constrained('partner_stores')->onDelete('cascade'); + $table->bigInteger('amount_centavos'); + $table->string('payment_method'); // cash, gcash, maya, card + $table->string('reference_number')->nullable(); + $table->text('notes')->nullable(); + $table->foreignId('recorded_by_user_id')->constrained('users'); + $table->timestamp('settled_at'); + $table->timestamps(); + + $table->index('settled_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('store_settlements'); + } +}; diff --git a/database/migrations/2026_07_01_081204_add_replacement_for_id_to_qr_codes_table.php b/database/migrations/2026_07_01_081204_add_replacement_for_id_to_qr_codes_table.php new file mode 100644 index 0000000..addea71 --- /dev/null +++ b/database/migrations/2026_07_01_081204_add_replacement_for_id_to_qr_codes_table.php @@ -0,0 +1,29 @@ +foreignId('replacement_for_id')->nullable()->after('status')->constrained('qr_codes')->onDelete('set null'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('qr_codes', function (Blueprint $table) { + $table->dropForeign(['replacement_for_id']); + $table->dropColumn('replacement_for_id'); + }); + } +}; diff --git a/database/migrations/2026_07_01_100000_create_store_inventory_adjustments_table.php b/database/migrations/2026_07_01_100000_create_store_inventory_adjustments_table.php new file mode 100644 index 0000000..7d843fd --- /dev/null +++ b/database/migrations/2026_07_01_100000_create_store_inventory_adjustments_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('store_id')->constrained('partner_stores')->onDelete('cascade'); + $table->string('type'); // purchase, sale, defective, manual, return + $table->integer('quantity'); // Positive or negative + $table->foreignId('qr_code_id')->nullable()->constrained('qr_codes')->onDelete('set null'); + $table->text('reason')->nullable(); + $table->foreignId('adjusted_by_user_id')->nullable()->constrained('users')->onDelete('set null'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('store_inventory_adjustments'); + } +}; diff --git a/resources/views/admin/partner-stores.blade.php b/resources/views/admin/partner-stores.blade.php index c28bd55..5ce77a3 100644 --- a/resources/views/admin/partner-stores.blade.php +++ b/resources/views/admin/partner-stores.blade.php @@ -73,6 +73,8 @@ @@ -131,9 +133,10 @@

0 codes

-
-

Quick Actions

+
+ +
@@ -159,25 +162,77 @@
+ {{-- TAB: Inventory History (Log) --}} + + + {{-- TAB: Settlements (edit mode only) --}} + + {{-- TAB: Analytics (edit mode only) --}} + + + + + +