Beberapa waktu lalu, aku mendapat tugas yang sepertinya simpel di sebuah legacy project Laravel: menambahkan metode pembayaran baru. Langkah pertamaku tentu saja observasi, mencari tahu di mana fitur baru ini sebaiknya diletakkan. Mengingat ini project Laravel, ekspektasiku kodenya akan mengikuti pola MVC yang rapi.
Namun, ceritanya jadi menarik ketika aku menemukan kode penanganan payment yang sudah ada. Ternyata, semuanya ditaruh di dalam Controller! Mulai dari pemanggilan external resource sampai proses lainnya menumpuk di sana. Padahal, sepemahamanku, pemanggilan external resource sebaiknya diletakkan di Service class agar Controller tetap bersih dan ‘dumb‘.
class InvoiceController extends Controller
{
public function saveInvoice(Request $request): JsonResponse
{
$request->validate([
'user_id' => 'required|exists:users,id',
'course_id' => 'required|exists:courses,id',
]);
$user = User::findOrFail($request->user_id);
$course = Course::findOrFail($request->course_id);
// Simpan data invoice
$invoice = Invoice::create([
'user_id' => $user->id,
'course_id' => $course->id,
'amount' => $course->price,
'status' => 'unpaid',
]);
Config::$serverKey = config('midtrans.server_key');
Config::$isProduction = config('midtrans.is_production');
Config::$isSanitized = true;
Config::$is3ds = true;
$orderId = 'EDU-' . time() . '-' . $user->id;
$params = [
'transaction_details' => [
'order_id' => $orderId,
'gross_amount' => $course->price,
],
'customer_details' => [
'first_name' => $user->name,
'email' => $user->email,
],
'item_details' => [
[
'id' => $course->id,
'price' => $course->price,
'quantity' => 1,
'name' => $course->title,
],
],
];
$snapToken = Snap::getSnapToken($params);
Transaction::create([
'user_id' => $user->id,
'course_id' => $course->id,
'invoice_id' => $invoice->id,
'midtrans_order_id' => $orderId,
'snap_token' => $snapToken,
'amount' => $course->price,
'status' => 'pending',
]);
return response()->json([
'invoice_id' => $invoice->id,
'order_id' => $orderId,
'snap_token' => $snapToken,
]);
}
public function handleWebhook(Request $request): JsonResponse
{
$payload = $request->all();
$transaction = Transaction::where('midtrans_order_id', $payload['order_id'])->firstOrFail();
$transactionStatus = $payload['transaction_status'];
if ($transactionStatus === 'settlement' || $transactionStatus === 'capture') {
DB::transaction(function () use ($transaction, $payload) {
$transaction->update([
'status' => 'paid',
'paid_at' => now(),
]);
Enrollment::updateOrCreate(
['user_id' => $transaction->user_id, 'course_id' => $transaction->course_id],
['status' => 'active', 'enrolled_at' => now()]
);
});
}
return response()->json(['message' => 'OK']);
}
}Ini potongan kode yang ku temukan.
Melihat pola ini, aku mulai menebak. Jangan-jangan ada kode serupa di Controller lain? Benar saja, aku menemukan duplikasi kode yang sama persis di Controller ShippingCash dan Invoice.
class ShippingCashController extends Controller
{
public function saveShippingCash(Request $request): JsonResponse
{
$request->validate([
'user_id' => 'required|exists:users,id',
'course_id' => 'required|exists:courses,id',
]);
$user = User::findOrFail($request->user_id);
$course = Course::findOrFail($request->course_id);
// Simpan data shipping cash
$shippingCash = ShippingCash::create([
'user_id' => $user->id,
'course_id' => $course->id,
'amount' => $course->price, // ❌ tidak pakai effective_price
'status' => 'unpaid',
]);
// ❌ Konfigurasi Midtrans copy-paste dari InvoiceController
Config::$serverKey = config('midtrans.server_key');
Config::$isProduction = config('midtrans.is_production');
Config::$isSanitized = true;
Config::$is3ds = true;
$orderId = 'EDU-' . time() . '-' . $user->id;
$params = [
'transaction_details' => [
'order_id' => $orderId,
'gross_amount' => $course->price,
],
'customer_details' => [
'first_name' => $user->name,
'email' => $user->email,
],
'item_details' => [
[
'id' => $course->id,
'price' => $course->price,
'quantity' => 1,
'name' => $course->title,
],
],
];
// ❌ HTTP call ke Midtrans copy-paste dari InvoiceController
$snapToken = Snap::getSnapToken($params);
Transaction::create([
'user_id' => $user->id,
'course_id' => $course->id,
'shipping_cash_id' => $shippingCash->id,
'midtrans_order_id' => $orderId,
'snap_token' => $snapToken,
'amount' => $course->price,
'status' => 'pending',
]);
return response()->json([
'shipping_cash_id' => $shippingCash->id,
'order_id' => $orderId,
'snap_token' => $snapToken,
]);
}
public function handleWebhook(Request $request): JsonResponse
{
$payload = $request->all();
// ❌ Tidak ada verifikasi signature — sama persis dengan InvoiceController
$transaction = Transaction::where('midtrans_order_id', $payload['order_id'])->firstOrFail();
$transactionStatus = $payload['transaction_status'];
if ($transactionStatus === 'settlement' || $transactionStatus === 'capture') {
DB::transaction(function () use ($transaction, $payload) {
$transaction->update([
'status' => 'paid',
'paid_at' => now(),
]);
Enrollment::updateOrCreate(
['user_id' => $transaction->user_id, 'course_id' => $transaction->course_id],
['status' => 'active', 'enrolled_at' => now()]
);
});
}
return response()->json(['message' => 'OK']);
}
}Kondisi ini sebenarnya bisa diatasi dengan mudah menggunakan Interface. Jadi, ketika nanti ada penambahan metode pembayaran baru, kita tidak perlu repot.
Akhirnya, aku berdiskusi dengan klien. Aku jelaskan kondisi kode saat ini dan menyarankan pendekatan yang lebih baik. Singkat cerita, aku mendapat lampu hijau untuk melakukan refactoring 🛠️ pada sistem payment ini.
Langkah perbaikannya begini: Aku membuat PaymentInterface, lalu membuat dua Service terpisah yaitu Stripe dan Midtrans. Setelah itu, aku me-register (binding) mereka bertiga di AppServiceProvider. Terakhir, aku mengubah kode di Controller ShippingCash dan Invoice yang tadinya hardcode, menjadi hanya memanggil interface tersebut. Memang pemanggilannya masih di Controller karena aku menghindari perombakan sistem secara masif, tapi setidaknya kode ini sekarang jauh lebih rapi.
interface PaymentInterface
{
/**
* Buat transaksi pending dan ambil snap token dari gateway.
*
* @return array ['order_id', 'snap_token', 'snap_url', 'amount']
*/
public function initiatePayment(User $user, Course $course): array;
/**
* Proses notifikasi webhook dari gateway.
*/
public function handleWebhook(array $payload): void;
}
class InvoiceController extends Controller
{
public function __construct(private PaymentInterface $payment) {}
public function saveInvoice(Request $request): JsonResponse
{
$request->validate([
'user_id' => 'required|exists:users,id',
'course_id' => 'required|exists:courses,id',
]);
$user = User::findOrFail($request->user_id);
$course = Course::findOrFail($request->course_id);
// Simpan data invoice
$invoice = Invoice::create([
'user_id' => $user->id,
'course_id' => $course->id,
'amount' => $course->effective_price,
'status' => 'unpaid',
]);
// Buat payment — tidak perlu tahu gateway apa yang jalan di baliknya
$payment = $this->payment->initiatePayment($user, $course);
return response()->json([
'invoice_id' => $invoice->id,
'order_id' => $payment['order_id'],
'snap_token' => $payment['snap_token'],
'amount' => $payment['amount'],
]);
}
public function handleWebhook(Request $request): JsonResponse
{
$this->payment->handleWebhook($request->all());
return response()->json(['message' => 'OK']);
}
}Bagi yang penasaran tentang apa itu Interface dan bagaimana cara kerjanya, kalian bisa membaca artikelku yang ini 📖 [Link Artikel].
