batches->generate( quantity: $quantity, purpose: QrCodeBatch::PURPOSE_STORE, targetArea: null, targetStoreId: $store->id, createdBy: $userId, notes: "Wholesale to store {$store->business_name}", ); $now = now(); QrCode::query() ->where('batch_id', $batch->id) ->where('status', Unassigned::$name) ->update([ 'status' => Allocated::$name, 'assigned_to_store_id' => $store->id, 'allocated_at' => $now, 'updated_at' => $now, ]); $purchase = StorePurchase::create([ 'store_id' => $store->id, 'batch_id' => $batch->id, 'quantity' => $quantity, 'wholesale_price_centavos' => $wholesalePriceCentavos, 'paid_at' => $paidAt ?? $now, 'is_prepaid' => $isPrepaid, ]); $this->logAdjustment($store, StoreInventoryAdjustment::TYPE_PURCHASE, $quantity, null, "Wholesale purchase: Batch #{$batch->batch_number}" . ($isPrepaid ? ' (Prepaid)' : ''), $userId); $this->updateBalance($store, $quantity, $isPrepaid); return $purchase; }); } /** * Sell N codes from a store's inventory to a resident's household. * Codes transition allocated -> active and reassign household. */ public function sellToHousehold( PartnerStore $store, Household $household, int $quantity, int $retailPricePerCodeCentavos, ?string $serialNumber = null, ): StoreSale { return DB::transaction(function () use ($store, $household, $quantity, $retailPricePerCodeCentavos, $serialNumber) { $inventory = StoreInventory::where('store_id', $store->id)->first(); $prepaidAvailable = $inventory ? $inventory->prepaid_balance : 0; $usePrepaid = $prepaidAvailable >= $quantity; $query = QrCode::query() ->where('assigned_to_store_id', $store->id) ->where('status', Allocated::$name); if ($serialNumber) { $query->where('serial', $serialNumber); } $codeIds = $query->orderBy('id') ->limit($quantity) ->lockForUpdate() ->pluck('id'); if ($codeIds->count() < $quantity) { throw new \DomainException("Store has only {$codeIds->count()} codes available"); } $now = now(); QrCode::whereIn('id', $codeIds)->update([ 'status' => Active::$name, 'assigned_to_household_id' => $household->id, 'assigned_to_store_id' => null, 'activated_at' => $now, 'updated_at' => $now, ]); $totalRetail = $retailPricePerCodeCentavos * $quantity; $commission = (int) round($totalRetail * ($store->commission_rate_percent / 100)); $sale = StoreSale::create([ 'store_id' => $store->id, 'household_id' => $household->id, 'quantity' => $quantity, 'retail_price_centavos' => $totalRetail, 'commission_centavos' => $commission, 'sold_at' => $now, 'is_prepaid' => $usePrepaid, ]); $this->logAdjustment($store, StoreInventoryAdjustment::TYPE_SALE, -$quantity, null, "Sold to Household #{$household->id}" . ($usePrepaid ? ' (Prepaid)' : ''), null); $this->updateBalance($store, -$quantity, $usePrepaid); // Notify the household head — fire after commit so the receiver // sees the persisted state. if ($household->head) { Notification::send( $household->head, new CodesPurchased($quantity, $totalRetail, $store->business_name), ); } 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() ->where('is_prepaid', false) ->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, bool $isPrepaid = false): void { $inventory = StoreInventory::firstOrCreate(['store_id' => $store->id], [ 'current_code_balance' => 0, 'prepaid_balance' => 0 ]); if ($isPrepaid) { $inventory->prepaid_balance = max(0, $inventory->prepaid_balance + $delta); } else { $inventory->current_code_balance = max(0, $inventory->current_code_balance + $delta); } $inventory->last_updated_at = now(); $inventory->save(); } }