201 lines
7.6 KiB
PHP
201 lines
7.6 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\Api\V1\Payment;
|
|
|
|
use App\Http\Controllers\Api\V1\ApiController;
|
|
use App\Models\Household;
|
|
use App\Models\PartnerStore;
|
|
use App\Models\Payment;
|
|
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' && $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()]);
|
|
}
|
|
}
|
|
}
|