Kali ini kita akan melanjutkan series tutorial yang paling penting yaitu integrasi Midtrans untuk metode pembayaran setelah melakukan checkout. Jika sebelumnya kita integrasi dengan API RajaOngkir, kali ini kita bermain dengan API Midtrans. 

Langkah 1: Buat Akun di Midtrans

Buka Midtrans Dashboard dan daftar akun

Setelah login, buat Project Baru untuk aplikasi Anda.

Di dashboard pilih enviroment sandbox, kemudian di menu pengaturan, Anda akan mendapatkan Server Key dan Client Key. Simpan ini untuk konfigurasi Laravel nanti.

midtrans

Langkah 2. Konfigurasi Midtrans

Install SDK Midtrans di Laravel:

composer require midtrans/midtrans-php

Konfigurasi API Key Midtrans: Tambahkan konfigurasi Midtrans di file .env:

MIDTRANS_SERVER_KEY=your-server-key
MIDTRANS_CLIENT_KEY=your-client-key
MIDTRANS_IS_PRODUCTION=false

Buat file konfigurasi Midtrans di config/midtrans.php:

<?php

return [
    'serverKey' => env('MIDTRANS_SERVER_KEY'),
    'clientKey' => env('MIDTRANS_CLIENT_KEY'),
    'isProduction' => env('MIDTRANS_IS_PRODUCTION', false),
    'is3ds' => true,
];

Tambahkan Service Provider di Laravel: Daftarkan konfigurasi di AppServiceProvider:

use Midtrans\Config;

public function boot()
{
    Config::$serverKey = config('midtrans.serverKey');
    Config::$isProduction = config('midtrans.isProduction');
    Config::$is3ds = config('midtrans.is3ds');
}

Langkah 3. Backend: Endpoint untuk Halaman Transaksi

Tambahkan metode di controller untuk memproses pembayaran dengan perintah:

php artisan make:controller OrderController

Edit file OrderController.php seperti berikut:

<?php
namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Midtrans\Snap;
use App\Models\Order; // Model untuk Order
use App\Models\OrderItem; // Model untuk item pesanan
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;

class OrderController extends Controller
{
    public function createTransaction(Request $request)
    {
        DB::beginTransaction();
        try {
            // Data Order
            $order = Order::create([
                'user_id' => auth()->id(), // ID User
                'total_price' => $request->totalPrice, // Total Price
                'status' => 'pending', // Status awal
            ]);

            // Simpan barang yang dipesan
            foreach ($request->cartItems as $item) {
                OrderItem::create([
                    'order_id' => $order->id,
                    'product_id' => $item['id'],
                    'quantity' => $item['quantity'],
                    'price' => $item['price'],
                ]);
            }

            // Data untuk Midtrans
            $transactionDetails = [
                'order_id' => $order->id, // ID order unik
                'gross_amount' => $request->totalPrice, // Total harga
            ];

            $itemDetails = [];
            foreach ($request->cartItems as $item) {
                $itemDetails[] = [
                    'id' => $item['id'],
                    'price' => $item['price'],
                    'quantity' => $item['quantity'],
                    'name' => $item['name'],
                ];
            }

            // Tambahkan shipping cost sebagai item baru
            $itemDetails[] = [
                'id' => 'shipping_cost',
                'price' => $request->shippingCost, // Ongkir
                'quantity' => 1,
                'name' => 'Shipping Cost',
            ];

            $payload = [
                'transaction_details' => $transactionDetails,
                'item_details' => $itemDetails,
                'customer_details' => [
                    'first_name' => auth()->user()->name,
                    'email' => auth()->user()->email,
                ],
            ];

            // Buat URL pembayaran menggunakan Snap
            $snapToken = Snap::getSnapToken($payload);
            Log::info('Generated Snap Token: ' . $snapToken);
            Log::info('Shipping Cost: ' . $request->shippingCost);

            DB::commit();

            return response()->json(['snapToken' => $snapToken, 'order_id' => $order->id,]);
        } catch (\Exception $e) {
            DB::rollBack();
            return response()->json(['error' => $e->getMessage()], 500);
        }
    }

