Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
95.24% |
40 / 42 |
|
33.33% |
1 / 3 |
CRAP | |
0.00% |
0 / 1 |
| PaymentCallbackController | |
95.24% |
40 / 42 |
|
33.33% |
1 / 3 |
12 | |
0.00% |
0 / 1 |
| callback | |
88.89% |
8 / 9 |
|
0.00% |
0 / 1 |
4.02 | |||
| webhook | |
85.71% |
6 / 7 |
|
0.00% |
0 / 1 |
3.03 | |||
| recordPayment | |
100.00% |
26 / 26 |
|
100.00% |
1 / 1 |
5 | |||
| 1 | <?php |
| 2 | |
| 3 | namespace App\Http\Controllers; |
| 4 | |
| 5 | use App\Enums\PaymentStatus; |
| 6 | use App\Models\Order; |
| 7 | use App\Models\Payment; |
| 8 | use App\Services\Accounting\AccountingService; |
| 9 | use App\Services\Marketplace\CommissionService; |
| 10 | use App\Services\Notification\NotificationDispatcher; |
| 11 | use App\Services\Payment\PaymentManager; |
| 12 | use App\Services\Payment\PaymentResult; |
| 13 | use Illuminate\Http\JsonResponse; |
| 14 | use Illuminate\Http\RedirectResponse; |
| 15 | use Illuminate\Http\Request; |
| 16 | use Illuminate\Support\Facades\Log; |
| 17 | |
| 18 | /** |
| 19 | * One entry point every gateway's redirect and webhook/IPN hits — the order |
| 20 | * reference is always passed through as ?order={bar_code} (see each |
| 21 | * driver's charge()), so this never has to guess which order a callback is |
| 22 | * for. Never trusts a provider's own "it worked" flag without calling that |
| 23 | * gateway's verify() first — see PaymentManager. |
| 24 | */ |
| 25 | class PaymentCallbackController extends Controller |
| 26 | { |
| 27 | /** The shopper's browser lands here after an off-site checkout. */ |
| 28 | public function callback(Request $request, string $gateway, PaymentManager $manager): RedirectResponse |
| 29 | { |
| 30 | $order = Order::where('bar_code', $request->query('order'))->first(); |
| 31 | |
| 32 | if (! $order || ! $manager->has($gateway)) { |
| 33 | return redirect()->route('home')->with('error', 'We could not find that order.'); |
| 34 | } |
| 35 | |
| 36 | $result = $manager->verify($gateway, $request); |
| 37 | $this->recordPayment($order, $gateway, $result); |
| 38 | |
| 39 | if ($result->status === PaymentStatus::Paid) { |
| 40 | return redirect()->route('thank.you', ['order' => $order->bar_code]); |
| 41 | } |
| 42 | |
| 43 | return redirect()->route('thank.you', ['order' => $order->bar_code]) |
| 44 | ->with('error', $result->message ?? 'Payment was not completed — you can try again or choose Cash on Delivery.'); |
| 45 | } |
| 46 | |
| 47 | /** Server-to-server IPN/webhook — no browser involved, always JSON. */ |
| 48 | public function webhook(Request $request, string $gateway, PaymentManager $manager): JsonResponse |
| 49 | { |
| 50 | $order = Order::where('bar_code', $request->query('order') ?? $request->input('order'))->first(); |
| 51 | |
| 52 | if (! $order || ! $manager->has($gateway)) { |
| 53 | return response()->json(['ok' => false, 'message' => 'Unknown order or gateway.'], 404); |
| 54 | } |
| 55 | |
| 56 | $result = $manager->verify($gateway, $request); |
| 57 | $this->recordPayment($order, $gateway, $result); |
| 58 | |
| 59 | Log::info("Payment webhook [{$gateway}] for order {$order->bar_code}: {$result->status->value}"); |
| 60 | |
| 61 | return response()->json(['ok' => true]); |
| 62 | } |
| 63 | |
| 64 | private function recordPayment(Order $order, string $gateway, PaymentResult $result): void |
| 65 | { |
| 66 | $payment = Payment::firstOrNew(['order_id' => $order->id, 'gateway' => $gateway]); |
| 67 | $payment->customer_id = $order->customer_id; |
| 68 | $payment->total = $order->total; |
| 69 | $payment->shipping_cost = $order->shipping_cost; |
| 70 | $payment->payment_type = $gateway; |
| 71 | $payment->status = $result->status->value; |
| 72 | $payment->transaction_id = $result->transactionId ?? $payment->transaction_id; |
| 73 | $payment->meta = $result->meta ?: $payment->meta; |
| 74 | |
| 75 | if ($result->status === PaymentStatus::Paid) { |
| 76 | // `payments` has no `due` column — every other read of "due" in |
| 77 | // this app computes it as total - paid (see admin/global/ |
| 78 | // datatable/Order.blade.php), so Order::due stays the one |
| 79 | // source of truth rather than duplicating it here. |
| 80 | $paidAmount = $order->due; |
| 81 | $payment->paid = $paidAmount; |
| 82 | $order->due = 0; |
| 83 | $order->save(); |
| 84 | |
| 85 | app(NotificationDispatcher::class)->dispatch('payment_success', [ |
| 86 | 'email' => $order->customer->email ?? null, |
| 87 | 'phone' => $order->customer->phone ?? null, |
| 88 | ], [ |
| 89 | 'customer_name' => $order->customer->name ?? 'Customer', |
| 90 | 'order_number' => $order->bar_code, |
| 91 | 'amount' => money($paidAmount), |
| 92 | ]); |
| 93 | } |
| 94 | |
| 95 | $payment->save(); |
| 96 | |
| 97 | // Idempotency (a webhook + the browser redirect can both call this for |
| 98 | // the same Paid transition) lives inside autoPostOrderPayment() itself. |
| 99 | if ($result->status === PaymentStatus::Paid) { |
| 100 | app(AccountingService::class)->autoPostOrderPayment($order, $payment); |
| 101 | |
| 102 | // Marketplace: record per-line vendor commissions on payment. Also |
| 103 | // idempotent (per order_detail + vendor) and a no-op when no line |
| 104 | // carries a vendor_id, so it's safe even with the flag on but no |
| 105 | // vendor-owned products in the order. |
| 106 | if (feature('multi_vendor')) { |
| 107 | app(CommissionService::class)->recordOrderCommissions($order); |
| 108 | } |
| 109 | } |
| 110 | } |
| 111 | } |