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()]); } } }