diff --git a/app/Http/Controllers/Api/V1/Admin/AdminHouseholdController.php b/app/Http/Controllers/Api/V1/Admin/AdminHouseholdController.php index 3a6c5be..6d52dc9 100644 --- a/app/Http/Controllers/Api/V1/Admin/AdminHouseholdController.php +++ b/app/Http/Controllers/Api/V1/Admin/AdminHouseholdController.php @@ -92,7 +92,16 @@ class AdminHouseholdController extends ApiController public function reject(RejectProfileRequest $request, Household $household): JsonResponse { - $household->markRejected($request->user(), $request->validated('reason')); + $reason = $request->validated('reason'); + $household->markRejected($request->user(), $reason); + + if ($household->head) { + \Illuminate\Support\Facades\Notification::send( + $household->head, + new \App\Notifications\HouseholdRejected($household, $reason), + ); + } + $household->load(['head', 'barangay'])->loadCount('members'); return $this->ok(new HouseholdResource($household), 'Household rejected'); diff --git a/app/Http/Controllers/Api/V1/Admin/AdminLiveTrackingController.php b/app/Http/Controllers/Api/V1/Admin/AdminLiveTrackingController.php new file mode 100644 index 0000000..bc5da7c --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/AdminLiveTrackingController.php @@ -0,0 +1,20 @@ +ok([ + 'trucks' => $this->tracker->activeTruckPositions(), + 'as_of' => now()->toIso8601String(), + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/Auth/NotificationPreferencesController.php b/app/Http/Controllers/Api/V1/Auth/NotificationPreferencesController.php new file mode 100644 index 0000000..22849f5 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Auth/NotificationPreferencesController.php @@ -0,0 +1,53 @@ + $request->user()->id], + ['language' => $request->user()->preferred_language ?? 'en'], + ); + + return $this->ok($prefs->only([ + 'pickup_reminder', 'pickup_imminent', 'pickup_completed', + 'low_codes_warning', 'codes_purchased', 'schedule_changed', + 'household_status', + 'sms_enabled', 'email_enabled', 'push_enabled', 'language', + ])); + } + + public function update(Request $request): JsonResponse + { + $data = $request->validate([ + 'pickup_reminder' => ['sometimes', 'boolean'], + 'pickup_imminent' => ['sometimes', 'boolean'], + 'pickup_completed' => ['sometimes', 'boolean'], + 'low_codes_warning' => ['sometimes', 'boolean'], + 'codes_purchased' => ['sometimes', 'boolean'], + 'schedule_changed' => ['sometimes', 'boolean'], + 'household_status' => ['sometimes', 'boolean'], + 'sms_enabled' => ['sometimes', 'boolean'], + 'email_enabled' => ['sometimes', 'boolean'], + 'push_enabled' => ['sometimes', 'boolean'], + 'language' => ['sometimes', 'in:en,tl,ceb'], + ]); + + $prefs = NotificationPreference::firstOrCreate(['user_id' => $request->user()->id]); + $prefs->update($data); + + return $this->ok($prefs->fresh()->only([ + 'pickup_reminder', 'pickup_imminent', 'pickup_completed', + 'low_codes_warning', 'codes_purchased', 'schedule_changed', + 'household_status', + 'sms_enabled', 'email_enabled', 'push_enabled', 'language', + ]), 'Preferences updated'); + } +} diff --git a/app/Http/Controllers/Api/V1/Auth/RegisterController.php b/app/Http/Controllers/Api/V1/Auth/RegisterController.php index 17cec7d..80a79b0 100644 --- a/app/Http/Controllers/Api/V1/Auth/RegisterController.php +++ b/app/Http/Controllers/Api/V1/Auth/RegisterController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api\V1\Auth; use App\Http\Controllers\Api\V1\ApiController; use App\Http\Requests\Auth\RegisterRequest; use App\Http\Resources\UserResource; +use App\Models\NotificationPreference; use App\Models\OtpCode; use App\Models\User; use App\Services\Otp\OtpService; @@ -38,6 +39,11 @@ class RegisterController extends ApiController $profileClass::create(['user_id' => $user->id]); } + NotificationPreference::create([ + 'user_id' => $user->id, + 'language' => $data['preferred_language'] ?? 'en', + ]); + return $user; }); diff --git a/app/Http/Controllers/Api/V1/Driver/DriverLocationController.php b/app/Http/Controllers/Api/V1/Driver/DriverLocationController.php new file mode 100644 index 0000000..bef79b0 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Driver/DriverLocationController.php @@ -0,0 +1,50 @@ +validate([ + 'lat' => ['required', 'numeric', 'between:-90,90'], + 'lng' => ['required', 'numeric', 'between:-180,180'], + 'heading_degrees' => ['nullable', 'integer', 'min:0', 'max:359'], + 'speed_kmh' => ['nullable', 'numeric', 'min:0', 'max:300'], + 'trip_id' => ['nullable', 'string', 'exists:trips,uuid'], + 'recorded_at' => ['nullable', 'date'], + ]); + + // The driver POSTing must be the team's driver for this truck + $team = $truck->fresh()->assignedTeam; + if ($team && $team->driver_id !== $request->user()->id) { + return $this->forbidden('You are not the assigned driver for this truck'); + } + + $trip = isset($data['trip_id']) ? Trip::where('uuid', $data['trip_id'])->first() : null; + + $result = $this->tracker->record( + truck: $truck, + lat: (float) $data['lat'], + lng: (float) $data['lng'], + heading: isset($data['heading_degrees']) ? (int) $data['heading_degrees'] : null, + speedKmh: isset($data['speed_kmh']) ? (float) $data['speed_kmh'] : null, + trip: $trip, + recordedAt: isset($data['recorded_at']) ? \Carbon\Carbon::parse($data['recorded_at']) : null, + ); + + return $this->ok([ + 'recorded' => $result->recorded, + 'geofence_triggered' => $result->geofenceTriggered, + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/Driver/DriverTripController.php b/app/Http/Controllers/Api/V1/Driver/DriverTripController.php index 6f5e168..147e804 100644 --- a/app/Http/Controllers/Api/V1/Driver/DriverTripController.php +++ b/app/Http/Controllers/Api/V1/Driver/DriverTripController.php @@ -123,10 +123,27 @@ class DriverTripController extends ApiController { $this->authorizeDriver($request, $trip); $data = $request->validate([ - 'lat' => ['required', 'numeric'], - 'lng' => ['required', 'numeric'], + 'lat' => ['required', 'numeric', 'between:-90,90'], + 'lng' => ['required', 'numeric', 'between:-180,180'], + 'override_geofence' => ['nullable', 'boolean'], ]); + // If the trip's dumpsite has a boundary configured, enforce that + // the driver's GPS is inside it — unless they explicitly override + // (e.g., GPS is reading wrong; admin can audit later). + $trip->loadMissing('dumpsite'); + $dumpsite = $trip->dumpsite; + if ($dumpsite && $dumpsite->boundary_polygon && empty($data['override_geofence'])) { + $inside = $dumpsite->containsPoint((float) $data['lat'], (float) $data['lng']); + if (! $inside) { + return $this->fail( + "GPS is outside the {$dumpsite->name} geofence. Pass override_geofence: true if the reading is wrong.", + ['geofence' => ['outside']], + 422, + ); + } + } + try { $trip = $this->executor->arriveAtDumpsite($trip, $request->user(), (float) $data['lat'], (float) $data['lng']); } catch (\DomainException $e) { diff --git a/app/Http/Controllers/Api/V1/Payment/PaymentController.php b/app/Http/Controllers/Api/V1/Payment/PaymentController.php new file mode 100644 index 0000000..b57c577 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Payment/PaymentController.php @@ -0,0 +1,149 @@ +validate([ + 'store_id' => ['required', 'string', 'exists:partner_stores,uuid'], + 'quantity' => ['required', 'integer', 'min:1', 'max:1000'], + 'retail_price_per_code_centavos' => ['required', 'integer', 'min:0'], + ]); + + $store = PartnerStore::where('uuid', $data['store_id'])->firstOrFail(); + if ($store->status !== PartnerStore::STATUS_ACTIVE) { + return $this->fail('Store is not currently selling', null, 422); + } + + $household = Household::where('head_user_id', $request->user()->id)->first(); + if (! $household) { + return $this->fail('You need a verified household first', null, 422); + } + + $total = (int) $data['retail_price_per_code_centavos'] * (int) $data['quantity']; + $payment = Payment::create([ + 'payer_user_id' => $request->user()->id, + 'purpose' => Payment::PURPOSE_RESIDENT, + 'amount_centavos' => $total, + 'currency' => 'PHP', + 'provider' => Payment::PROVIDER_MANUAL, + 'status' => Payment::STATUS_PENDING, + 'metadata' => [ + 'store_id' => $store->id, + 'household_id' => $household->id, + 'quantity' => (int) $data['quantity'], + 'retail_price_per_code_centavos' => (int) $data['retail_price_per_code_centavos'], + ], + ]); + + $result = $this->driver->initiate($payment); + if (! $result->ok) { + return $this->fail('Payment initiation failed: '.$result->error, null, 502); + } + + return $this->created([ + 'payment_id' => $payment->uuid, + 'amount_centavos' => $payment->amount_centavos, + 'checkout_url' => $result->checkoutUrl, + 'provider_payment_id' => $result->providerPaymentId, + ], 'Payment initiated'); + } + + public function show(Request $request, Payment $payment): JsonResponse + { + if ($payment->payer_user_id !== $request->user()->id && $request->user()->role !== 'admin') { + return $this->forbidden(); + } + + return $this->ok([ + 'id' => $payment->uuid, + 'status' => $payment->status, + 'amount_centavos' => $payment->amount_centavos, + 'paid_at' => $payment->paid_at?->toIso8601String(), + ]); + } + + /** + * Admin manually marks a payment paid (used for cash-paid resident + * purchases or when the manual driver is in effect). Triggers the + * post-payment fulfillment. + */ + public function adminMarkPaid(Request $request, Payment $payment, StoreOperations $stores): JsonResponse + { + if ($payment->status === Payment::STATUS_PAID) { + return $this->fail('Payment already paid', null, 422); + } + + $payment->forceFill([ + 'status' => Payment::STATUS_PAID, + 'paid_at' => now(), + ])->save(); + + $this->fulfill($payment, $stores); + + return $this->ok([ + 'id' => $payment->uuid, + 'status' => $payment->status, + ], 'Payment marked paid + fulfilled'); + } + + public function paymongoWebhook(Request $request): JsonResponse + { + $signature = $request->header('Paymongo-Signature', ''); + $raw = $request->getContent(); + + if (! $this->driver->verifyWebhook($raw, $signature)) { + return $this->fail('Invalid signature', null, 400); + } + + $payment = $this->driver->applyWebhook($request->json()->all()); + if ($payment && $payment->status === Payment::STATUS_PAID) { + $this->fulfill($payment, app(StoreOperations::class)); + } + + return $this->ok(['received' => true]); + } + + /** + * Run the post-payment side effects. For resident purchases that's + * the store sale (which activates codes for the household + sends + * the CodesPurchased notification). + */ + private function fulfill(Payment $payment, StoreOperations $stores): void + { + if ($payment->purpose !== Payment::PURPOSE_RESIDENT) return; + + $meta = $payment->metadata ?? []; + $store = isset($meta['store_id']) ? PartnerStore::find($meta['store_id']) : null; + $household = isset($meta['household_id']) ? Household::find($meta['household_id']) : null; + $qty = (int) ($meta['quantity'] ?? 0); + $price = (int) ($meta['retail_price_per_code_centavos'] ?? 0); + + if (! $store || ! $household || $qty <= 0) return; + + try { + $stores->sellToHousehold($store, $household, $qty, $price); + } catch (\DomainException $e) { + $payment->forceFill(['status' => Payment::STATUS_FAILED])->save(); + \Log::warning('Fulfillment failed', ['payment' => $payment->uuid, 'error' => $e->getMessage()]); + } + } +} diff --git a/app/Listeners/SendHouseholdApprovedNotification.php b/app/Listeners/SendHouseholdApprovedNotification.php new file mode 100644 index 0000000..245fc4e --- /dev/null +++ b/app/Listeners/SendHouseholdApprovedNotification.php @@ -0,0 +1,27 @@ +household; + $head = $household->head; + if (! $head) return; + + // The free-allocation listener runs in parallel; query for the + // count of active codes so the user sees the actual number. + $activeCount = QrCode::where('assigned_to_household_id', $household->id) + ->where('status', 'active') + ->count(); + + Notification::send($head, new HouseholdApproved($household, $activeCount)); + } +} diff --git a/app/Listeners/SendQrBalanceLowNotification.php b/app/Listeners/SendQrBalanceLowNotification.php new file mode 100644 index 0000000..259eba7 --- /dev/null +++ b/app/Listeners/SendQrBalanceLowNotification.php @@ -0,0 +1,20 @@ +household->head; + if (! $head) return; + + Notification::send($head, new QrBalanceLowNotification( + $event->household, $event->activeCount, $event->threshold, + )); + } +} diff --git a/app/Models/NotificationPreference.php b/app/Models/NotificationPreference.php new file mode 100644 index 0000000..462dada --- /dev/null +++ b/app/Models/NotificationPreference.php @@ -0,0 +1,48 @@ + 'boolean', + 'pickup_imminent' => 'boolean', + 'pickup_completed' => 'boolean', + 'low_codes_warning' => 'boolean', + 'codes_purchased' => 'boolean', + 'schedule_changed' => 'boolean', + 'household_status' => 'boolean', + 'sms_enabled' => 'boolean', + 'email_enabled' => 'boolean', + 'push_enabled' => 'boolean', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/Payment.php b/app/Models/Payment.php new file mode 100644 index 0000000..6a58309 --- /dev/null +++ b/app/Models/Payment.php @@ -0,0 +1,58 @@ + 'integer', + 'provider_data' => 'array', + 'metadata' => 'array', + 'paid_at' => 'datetime', + ]; + } + + public function getRouteKeyName(): string + { + return 'uuid'; + } + + protected static function booted(): void + { + static::creating(function (self $p): void { + if (empty($p->uuid)) $p->uuid = (string) Str::uuid(); + }); + } + + public function payer(): BelongsTo + { + return $this->belongsTo(User::class, 'payer_user_id'); + } +} diff --git a/app/Models/TruckLocationHistory.php b/app/Models/TruckLocationHistory.php new file mode 100644 index 0000000..7d5e6fe --- /dev/null +++ b/app/Models/TruckLocationHistory.php @@ -0,0 +1,41 @@ + Point::class, + 'recorded_at' => 'datetime', + 'heading_degrees' => 'integer', + 'speed_kmh' => 'float', + ]; + } + + public function truck(): BelongsTo + { + return $this->belongsTo(Truck::class); + } + + public function trip(): BelongsTo + { + return $this->belongsTo(Trip::class); + } +} diff --git a/app/Notifications/Channels/SmsChannel.php b/app/Notifications/Channels/SmsChannel.php new file mode 100644 index 0000000..e01ab9d --- /dev/null +++ b/app/Notifications/Channels/SmsChannel.php @@ -0,0 +1,25 @@ +toSms($notifiable); + if (! $payload || empty($payload['to']) || empty($payload['message'])) { + return; + } + + $this->sms->send($payload['to'], $payload['message']); + } +} diff --git a/app/Notifications/CodesPurchased.php b/app/Notifications/CodesPurchased.php new file mode 100644 index 0000000..c9182a6 --- /dev/null +++ b/app/Notifications/CodesPurchased.php @@ -0,0 +1,41 @@ + 'qr.codes_purchased', + 'quantity' => $this->quantity, + 'total_centavos' => $this->totalCentavos, + 'source' => $this->sourceName, + 'message' => "{$this->quantity} QR codes added to your wallet from {$this->sourceName}.", + ]; + } + + public function toSms(mixed $notifiable): array + { + $pesos = number_format($this->totalCentavos / 100, 2); + + return [ + 'to' => $notifiable->phone, + 'message' => "Verde: {$this->quantity} codes added to your wallet (₱{$pesos}, from {$this->sourceName}).", + ]; + } +} diff --git a/app/Notifications/Concerns/RoutesByPreferences.php b/app/Notifications/Concerns/RoutesByPreferences.php new file mode 100644 index 0000000..963596e --- /dev/null +++ b/app/Notifications/Concerns/RoutesByPreferences.php @@ -0,0 +1,36 @@ +id); + $key = static::PREF_KEY ?? null; + if ($key && $prefs && ! ($prefs->{$key} ?? true)) { + return []; + } + + $channels = ['database']; + if ($prefs?->sms_enabled ?? true) { + if ($notifiable->phone) $channels[] = SmsChannel::class; + } + + return $channels; + } +} diff --git a/app/Notifications/HouseholdApproved.php b/app/Notifications/HouseholdApproved.php new file mode 100644 index 0000000..d54300e --- /dev/null +++ b/app/Notifications/HouseholdApproved.php @@ -0,0 +1,38 @@ + 'household.approved', + 'household_id' => $this->household->uuid, + 'codes_allocated' => $this->codesAllocated, + 'message' => "Your household {$this->household->address_line} is verified." + .($this->codesAllocated > 0 ? " {$this->codesAllocated} free QR codes are ready." : ''), + ]; + } + + public function toSms(mixed $notifiable): array + { + return [ + 'to' => $notifiable->phone, + 'message' => "Verde: Your household is verified." + .($this->codesAllocated > 0 ? " {$this->codesAllocated} QR codes are ready in your wallet." : '') + .' Open the app to see details.', + ]; + } +} diff --git a/app/Notifications/HouseholdRejected.php b/app/Notifications/HouseholdRejected.php new file mode 100644 index 0000000..581e3f7 --- /dev/null +++ b/app/Notifications/HouseholdRejected.php @@ -0,0 +1,35 @@ + 'household.rejected', + 'household_id' => $this->household->uuid, + 'reason' => $this->reason, + 'message' => "Household verification was rejected: {$this->reason}", + ]; + } + + public function toSms(mixed $notifiable): array + { + return [ + 'to' => $notifiable->phone, + 'message' => "Verde: Household verification rejected. Reason: {$this->reason}. Update your details and resubmit.", + ]; + } +} diff --git a/app/Notifications/QrBalanceLowNotification.php b/app/Notifications/QrBalanceLowNotification.php new file mode 100644 index 0000000..e734749 --- /dev/null +++ b/app/Notifications/QrBalanceLowNotification.php @@ -0,0 +1,40 @@ + 'qr.balance_low', + 'household_id' => $this->household->uuid, + 'active_count' => $this->activeCount, + 'threshold' => $this->threshold, + 'message' => "You have {$this->activeCount} QR codes left. Buy more from a partner store.", + ]; + } + + public function toSms(mixed $notifiable): array + { + return [ + 'to' => $notifiable->phone, + 'message' => "Verde: Only {$this->activeCount} QR codes left. Buy more at any partner store before you run out.", + ]; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index f7bb5aa..2988a5f 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,6 +2,9 @@ namespace App\Providers; +use App\Services\Payment\ManualPaymentDriver; +use App\Services\Payment\PayMongoDriver; +use App\Services\Payment\PaymentDriver; use App\Services\Sms\FakeSmsService; use App\Services\Sms\LogSmsService; use App\Services\Sms\SemaphoreSmsService; @@ -12,6 +15,22 @@ class AppServiceProvider extends ServiceProvider { public function register(): void { + $this->app->singleton(PaymentDriver::class, function ($app) { + $driver = config('services.payments.driver', 'manual'); + $key = (string) config('services.paymongo.secret_key'); + + if ($driver === 'paymongo' && $key !== '') { + return new PayMongoDriver( + secretKey: $key, + webhookSecret: (string) config('services.paymongo.webhook_secret'), + successUrl: (string) config('services.paymongo.success_url'), + cancelUrl: (string) config('services.paymongo.cancel_url'), + ); + } + + return new ManualPaymentDriver(); + }); + $this->app->singleton(SmsService::class, function ($app) { $driver = config('services.sms.driver', 'log'); diff --git a/app/Services/LiveTracking/TrackResult.php b/app/Services/LiveTracking/TrackResult.php new file mode 100644 index 0000000..5c5c729 --- /dev/null +++ b/app/Services/LiveTracking/TrackResult.php @@ -0,0 +1,11 @@ + $truck->id, + 'trip_id' => $trip?->id, + 'coordinates' => $point, + 'heading_degrees' => $heading, + 'speed_kmh' => $speedKmh, + 'recorded_at' => $when, + ]); + + $truck->forceFill([ + 'last_known_coordinates' => $point, + 'last_location_updated_at' => $when, + ])->save(); + }); + + Cache::put($this->cacheKey($truck), [ + 'lat' => $lat, 'lng' => $lng, + 'heading_degrees' => $heading, + 'speed_kmh' => $speedKmh, + 'recorded_at' => $when->toIso8601String(), + 'trip_id' => $trip?->uuid, + ], now()->addMinutes(5)); + + $geofenceTriggered = false; + if ($trip?->dumpsite && $trip->dumpsite->boundary_polygon + && $trip->status === Trip::STATUS_IN_PROGRESS) { + if ($trip->dumpsite->containsPoint($lat, $lng)) { + $geofenceTriggered = true; + } + } + + return new TrackResult(true, $geofenceTriggered); + } + + /** + * Return latest known position for each active truck (within last hour). + */ + public function activeTruckPositions(): array + { + $trucks = Truck::query() + ->where('status', Truck::STATUS_ACTIVE) + ->whereNotNull('last_known_coordinates') + ->where('last_location_updated_at', '>=', now()->subHour()) + ->with('assignedTeam') + ->get(); + + return $trucks->map(function (Truck $t) { + $cached = Cache::get($this->cacheKey($t)); + + return [ + 'truck_id' => $t->uuid, + 'plate_number' => $t->plate_number, + 'team' => $t->assignedTeam?->name, + 'lat' => $t->last_known_coordinates->latitude, + 'lng' => $t->last_known_coordinates->longitude, + 'heading_degrees' => $cached['heading_degrees'] ?? null, + 'speed_kmh' => $cached['speed_kmh'] ?? null, + 'recorded_at' => $t->last_location_updated_at?->toIso8601String(), + 'active_trip_id' => $cached['trip_id'] ?? null, + ]; + })->all(); + } + + private function cacheKey(Truck $truck): string + { + return "truck:position:{$truck->id}"; + } +} diff --git a/app/Services/Payment/InitiateResult.php b/app/Services/Payment/InitiateResult.php new file mode 100644 index 0000000..a18bc70 --- /dev/null +++ b/app/Services/Payment/InitiateResult.php @@ -0,0 +1,23 @@ +forceFill([ + 'provider' => Payment::PROVIDER_MANUAL, + 'provider_payment_id' => $providerId, + 'status' => Payment::STATUS_PENDING, + ])->save(); + + return InitiateResult::success(null, $providerId); + } + + public function verifyWebhook(string $rawBody, string $signature): bool + { + // Manual driver doesn't accept real webhooks; always reject. + return false; + } + + public function applyWebhook(array $payload): ?Payment + { + return null; + } +} diff --git a/app/Services/Payment/PayMongoDriver.php b/app/Services/Payment/PayMongoDriver.php new file mode 100644 index 0000000..07af3d5 --- /dev/null +++ b/app/Services/Payment/PayMongoDriver.php @@ -0,0 +1,121 @@ +secretKey)) { + return InitiateResult::failure('paymongo_not_configured'); + } + + try { + $response = Http::withBasicAuth($this->secretKey, '') + ->timeout(15) + ->post($this->endpoint.'/checkout_sessions', [ + 'data' => [ + 'attributes' => [ + 'line_items' => [[ + 'name' => $payment->purpose, + 'amount' => $payment->amount_centavos, + 'currency' => $payment->currency, + 'quantity' => 1, + ]], + 'payment_method_types' => ['gcash', 'paymaya', 'card'], + 'success_url' => $this->successUrl.'?p='.$payment->uuid, + 'cancel_url' => $this->cancelUrl.'?p='.$payment->uuid, + 'metadata' => ['payment_uuid' => $payment->uuid], + ], + ], + ]); + } catch (ConnectionException $e) { + Log::warning('PayMongo connection failed', ['error' => $e->getMessage()]); + + return InitiateResult::failure('connection_failed'); + } + + if (! $response->successful()) { + Log::warning('PayMongo error', ['status' => $response->status(), 'body' => $response->body()]); + + return InitiateResult::failure('provider_error_'.$response->status()); + } + + $data = $response->json('data') ?? []; + $sessionId = $data['id'] ?? null; + $checkoutUrl = $data['attributes']['checkout_url'] ?? null; + + $payment->forceFill([ + 'provider' => Payment::PROVIDER_PAYMONGO, + 'provider_payment_id' => $sessionId, + 'provider_data' => $data, + 'status' => Payment::STATUS_PROCESSING, + ])->save(); + + return InitiateResult::success($checkoutUrl, $sessionId); + } + + public function verifyWebhook(string $rawBody, string $signature): bool + { + if (empty($this->webhookSecret)) { + return false; + } + + // PayMongo webhook header format: t=timestamp,te=signature,li=... + $parts = []; + foreach (explode(',', $signature) as $kv) { + [$k, $v] = array_pad(explode('=', $kv, 2), 2, ''); + $parts[$k] = $v; + } + + $timestamp = $parts['t'] ?? ''; + $signed = $parts['te'] ?? ''; + $expected = hash_hmac('sha256', "{$timestamp}.{$rawBody}", $this->webhookSecret); + + return hash_equals($expected, $signed); + } + + public function applyWebhook(array $payload): ?Payment + { + $eventType = $payload['data']['attributes']['type'] ?? null; + $paymentUuid = $payload['data']['attributes']['data']['attributes']['metadata']['payment_uuid'] ?? null; + if (! $paymentUuid) return null; + + $payment = Payment::where('uuid', $paymentUuid)->first(); + if (! $payment) return null; + + if (in_array($eventType, ['checkout_session.payment.paid', 'payment.paid'], true)) { + $payment->forceFill([ + 'status' => Payment::STATUS_PAID, + 'paid_at' => now(), + 'provider_data' => $payload, + ])->save(); + } elseif (in_array($eventType, ['payment.failed'], true)) { + $payment->forceFill([ + 'status' => Payment::STATUS_FAILED, + 'provider_data' => $payload, + ])->save(); + } + + return $payment->fresh(); + } +} diff --git a/app/Services/Payment/PaymentDriver.php b/app/Services/Payment/PaymentDriver.php new file mode 100644 index 0000000..ee2bb47 --- /dev/null +++ b/app/Services/Payment/PaymentDriver.php @@ -0,0 +1,26 @@ +where('assigned_to_household_id', $household->id) + ->exists(); + + if ($alreadyAllocated) { + Log::info('Free allocation skipped — household already has codes', [ + 'household_id' => $household->id, + ]); + + return 0; + } + $batch = $this->findFreeBatchForHousehold($household); if (! $batch) { Log::warning('No free_allocation batch available for household', [ diff --git a/app/Services/Store/StoreOperations.php b/app/Services/Store/StoreOperations.php index 90b4670..13b8d0b 100644 --- a/app/Services/Store/StoreOperations.php +++ b/app/Services/Store/StoreOperations.php @@ -119,6 +119,15 @@ class StoreOperations 'last_updated_at' => $now, ])->save(); + // Notify the household head — fire after commit so the receiver + // sees the persisted state. + if ($household->head) { + \Illuminate\Support\Facades\Notification::send( + $household->head, + new \App\Notifications\CodesPurchased($quantity, $totalRetail, $store->business_name), + ); + } + return $sale; }); } diff --git a/config/services.php b/config/services.php index 403b888..2ab2dbc 100644 --- a/config/services.php +++ b/config/services.php @@ -50,4 +50,15 @@ return [ 'resend_cooldown_seconds' => (int) env('OTP_RESEND_COOLDOWN_SECONDS', 60), ], + 'payments' => [ + 'driver' => env('PAYMENTS_DRIVER', 'manual'), + ], + + 'paymongo' => [ + 'secret_key' => env('PAYMONGO_SECRET_KEY'), + 'webhook_secret' => env('PAYMONGO_WEBHOOK_SECRET'), + 'success_url' => env('PAYMENT_SUCCESS_URL', env('APP_URL').'/payment/success'), + 'cancel_url' => env('PAYMENT_CANCEL_URL', env('APP_URL').'/payment/cancel'), + ], + ]; diff --git a/database/migrations/2026_05_12_100000_create_notification_preferences_table.php b/database/migrations/2026_05_12_100000_create_notification_preferences_table.php new file mode 100644 index 0000000..84a2514 --- /dev/null +++ b/database/migrations/2026_05_12_100000_create_notification_preferences_table.php @@ -0,0 +1,43 @@ +foreignId('user_id')->primary()->constrained('users')->cascadeOnDelete(); + $table->boolean('pickup_reminder')->default(true); + $table->boolean('pickup_imminent')->default(true); + $table->boolean('pickup_completed')->default(true); + $table->boolean('low_codes_warning')->default(true); + $table->boolean('codes_purchased')->default(true); + $table->boolean('schedule_changed')->default(true); + $table->boolean('household_status')->default(true); + $table->boolean('sms_enabled')->default(true); + $table->boolean('email_enabled')->default(true); + $table->boolean('push_enabled')->default(true); + $table->string('language', 8)->default('en'); + $table->timestamps(); + }); + + // Standard Laravel notifications table for in-app inbox + Schema::create('notifications', function (Blueprint $table) { + $table->uuid('id')->primary(); + $table->string('type'); + $table->morphs('notifiable'); + $table->text('data'); + $table->timestamp('read_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('notifications'); + Schema::dropIfExists('notification_preferences'); + } +}; diff --git a/database/migrations/2026_05_13_100000_create_payments_table.php b/database/migrations/2026_05_13_100000_create_payments_table.php new file mode 100644 index 0000000..f47889b --- /dev/null +++ b/database/migrations/2026_05_13_100000_create_payments_table.php @@ -0,0 +1,36 @@ +id(); + $table->uuid('uuid')->unique(); + $table->foreignId('payer_user_id')->constrained('users')->cascadeOnDelete(); + $table->enum('purpose', ['resident_code_purchase', 'store_inventory_purchase'])->index(); + $table->unsignedBigInteger('amount_centavos'); + $table->string('currency', 8)->default('PHP'); + $table->enum('provider', ['paymongo', 'manual'])->default('manual'); + $table->string('provider_payment_id')->nullable()->index(); + $table->enum('status', ['pending', 'processing', 'paid', 'failed', 'refunded']) + ->default('pending') + ->index(); + $table->json('provider_data')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamp('paid_at')->nullable(); + $table->timestamps(); + + $table->index(['payer_user_id', 'status']); + }); + } + + public function down(): void + { + Schema::dropIfExists('payments'); + } +}; diff --git a/database/migrations/2026_05_14_100000_create_truck_location_history_table.php b/database/migrations/2026_05_14_100000_create_truck_location_history_table.php new file mode 100644 index 0000000..ef16b7a --- /dev/null +++ b/database/migrations/2026_05_14_100000_create_truck_location_history_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('truck_id')->constrained('trucks')->cascadeOnDelete(); + $table->foreignId('trip_id')->nullable()->constrained('trips')->nullOnDelete(); + $table->geometry('coordinates', subtype: 'point', srid: 4326); + $table->unsignedSmallInteger('heading_degrees')->nullable(); + $table->decimal('speed_kmh', 5, 2)->nullable(); + $table->timestamp('recorded_at'); + $table->timestamps(); + + $table->index(['truck_id', 'recorded_at']); + $table->index(['trip_id', 'recorded_at']); + }); + + DB::statement( + 'ALTER TABLE truck_location_history ADD SPATIAL INDEX truck_location_coords_spx (coordinates)', + ); + } + + public function down(): void + { + Schema::dropIfExists('truck_location_history'); + } +}; diff --git a/routes/api.php b/routes/api.php index 8ddbe0b..dcc7b38 100644 --- a/routes/api.php +++ b/routes/api.php @@ -3,6 +3,7 @@ use App\Http\Controllers\Api\V1\Admin\AdminDropOffPointController; use App\Http\Controllers\Api\V1\Admin\AdminDumpsiteController; use App\Http\Controllers\Api\V1\Admin\AdminHouseholdController; +use App\Http\Controllers\Api\V1\Admin\AdminLiveTrackingController; use App\Http\Controllers\Api\V1\Admin\AdminPartnerStoreController; use App\Http\Controllers\Api\V1\Admin\AdminQrBatchController; use App\Http\Controllers\Api\V1\Admin\AdminQrCodeController; @@ -16,6 +17,7 @@ use App\Http\Controllers\Api\V1\Auth\ForgotPasswordController; use App\Http\Controllers\Api\V1\Auth\LoginController; use App\Http\Controllers\Api\V1\Auth\LogoutController; use App\Http\Controllers\Api\V1\Auth\MeController; +use App\Http\Controllers\Api\V1\Auth\NotificationPreferencesController; use App\Http\Controllers\Api\V1\Auth\RefreshTokenController; use App\Http\Controllers\Api\V1\Auth\RegisterController; use App\Http\Controllers\Api\V1\Auth\ResendOtpController; @@ -23,8 +25,10 @@ use App\Http\Controllers\Api\V1\Auth\ResetPasswordController; use App\Http\Controllers\Api\V1\Auth\SelfProfileController; use App\Http\Controllers\Api\V1\Auth\UpdateSelfController; use App\Http\Controllers\Api\V1\Auth\VerifyOtpController; +use App\Http\Controllers\Api\V1\Driver\DriverLocationController; use App\Http\Controllers\Api\V1\Driver\DriverTripController; use App\Http\Controllers\Api\V1\DropOff\DropOffPointController; +use App\Http\Controllers\Api\V1\Payment\PaymentController; use App\Http\Controllers\Api\V1\Geo\BarangayController; use App\Http\Controllers\Api\V1\Geo\CityMunicipalityController; use App\Http\Controllers\Api\V1\Geo\ProvinceController; @@ -67,6 +71,27 @@ Route::middleware('auth:sanctum')->prefix('me')->name('api.v1.me.')->group(funct Route::patch('/', UpdateSelfController::class)->name('update'); Route::get('/profile', [SelfProfileController::class, 'show'])->name('profile.show'); Route::patch('/profile', [SelfProfileController::class, 'update'])->name('profile.update'); + + Route::get('/notification-preferences', [NotificationPreferencesController::class, 'show'])->name('notification-preferences.show'); + Route::patch('/notification-preferences', [NotificationPreferencesController::class, 'update'])->name('notification-preferences.update'); + + Route::post('/payments/code-purchase', [PaymentController::class, 'initiateResidentPurchase'])->name('payments.code-purchase'); + Route::get('/payments/{payment}', [PaymentController::class, 'show'])->name('payments.show'); +}); + +// PayMongo webhook — no auth, signature verified by driver +Route::post('/webhooks/paymongo', [PaymentController::class, 'paymongoWebhook']) + ->name('api.v1.webhooks.paymongo'); + +// Driver telemetry +Route::middleware(['auth:sanctum', 'role:driver'])->prefix('driver')->name('api.v1.driver.')->group(function () { + Route::post('/trucks/{truck}/location', [DriverLocationController::class, 'store'])->name('trucks.location'); +}); + +// Admin live tracking +Route::middleware(['auth:sanctum', 'role:admin'])->prefix('admin/live')->name('api.v1.admin.live.')->group(function () { + Route::get('/trucks', [AdminLiveTrackingController::class, 'trucks'])->name('trucks'); + Route::post('/payments/{payment}/mark-paid', [PaymentController::class, 'adminMarkPaid'])->name('payments.mark-paid'); }); Route::prefix('admin/users') diff --git a/tests/Feature/Api/V1/LiveTracking/LiveTrackingTest.php b/tests/Feature/Api/V1/LiveTracking/LiveTrackingTest.php new file mode 100644 index 0000000..54f6f96 --- /dev/null +++ b/tests/Feature/Api/V1/LiveTracking/LiveTrackingTest.php @@ -0,0 +1,75 @@ +seed(RoleSeeder::class); + } + + public function test_assigned_driver_can_post_location(): void + { + $driver = User::factory()->create(['role' => User::ROLE_DRIVER, 'status' => 'active']); + $truck = Truck::create(['plate_number' => 'NCR-9999', 'status' => 'active']); + $team = CollectionTeam::create([ + 'name' => 'T1', 'driver_id' => $driver->id, 'truck_id' => $truck->id, 'status' => 'active', + ]); + $truck->forceFill(['assigned_team_id' => $team->id])->save(); + Sanctum::actingAs($driver); + + $response = $this->postJson("/api/v1/driver/trucks/{$truck->uuid}/location", [ + 'lat' => 14.65, 'lng' => 121.07, 'speed_kmh' => 22, + ]); + + $response->assertOk()->assertJsonPath('data.recorded', true); + $this->assertSame(1, TruckLocationHistory::count()); + $this->assertNotNull($truck->fresh()->last_known_coordinates); + } + + public function test_non_team_driver_blocked(): void + { + $driver = User::factory()->create(['role' => User::ROLE_DRIVER, 'status' => 'active']); + $other = User::factory()->create(['role' => User::ROLE_DRIVER, 'status' => 'active']); + $truck = Truck::create(['plate_number' => 'NCR-1111', 'status' => 'active']); + $team = CollectionTeam::create([ + 'name' => 'T1', 'driver_id' => $other->id, 'truck_id' => $truck->id, 'status' => 'active', + ]); + $truck->forceFill(['assigned_team_id' => $team->id])->save(); + Sanctum::actingAs($driver); + + $this->postJson("/api/v1/driver/trucks/{$truck->uuid}/location", [ + 'lat' => 14.65, 'lng' => 121.07, + ])->assertStatus(403); + } + + public function test_admin_sees_active_truck_positions(): void + { + $admin = User::factory()->create(['role' => User::ROLE_ADMIN, 'status' => 'active']); + $truck = Truck::create([ + 'plate_number' => 'NCR-2222', 'status' => 'active', + 'last_known_coordinates' => new \MatanYadaev\EloquentSpatial\Objects\Point(14.65, 121.07, 4326), + 'last_location_updated_at' => now(), + ]); + Sanctum::actingAs($admin); + + $response = $this->getJson('/api/v1/admin/live/trucks'); + + $response->assertOk(); + $this->assertCount(1, $response->json('data.trucks')); + $this->assertSame('NCR-2222', $response->json('data.trucks.0.plate_number')); + } +} diff --git a/tests/Feature/Api/V1/Payment/PaymentFlowTest.php b/tests/Feature/Api/V1/Payment/PaymentFlowTest.php new file mode 100644 index 0000000..5f833e7 --- /dev/null +++ b/tests/Feature/Api/V1/Payment/PaymentFlowTest.php @@ -0,0 +1,98 @@ +seed(RoleSeeder::class); + } + + public function test_resident_initiates_a_code_purchase_payment(): void + { + $resident = User::factory()->create(['role' => User::ROLE_RESIDENT, 'status' => 'active']); + Household::factory()->create(['head_user_id' => $resident->id]); + $store = PartnerStore::factory()->create(['status' => 'active']); + Sanctum::actingAs($resident); + + $response = $this->postJson('/api/v1/me/payments/code-purchase', [ + 'store_id' => $store->uuid, + 'quantity' => 5, + 'retail_price_per_code_centavos' => 1000, + ]); + + $response->assertCreated() + ->assertJsonPath('data.amount_centavos', 5000); + $this->assertDatabaseHas('payments', [ + 'payer_user_id' => $resident->id, + 'amount_centavos' => 5000, + 'status' => 'pending', + ]); + } + + public function test_admin_marking_paid_fulfills_the_purchase(): void + { + $admin = User::factory()->create(['role' => User::ROLE_ADMIN, 'status' => 'active']); + $resident = User::factory()->create(['role' => User::ROLE_RESIDENT, 'status' => 'active']); + $household = Household::factory()->create(['head_user_id' => $resident->id]); + + $store = PartnerStore::factory()->create(['status' => 'active']); + app(StoreOperations::class)->issueWholesale($store, 20, 100000); + + $payment = Payment::create([ + 'payer_user_id' => $resident->id, + 'purpose' => Payment::PURPOSE_RESIDENT, + 'amount_centavos' => 5000, + 'status' => 'pending', + 'metadata' => [ + 'store_id' => $store->id, + 'household_id' => $household->id, + 'quantity' => 5, + 'retail_price_per_code_centavos' => 1000, + ], + ]); + + Sanctum::actingAs($admin); + $response = $this->postJson("/api/v1/admin/live/payments/{$payment->uuid}/mark-paid"); + + $response->assertOk()->assertJsonPath('data.status', 'paid'); + + $active = QrCode::where('assigned_to_household_id', $household->id) + ->where('status', 'active')->count(); + $this->assertSame(5, $active); + } + + public function test_resident_without_household_cannot_buy(): void + { + $resident = User::factory()->create(['role' => User::ROLE_RESIDENT, 'status' => 'active']); + $store = PartnerStore::factory()->create(['status' => 'active']); + Sanctum::actingAs($resident); + + $this->postJson('/api/v1/me/payments/code-purchase', [ + 'store_id' => $store->uuid, + 'quantity' => 1, + 'retail_price_per_code_centavos' => 1000, + ])->assertStatus(422); + } + + public function test_paymongo_webhook_rejects_invalid_signature(): void + { + $this->postJson('/api/v1/webhooks/paymongo', ['data' => []]) + ->assertStatus(400); + } +} diff --git a/tests/Feature/Api/V1/Trip/TripLifecycleTest.php b/tests/Feature/Api/V1/Trip/TripLifecycleTest.php index 1d31710..1d913c7 100644 --- a/tests/Feature/Api/V1/Trip/TripLifecycleTest.php +++ b/tests/Feature/Api/V1/Trip/TripLifecycleTest.php @@ -93,9 +93,10 @@ class TripLifecycleTest extends TestCase $this->postJson("/api/v1/driver/trips/{$trip->uuid}/start", ['lat' => 14.6, 'lng' => 121.0])->assertOk(); $this->postJson("/api/v1/driver/trips/{$trip->uuid}/stops/{$stop->id}/arrive", ['lat' => 14.6, 'lng' => 121.0])->assertOk(); $this->postJson("/api/v1/driver/trips/{$trip->uuid}/stops/{$stop->id}/depart", ['lat' => 14.6, 'lng' => 121.0])->assertOk(); - $this->postJson("/api/v1/driver/trips/{$trip->uuid}/arrive-dumpsite", ['lat' => 14.7, 'lng' => 121.1])->assertOk(); + // Inside the seeded Payatas dumpsite boundary (14.7155, 121.1083 ± 0.0035) + $this->postJson("/api/v1/driver/trips/{$trip->uuid}/arrive-dumpsite", ['lat' => 14.7155, 'lng' => 121.1083])->assertOk(); $this->postJson("/api/v1/driver/trips/{$trip->uuid}/release-load", [ - 'weight_kg' => 1500, 'gate_pass_number' => 'GP-1', 'lat' => 14.7, 'lng' => 121.1, + 'weight_kg' => 1500, 'gate_pass_number' => 'GP-1', 'lat' => 14.7155, 'lng' => 121.1083, ])->assertCreated(); $this->postJson("/api/v1/driver/trips/{$trip->uuid}/complete")->assertOk();