    public function handleNotification(Request $request)
    {
        $notification = new \Midtrans\Notification();

        $order = Order::find($notification->id);
        if (!$order) {
            return response()->json(['error' => 'Order not found'], 404);
        }

        // Update status berdasarkan Midtrans
        $transactionStatus = $notification->transaction_status;

        if ($transactionStatus == 'capture' || $transactionStatus == 'settlement') {
            $order->status = 'success';
        } elseif ($transactionStatus == 'pending') {
            $order->status = 'pending';
        } else {
            $order->status = 'failed';
        }

        $order->save();

        return response()->json(['message' => 'Notification handled']);
    }
    public function updateStatus(Request $request)
    {
        $validated = $request->validate([
            'id' => 'required|exists:orders,id',
            'status' => 'required|string',
        ]);

        $order = Order::findOrFail($validated['id']);
        $order->update(['status' => $validated['status']]);

        return response()->json(['message' => 'Order status updated successfully']);
    }
}

Langkah 4. Vue.js: Memproses Pembayaran

Tambahkan fungsi untuk mengirim data ke backend dan memanggil Snap Popup Midtrans:

Buka file checkout.vue dan tambahkan method untuk memanggil OrderController:

  async processPayment() {

      try {
      // Panggil backend untuk membuat order dan mendapatkan Snap Token
      const response = await axios.post('/orders', {
        cartItems: this.cartItems,
        totalPrice: this.totalPrice + this.shippingCost,
        shippingCost: this.shippingCost,
      });

      // Simpan data order ID dan Snap Token ke properti Vue
      this.orderId = response.data.order_id; // Pastikan backend mengirimkan `id` order
      this.snapToken = response.data.snapToken;

      // Panggil Snap Midtrans
      window.snap.pay(this.snapToken, {
        onSuccess: async () => {
          try {
            // Akses `this.orderId` untuk mendapatkan ID order
            alert(`Order ID: ${this.orderId}`);

            // Perbarui status order di backend
            await axios.post('/orders/update-status', {
              id: this.orderId,
              status: 'paid',
            });

            alert('Order status updated!');
          } catch (error) {
            console.error('Error updating order status:', error);
            alert('Gagal memperbarui status order.');
          }
        },
        onPending: () => {
          alert('Payment sedang diproses!');
        },
        onError: () => {
          alert('Payment gagal!');
        },
      });
    } catch (error) {
      console.error('Payment gagal:', error.response?.data || error.message);
      alert('Terjadi kesalahan saat memproses pembayaran.');
    }
  },

di button bayar sekarang, panggil fungsi processPayment yang sudah kita defined tadi. 

<!-- Tombol Lanjut ke Pembayaran -->
<button @click="processPayment" class="mt-6 w-full bg-blue-500 hover:bg-blue-600 text-white py-2 px-4 rounded-md">
  Bayar Sekarang
</button>

Kira kira source code lengkap untuk checkout.vue seperti berikut:

