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' && $request->user()->role !== 'super_admin') { return $this->forbidden(); } return $this->ok([ 'id' => $payment->uuid, 'status' => $payment->status, 'amount_centavos' => $payment->amount_centavos, 'paid_at' => $payment->paid_at?->toIso8601String(), ]); } public function adminIndex(Request $request): JsonResponse { $request->validate([ 'status' => ['nullable', 'in:pending,processing,paid,failed,refunded'], 'purpose' => ['nullable', 'in:resident_code_purchase,store_inventory_purchase'], 'q' => ['nullable', 'string', 'max:100'], 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'], ]); $perPage = (int) $request->input('per_page', 25); $payments = Payment::query() ->with('payer') ->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status'))) ->when($request->filled('purpose'), fn ($q) => $q->where('purpose', $request->string('purpose'))) ->when($request->filled('q'), function ($q) use ($request) { $term = '%'.$request->string('q').'%'; $q->where(fn ($qq) => $qq->where('uuid', 'like', $term) ->orWhereHas('payer', fn ($p) => $p->where('email', 'like', $term)->orWhere('phone', 'like', $term))); }) ->orderByDesc('id') ->paginate($perPage); return $this->ok( $payments->getCollection()->map(fn (Payment $p) => [ 'id' => $p->uuid, 'payer' => [ 'name' => $p->payer?->full_name, 'email' => $p->payer?->email, ], 'purpose' => $p->purpose, 'amount_centavos' => $p->amount_centavos, 'currency' => $p->currency, 'provider' => $p->provider, 'status' => $p->status, 'paid_at' => $p->paid_at?->toIso8601String(), 'created_at' => $p->created_at?->toIso8601String(), ])->all(), null, [ 'page' => $payments->currentPage(), 'per_page' => $payments->perPage(), 'total' => $payments->total(), 'last_page' => $payments->lastPage(), ], ); } /** * 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()]); } } }