Files
Verde-Web/app/Http/Controllers/Api/V1/Payment/PaymentController.php
admin 15d56d0e98 feat(backend): finish Module 13 sub-modules + flow fixes
Notifications: notification_preferences + Laravel notifications inbox.
SmsChannel adapter for our SmsService. RoutesByPreferences trait reads
per-user toggles. HouseholdApproved/Rejected, QrBalanceLow, and
CodesPurchased notifications wired in via auto-discovered listeners
or direct dispatch from controllers/StoreOperations.

Payments: payments table + PaymentDriver interface. ManualPaymentDriver
works out of the box; PayMongoDriver activates when
PAYMONGO_SECRET_KEY is set, falls back to manual otherwise. Resident
initiates code-purchase, admin can mark paid manually, webhook applies
real provider events. Fulfillment runs StoreOperations::sellToHousehold.

Live tracking (HTTP polling): truck_location_history (with SPATIAL
INDEX + 7-day retention plan). Driver POST /driver/trucks/{uuid}/location
writes history, updates trucks.last_known_coordinates, caches in Redis,
flags geofence-trigger when entering active trip dumpsite. Admin
GET /admin/live/trucks returns active truck positions. Reverb broadcast
deferred.

Flow corrections:
- QrAllocator now idempotent — re-approving a household no longer
  re-dispenses free codes.
- arrive-dumpsite enforces dumpsite geofence via ST_Contains; can be
  bypassed with override_geofence: true.

171 feature tests passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 14:01:38 +08:00

150 lines
5.5 KiB
PHP

<?php
namespace App\Http\Controllers\Api\V1\Payment;
use App\Http\Controllers\Api\V1\ApiController;
use App\Models\Household;
use App\Models\Payment;
use App\Models\PartnerStore;
use App\Services\Payment\PaymentDriver;
use App\Services\Store\StoreOperations;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class PaymentController extends ApiController
{
public function __construct(private readonly PaymentDriver $driver) {}
/**
* Resident initiates buying QR codes from a partner store. Creates a
* Payment row + asks the driver for a checkout URL. After the
* provider confirms via webhook, the codes get activated.
*/
public function initiateResidentPurchase(Request $request): JsonResponse
{
$data = $request->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()]);
}
}
}