<template>
    <div class="min-h-screen flex flex-col bg-gray-50">
      <!-- NAVBAR -->
      <header class="bg-white shadow z-10">
        <div class="max-w-7xl mx-auto px-4 py-4 flex items-center justify-between">
          <div class="text-2xl font-bold text-gray-800">MyShop</div>
          <nav class="flex items-center space-x-6">
            <Link href="/" class="text-gray-700 hover:text-gray-900 font-medium px-3 py-2 transition-colors">Home</Link>
          </nav>
        </div>
      </header>
  
      <main class="flex-1 py-10">
        <div class="container mx-auto p-4">
          <div class="grid grid-cols-1 md:grid-cols-2 gap-6">

            <div>
          <h1 class="text-3xl font-bold text-gray-800 mb-6">Checkout</h1>
  
          <!-- Error Message -->
          <div v-if="error" class="bg-red-100 text-red-600 p-4 mb-6 rounded">
            <p>{{ error }}</p>
          </div>
  
          <form @submit.prevent="submitOrder">
            <!-- Address Information -->
            <div class="mb-6">
              <label for="address" class="block text-sm font-medium text-gray-700">Address</label>
              <textarea id="address" v-model="address" class="mt-1 block w-full p-2 border rounded" required></textarea>
            </div>
  
            <!-- Select Province -->
            <div class="mb-6">
            <label for="province" class="block text-sm font-medium text-gray-700">Province</label>
            <select v-model="selectedProvince" @change="fetchCities" class="mt-1 block w-full p-2 border rounded">
              <option v-for="province in provinces" :value="province.id" :key="province.id">
                {{ province.name }}
              </option>
            </select>
            </div>

            <!-- Select City -->
        <div class="mb-6">
          <label for="city" class="block text-sm font-medium text-gray-700">City</label>
          <select id="destination" v-model="destination" class="mt-1 block w-full p-2 border rounded">
            <option v-for="city in cities" :value="city.id" :key="city.id">
              {{ city.name }}
            </option>
          </select>
        </div>
  
            <!-- Select Subdistrict -->
            <div class="mb-6">
              <label for="subdistrict" class="block text-sm font-medium text-gray-700">Subdistrict</label>
              <select v-model="selectedSubdistrict" class="mt-1 block w-full p-2 border rounded" required>
                <option value="" disabled selected>Select a subdistrict</option>
                <option v-for="subdistrict in subdistricts" :key="subdistrict.subdistrict_id" :value="subdistrict.subdistrict_id">{{ subdistrict.subdistrict_name }}</option>
              </select>
            </div>
  
            <!-- Shipping Cost Estimation -->
            <div class="mb-6">
              <label for="courier" class="block text-sm font-medium text-gray-700">Courier</label>
              <select v-model="courier" @change="fetchShippingCost" class="mt-1 block w-full p-2 border rounded" required>
                <option v-for="courier in couriers" :key="courier.code" :value="courier.code">
                  {{ courier.name }}
                </option>
              </select>
            </div>

             <!-- Select Services -->
            <div class="mb-6">
              <label for="city" class="block text-sm font-medium text-gray-700">Services</label>
              <select id="shipping-service" v-model="selectedService" @change="fetchCost" class="mt-1 block w-full p-2 border rounded">
                <option v-for="cost in shippingCosts" :key="cost.service" :value="cost">
                  {{ cost.service }}
                      {{ cost.cost[0].value | currency }} ({{ cost.cost[0].etd }} hari)
                </option>
              </select>
            </div>
          </form>

          </div>
          <!-- end kiri -->
          <div>
            <h2 class="text-lg font-semibold mb-4">Ringkasan Pesanan</h2>
            <div class="bg-gray-100 p-4 rounded-md shadow-md">
                <!-- Daftar Produk -->
                <div v-for="item in cartItems" :key="item.id" class="flex justify-between mb-2">
                    <span>{{ item.name }}</span>
                    <span>Rp {{ formatCurrency(item.price) }} x {{ item.quantity }}</span>
                </div>

                <!-- Total Harga Barang -->
                <div class="flex justify-between mt-4 border-t pt-2">
                    <span>Total Barang:</span>
                    <span>Rp {{ formatCurrency(totalPrice) }}</span>
                </div>

                <!-- Ongkos Kirim -->
                <div class="flex justify-between mt-2">
                    <span>Ongkos Kirim:</span>
                    <span v-if="shippingCost">Rp {{ formatCurrency(shippingCost) }}</span>
                    <span v-else>Rp 0</span>
                </div>

                <!-- Total Harga -->
                <div class="flex justify-between mt-4 border-t pt-2 font-semibold text-lg">
                    <span>Total Bayar:</span>
                    <span>Rp {{ formatCurrency(totalPrice + shippingCost) }}</span>
                </div>
            </div>

            <!-- Tombol Lanjut ke Pembayaran -->
            <button @click="processPayment" class="mt-6 w-full bg-blue-500 hover:bg-blue-600 text-white py-2 px-4 rounded-md">
              Bayar Sekarang
            </button>
        </div>

        </div>

        </div>
      </main>
  
      <footer class="bg-white py-4">
        <div class="max-w-7xl mx-auto px-4 text-center text-gray-600 text-sm">
          &copy; 2025 MyShop. All rights reserved.
        </div>
      </footer>
    </div>
  </template>

 <script>
  import {ref, computed } from 'vue';
  import axios from 'axios';
    export default {
      data() {
        return {
          provinces: [],
          cities: [],
          selectedProvince: null,
          selectedCity: null,
          origin: '501', // ID kota asal (contoh: Semarang)
          destination: null, // ID kota tujuan (dipilih user)
          weight: 1000, // Berat barang dalam gram
          courier: null, // Kurir (dipilih user)
          shippingCosts: [], // Menyimpan data ongkir
          couriers: [
              { name: 'JNE', code: 'jne' },
              { name: 'POS', code: 'pos' },
              { name: 'TIKI', code: 'tiki' },
          ],

          selectedService: null, // Layanan pengiriman yang dipilih
          shippingCost: 0, // Biaya pengiriman
          orderId: null,       // ID order, diisi setelah membuat order
          snapToken: null,     // Token Snap Midtrans, diisi setelah membuat order
        };
      },
      setup() {
        // Retrieve the cart from localStorage, or initialize an empty cart
        const cartItems = ref(JSON.parse(localStorage.getItem('cart')) || []);
    
        // Calculate total price
        const totalPrice = computed(() => {
          return cartItems.value.reduce((total, item) => total + item.subTotal, 0);
        });

        const totalwithShipping = computed(()=>{
          return this.totalPrice + this.shippingCost;
        })
    
        // Initialize subTotal for each item
        cartItems.value.forEach(item => {
          item.subTotal = item.price * item.quantity;
        });
    
        return {
          cartItems,
          totalPrice,
        };
      },
      methods: {
        formatCurrency(amount) {
            const formatted = amount.toLocaleString("id-ID", {
                style: "currency",
                currency: "IDR",
                minimumFractionDigits: 0,
                maximumFractionDigits: 2,
            });

            // Hapus desimal jika hanya berisi .00
            return formatted.replace(/,00$/, "");
        },
        async fetchProvinces() {
          const response = await axios.get('/provinces');
          this.provinces = response.data;
        },
        async fetchCities() {
          const response = await axios.get(`/cities/${this.selectedProvince}`);
          this.cities = response.data;
        },
        async fetchCost() {
            console.log('Selected service:', this.selectedService);
            const selected = this.selectedService; // Karena sekarang selectedService sudah berupa objek
            if (selected) {
                this.shippingCost = selected.cost[0].value;
            } else {
                console.error('No matching service found');
            }
        },
        async fetchShippingCost() {
              if (!this.destination || !this.courier) {
                  alert('Pilih kota tujuan dan kurir!');
                  return;
              }

              try {
                  const response = await axios.post('/shipping/cost', {
                      origin: this.origin,
                      destination: this.destination,
                      weight: this.weight,
                      courier: this.courier,
                  });

                  this.shippingCosts = response.data[0].costs; // Ambil biaya pengiriman
              } catch (error) {
                  console.error('Failed to fetch shipping cost:', error);
              }
          },
            async processPayment() {

              try {
              // Panggil backend untuk membuat order dan mendapatkan Snap Token
              const response = await axios.post('/orders', {
                cartItems: this.cartItems,
                totalPrice: this.totalPrice + this.shippingCost,
                shippingCost: this.shippingCost,
              });

              // Simpan data order ID dan Snap Token ke properti Vue
              this.orderId = response.data.order_id; // Pastikan backend mengirimkan `id` order
              this.snapToken = response.data.snapToken;

              // Panggil Snap Midtrans
              window.snap.pay(this.snapToken, {
                onSuccess: async () => {
                  try {
                    // Akses `this.orderId` untuk mendapatkan ID order
                    alert(`Order ID: ${this.orderId}`);

                    // Perbarui status order di backend
                    await axios.post('/orders/update-status', {
                      id: this.orderId,
                      status: 'paid',
                    });

                    alert('Order status updated!');
                  } catch (error) {
                    console.error('Error updating order status:', error);
                    alert('Gagal memperbarui status order.');
                  }
                },
                onPending: () => {
                  alert('Payment sedang diproses!');
                },
                onError: () => {
                  alert('Payment gagal!');
                },
              });
            } catch (error) {
              console.error('Payment gagal:', error.response?.data || error.message);
              alert('Terjadi kesalahan saat memproses pembayaran.');
            }
          },
      },
      
      mounted() {
        this.fetchProvinces();
        const script = document.createElement('script');
        script.src = 'https://app.sandbox.midtrans.com/snap/snap.js';
        script.type = 'text/javascript';
        script.setAttribute('data-client-key', 'SB-Mid-client-YR7We1Ycxm1E-kzs');
        document.body.appendChild(script);
      },
    };
