Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.24% covered (success)
95.24%
40 / 42
33.33% covered (danger)
33.33%
1 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
PaymentCallbackController
95.24% covered (success)
95.24%
40 / 42
33.33% covered (danger)
33.33%
1 / 3
12
0.00% covered (danger)
0.00%
0 / 1
 callback
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
4.02
 webhook
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 recordPayment
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2
3namespace App\Http\Controllers;
4
5use App\Enums\PaymentStatus;
6use App\Models\Order;
7use App\Models\Payment;
8use App\Services\Accounting\AccountingService;
9use App\Services\Marketplace\CommissionService;
10use App\Services\Notification\NotificationDispatcher;
11use App\Services\Payment\PaymentManager;
12use App\Services\Payment\PaymentResult;
13use Illuminate\Http\JsonResponse;
14use Illuminate\Http\RedirectResponse;
15use Illuminate\Http\Request;
16use 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 */
25class 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}