From c4cbdb42c7e2dd0c67ece6bcd416d2f494720efd Mon Sep 17 00:00:00 2001 From: Developer Date: Thu, 2 Jul 2026 12:47:50 +0800 Subject: [PATCH] feat(store): complete partner store overhaul with mapping, household search, financial analytics, and resident notification system --- .../V1/Admin/AdminPartnerStoreController.php | 3 +- app/Http/Controllers/Api/V1/ApiController.php | 3 + .../Api/V1/Me/MySalesController.php | 79 ++++ .../Api/V1/Store/StorePortalController.php | 53 ++- app/Http/Middleware/ResolveTenant.php | 4 + app/Models/QrCode.php | 1 + app/Models/Tenant.php | 6 + app/Notifications/CodesPurchased.php | 3 + app/Policies/StoreSalePolicy.php | 74 ++++ app/Services/Store/StoreOperations.php | 2 +- ...6_07_02_042046_add_qr_price_to_tenants.php | 28 ++ resources/views/layouts/web.blade.php | 2 + resources/views/pdfs/receipt.blade.php | 78 ++++ resources/views/store/sales.blade.php | 396 ++++++++---------- routes/api.php | 5 + .../Api/V1/Me/MySalesControllerTest.php | 80 ++++ .../V1/Store/PartnerStoreSettlementTest.php | 3 +- .../Feature/Api/V1/Store/PartnerStoreTest.php | 6 +- .../V1/Store/StorePortalEnhancementsTest.php | 66 +++ walkthrough.md | 27 ++ 20 files changed, 692 insertions(+), 227 deletions(-) create mode 100644 app/Http/Controllers/Api/V1/Me/MySalesController.php create mode 100644 app/Policies/StoreSalePolicy.php create mode 100644 database/migrations/2026_07_02_042046_add_qr_price_to_tenants.php create mode 100644 resources/views/pdfs/receipt.blade.php create mode 100644 tests/Feature/Api/V1/Me/MySalesControllerTest.php create mode 100644 tests/Feature/Api/V1/Store/StorePortalEnhancementsTest.php diff --git a/app/Http/Controllers/Api/V1/Admin/AdminPartnerStoreController.php b/app/Http/Controllers/Api/V1/Admin/AdminPartnerStoreController.php index 905e90c..c6cab67 100644 --- a/app/Http/Controllers/Api/V1/Admin/AdminPartnerStoreController.php +++ b/app/Http/Controllers/Api/V1/Admin/AdminPartnerStoreController.php @@ -347,7 +347,7 @@ class AdminPartnerStoreController extends ApiController // 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]), + 'qr_data' => route('api.v1.admin.qr-codes.show', ['serial' => $qrCode->serial]), ]); } @@ -365,6 +365,7 @@ class AdminPartnerStoreController extends ApiController 'total_commission_pesos' => number_format($totalCommission / 100, 2), 'total_settled_pesos' => number_format($totalSettled / 100, 2), 'balance_due_pesos' => number_format($balanceDue / 100, 2), + 'gross_profit_pesos' => number_format(($totalRetailRevenue - $totalWholesaleCost) / 100, 2), 'total_issued_codes' => (int) $store->purchases()->sum('quantity'), 'total_sold_codes' => (int) $store->sales()->sum('quantity'), ]); diff --git a/app/Http/Controllers/Api/V1/ApiController.php b/app/Http/Controllers/Api/V1/ApiController.php index 8d30762..ff84168 100644 --- a/app/Http/Controllers/Api/V1/ApiController.php +++ b/app/Http/Controllers/Api/V1/ApiController.php @@ -4,11 +4,14 @@ namespace App\Http\Controllers\Api\V1; use App\Http\Controllers\Controller; use App\Http\Responses\ApiResponse; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Http\JsonResponse; use Symfony\Component\HttpFoundation\Response; abstract class ApiController extends Controller { + use AuthorizesRequests; + protected function ok(mixed $data = null, ?string $message = null, array $meta = []): JsonResponse { return ApiResponse::success($data, $message, $meta); diff --git a/app/Http/Controllers/Api/V1/Me/MySalesController.php b/app/Http/Controllers/Api/V1/Me/MySalesController.php new file mode 100644 index 0000000..e3f870f --- /dev/null +++ b/app/Http/Controllers/Api/V1/Me/MySalesController.php @@ -0,0 +1,79 @@ +id()) + ->orWhereHas('members', function ($q) { + $q->where('user_id', auth()->id()); + }) + ->first(); + } + + /** + * List sales history for the user's household. + */ + public function index(): JsonResponse + { + $household = $this->getHousehold(); + + if (!$household) { + return $this->ok([], 'No household associated with this account.'); + } + + $sales = StoreSale::where('household_id', $household->id) + ->with('store') + ->latest('sold_at') + ->paginate(15); + + return $this->ok( + $sales->map(fn($s) => [ + 'id' => $s->id, + 'store_name' => $s->store?->business_name ?? 'Verde Partner Store', + 'quantity' => $s->quantity, + 'retail_price_pesos' => number_format($s->retail_price_centavos / 100, 2), + 'sold_at' => $s->sold_at?->toIso8601String() ?? $s->created_at->toIso8601String(), + 'receipt_url' => route('api.v1.me.sales.receipt', $s->id), + ]), + null, + [ + 'page' => $sales->currentPage(), + 'per_page' => $sales->perPage(), + 'total' => $sales->total(), + ] + ); + } + + /** + * Download a PDF receipt for a specific sale. + */ + public function downloadReceipt(StoreSale $sale): Response + { + $household = $this->getHousehold(); + + if (!$household || $sale->household_id !== $household->id) { + abort(403, 'Unauthorized access to this receipt.'); + } + + $sale->load(['store', 'household.head', 'household.barangay']); + + $pdf = \Barryvdh\DomPDF\Facade\Pdf::loadView('pdfs.receipt', [ + 'sale' => $sale + ]); + + return $pdf->download("receipt-{$sale->id}.pdf"); + } +} diff --git a/app/Http/Controllers/Api/V1/Store/StorePortalController.php b/app/Http/Controllers/Api/V1/Store/StorePortalController.php index 49934cc..b8ec244 100644 --- a/app/Http/Controllers/Api/V1/Store/StorePortalController.php +++ b/app/Http/Controllers/Api/V1/Store/StorePortalController.php @@ -4,10 +4,12 @@ namespace App\Http\Controllers\Api\V1\Store; use App\Http\Controllers\Api\V1\ApiController; use App\Models\PartnerStore; +use App\Models\StoreSale; use App\Models\User; use App\Services\Store\StoreOperations; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Http\Response; class StorePortalController extends ApiController { @@ -155,11 +157,26 @@ class StorePortalController extends ApiController return $this->ok($history); } + public function downloadReceipt(\App\Models\StoreSale $sale): Response + { + $this->authorize('view', $sale); + + $sale->load(['store', 'household.head', 'household.barangay']); + + $pdf = \Barryvdh\DomPDF\Facade\Pdf::loadView('pdfs.receipt', [ + 'sale' => $sale + ]); + + return $pdf->download("receipt-{$sale->id}.pdf"); + } + /** - * Search for households to record a sale. + * Search for households to record a sale. Scoped to the store's LGU. */ public function searchHouseholds(Request $request): JsonResponse { + $store = $this->getStore(); + $request->validate([ 'q' => 'required|string|min:2', ]); @@ -167,18 +184,46 @@ class StorePortalController extends ApiController $term = '%' . $request->string('q') . '%'; $households = \App\Models\Household::query() - ->with('head') + ->with(['head', 'barangay']) + ->where('tenant_id', $store->tenant_id) ->where('verification_status', \App\Models\Household::VERIFICATION_APPROVED) ->where(function($q) use ($term) { $q->where('address_line', 'like', $term) ->orWhereHas('head', function($h) use ($term) { $h->where('first_name', 'like', $term) ->orWhere('last_name', 'like', $term); + }) + ->orWhereHas('members', function($m) use ($term) { + $m->where('first_name', 'like', $term) + ->orWhere('last_name', 'like', $term); }); }) - ->limit(10) + ->limit(15) ->get(); - return $this->ok($households); + return $this->ok($households->map(fn($h) => [ + 'id' => $h->id, + 'uuid' => $h->uuid, + 'head_name' => $h->head?->full_name ?? 'Unknown Head', + 'address' => $h->address_line, + 'barangay' => $h->barangay?->name, + 'lat' => $h->coordinates?->latitude, + 'lng' => $h->coordinates?->longitude, + ])); + } + + /** + * Get store settings and pricing. + */ + public function getSettings(): JsonResponse + { + $store = $this->getStore(); + $tenant = $store->tenant; + + return $this->ok([ + 'retail_price_centavos' => (int) ($tenant->qr_retail_price_centavos ?? 1000), + 'retail_price_pesos' => ($tenant->qr_retail_price_centavos ?? 1000) / 100, + 'commission_rate_percent' => (int) $store->commission_rate_percent, + ]); } } diff --git a/app/Http/Middleware/ResolveTenant.php b/app/Http/Middleware/ResolveTenant.php index 4ffc2bd..f51ffb4 100644 --- a/app/Http/Middleware/ResolveTenant.php +++ b/app/Http/Middleware/ResolveTenant.php @@ -36,6 +36,7 @@ class ResolveTenant if ($headerCode) { $tenant = Tenant::where('code', strtoupper((string) $headerCode))->first(); if (! $tenant) { + \Log::info("ResolveTenant: Header code not found: {$headerCode}"); return response()->json([ 'success' => false, 'data' => null, @@ -46,6 +47,9 @@ class ResolveTenant } } elseif ($headerId) { $tenant = Tenant::where('uuid', $headerId)->first(); + if (! $tenant) { + \Log::info("ResolveTenant: Header ID not found: {$headerId}"); + } } // Fall back to the authenticated user's tenant. diff --git a/app/Models/QrCode.php b/app/Models/QrCode.php index 8cd8783..a40efc9 100644 --- a/app/Models/QrCode.php +++ b/app/Models/QrCode.php @@ -31,6 +31,7 @@ class QrCode extends Model 'scanned_by_user_id', 'expires_at', 'metadata', + 'replacement_for_id', ]; protected function casts(): array diff --git a/app/Models/Tenant.php b/app/Models/Tenant.php index 4fde07f..06c3fea 100644 --- a/app/Models/Tenant.php +++ b/app/Models/Tenant.php @@ -28,6 +28,7 @@ class Tenant extends Model 'timezone', 'theme_color', 'logo_path', 'contact_email', 'contact_phone', 'status', + 'qr_retail_price_centavos', ]; protected function casts(): array @@ -100,4 +101,9 @@ class Tenant extends Model { return 'uuid'; } + + public function getQrPriceAttribute(): float + { + return $this->qr_retail_price_centavos / 100; + } } diff --git a/app/Notifications/CodesPurchased.php b/app/Notifications/CodesPurchased.php index c9182a6..a87ceb6 100644 --- a/app/Notifications/CodesPurchased.php +++ b/app/Notifications/CodesPurchased.php @@ -16,6 +16,7 @@ class CodesPurchased extends Notification public readonly int $quantity, public readonly int $totalCentavos, public readonly string $sourceName, + public readonly int $saleId, ) {} public function toArray(mixed $notifiable): array @@ -25,7 +26,9 @@ class CodesPurchased extends Notification 'quantity' => $this->quantity, 'total_centavos' => $this->totalCentavos, 'source' => $this->sourceName, + 'sale_id' => $this->saleId, 'message' => "{$this->quantity} QR codes added to your wallet from {$this->sourceName}.", + 'receipt_url' => route('api.v1.me.sales.receipt', $this->saleId), ]; } diff --git a/app/Policies/StoreSalePolicy.php b/app/Policies/StoreSalePolicy.php new file mode 100644 index 0000000..79123fe --- /dev/null +++ b/app/Policies/StoreSalePolicy.php @@ -0,0 +1,74 @@ +role === User::ROLE_ADMIN) { + return $user->tenant_id === $storeSale->store?->tenant_id; + } + + if ($user->role === User::ROLE_STORE_PARTNER) { + return $user->id === $storeSale->store?->owner_user_id; + } + + return false; + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + return false; + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, StoreSale $storeSale): bool + { + return false; + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, StoreSale $storeSale): bool + { + return false; + } + + /** + * Determine whether the user can restore the model. + */ + public function restore(User $user, StoreSale $storeSale): bool + { + return false; + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(User $user, StoreSale $storeSale): bool + { + return false; + } +} diff --git a/app/Services/Store/StoreOperations.php b/app/Services/Store/StoreOperations.php index dcf0cdd..c3e6027 100644 --- a/app/Services/Store/StoreOperations.php +++ b/app/Services/Store/StoreOperations.php @@ -138,7 +138,7 @@ class StoreOperations if ($household->head) { Notification::send( $household->head, - new CodesPurchased($quantity, $totalRetail, $store->business_name), + new CodesPurchased($quantity, $totalRetail, $store->business_name, $sale->id), ); } diff --git a/database/migrations/2026_07_02_042046_add_qr_price_to_tenants.php b/database/migrations/2026_07_02_042046_add_qr_price_to_tenants.php new file mode 100644 index 0000000..4672809 --- /dev/null +++ b/database/migrations/2026_07_02_042046_add_qr_price_to_tenants.php @@ -0,0 +1,28 @@ +unsignedBigInteger('qr_retail_price_centavos') + ->default(1000) + ->after('status') + ->comment('Default retail price per QR sticker for this LGU in centavos'); + }); + } + + public function down(): void + { + Schema::table('tenants', function (Blueprint $table) { + $table->dropColumn('qr_retail_price_centavos'); + }); + } +}; diff --git a/resources/views/layouts/web.blade.php b/resources/views/layouts/web.blade.php index 8019b17..c058b4a 100644 --- a/resources/views/layouts/web.blade.php +++ b/resources/views/layouts/web.blade.php @@ -20,9 +20,11 @@ @vite(['resources/css/app.css', 'resources/js/app.js']) + @stack('styles') {{ $slot ?? '' }} @yield('content') + @stack('scripts') diff --git a/resources/views/pdfs/receipt.blade.php b/resources/views/pdfs/receipt.blade.php new file mode 100644 index 0000000..bea0754 --- /dev/null +++ b/resources/views/pdfs/receipt.blade.php @@ -0,0 +1,78 @@ + + + + + Receipt #{{ $sale->id }} + + + +
+ +
Transaction Receipt
+
+ + + + + + +
+
Issued By
+
{{ $sale->store?->name }}
+
{{ $sale->store?->address }}
+
+
Receipt Number
+
#{{ str_pad($sale->id, 8, '0', STR_PAD_LEFT) }}
+
Date
+
{{ $sale->sold_at?->format('M d, Y h:i A') }}
+
+ +
Customer Details
+
{{ $sale->household?->head?->full_name }}
+
{{ $sale->household?->address_line }}, {{ $sale->household?->barangay?->name }}
+ + + + + + + + + + + + + + + + + + +
DescriptionQtyUnit PriceTotal
Verde QR Code Stickers (Retail){{ $sale->quantity }}₱{{ number_format($sale->retail_price_centavos / 100, 2) }}₱{{ number_format(($sale->retail_price_centavos * $sale->quantity) / 100, 2) }}
+ +
+ Total Amount Paid: +
₱{{ number_format(($sale->retail_price_centavos * $sale->quantity) / 100, 2) }}
+
+ + + + diff --git a/resources/views/store/sales.blade.php b/resources/views/store/sales.blade.php index 6efc30d..39477ac 100644 --- a/resources/views/store/sales.blade.php +++ b/resources/views/store/sales.blade.php @@ -1,208 +1,207 @@ @extends('store.layouts.app', ['pageTitle' => 'Record Sale', 'pageSubtitle' => 'Register retail QR code sales']) +@push('styles') + + + +@endpush + @section('page')
-
+
-

Search Household

-

Search by resident name or address to start a transaction.

+

Record New Sale

+

Link QR stickers to a verified household in your LGU.

-
-
- -
- - - {{-- Results Dropdown --}} - -
-
- - {{-- Selected Household Card (Hidden by default) --}} -