</script>
  <style scoped>
  /* Styling */
  </style>
  

Langkah 5. Update Model dan Database

Migration

Buat migrasi untuk orders dan order_items:

php artisan make:migration create_orders_table
php artisan make:migration create_order_items_table

Edit Migration Orders Table:

Schema::create('orders', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained()->onDelete('cascade');
    $table->decimal('total_price', 15, 2);
    $table->string('status')->default('pending');
    $table->timestamps();
});

Migration Order Items Table:

Schema::create('order_items', function (Blueprint $table) {
    $table->id();
    $table->foreignId('order_id')->constrained()->onDelete('cascade');
    $table->foreignId('product_id')->constrained()->onDelete('cascade');
    $table->integer('quantity');
    $table->decimal('price', 15, 2);
    $table->timestamps();
});

Lalu ketik perintah migrate: 

php artisan migrate

Buat Order Model dengan perintah berikut:

php artisan make:model Order

Edit Order Model menjadi berikut:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Order extends Model
{
    protected $fillable = ['user_id', 'total_price', 'status'];

    public function items()
    {
        return $this->hasMany(OrderItem::class);
    }
}

Kemudian buat model OrderItem untuk menyimpan item-item yang diorder. 

php artisan make:model OrderItem

Lalu isi OrderItem sebagai berikut:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class OrderItem extends Model
{
    protected $fillable = ['order_id', 'product_id', 'quantity', 'price'];

    public function order()
    {
        return $this->belongsTo(Order::class);
    }
}

Langkah 6. Tambahkan Routes

Tambahkan di file routes/web.php:

use App\Http\Controllers\OrderController;

Route::post('/orders', [OrderController::class, 'createTransaction'])->name('orders');
Route::post('/orders/update-status', [OrderController::class, 'updateStatus']);

// Endpoint untuk menerima notifikasi dari Midtrans
Route::post('/orders/notification', [OrderController::class, 'handleNotification'])->name('orders.notification');

Langkah 7. Testing

Jika semua sudah benar, akan menghasilkan seperti berikut:

checkout

Klik Bayar sekarang, maka akan muncul snap midtrans seperti berikut:

snap midtrans

Lalu lakukan simulasi pembayaran di Simulator Sandbox Midtrans:

Simulator sandbox

result simulator

 

Jika sudah berhasil, maka otomatis payment di frontend akan berhasil, dan ada notif seperti berikut:

payment success

Dan otomatis data di database, status nya berubah jadi paid

paid

Selamat Mencoba!!!!