Kalau kamu sudah punya toko digital yang jalan — atau lagi bangun satu seperti di artikel sebelumnya — ada satu fitur yang bisa melipatgandakan penjualan tanpa kamu harus nambah budget iklan: sistem affiliate.
Idenya sederhana. Orang lain bantu promosiin produk kamu. Kalau ada yang beli lewat mereka, mereka dapat komisi. Kamu dapat pelanggan baru. Win-win.
Affiliate Itu Sebenarnya Sudah Kamu Kenal
Kamu pasti pernah lihat ini: influencer atau kreator konten share link produk dengan kode khusus. "Pakai kode DITO20 untuk diskon 20%." Atau link di bio Instagram yang kalau diklik langsung ke halaman produk tertentu.
Itu sistem affiliate. Bedanya, di platform besar seperti Tokopedia Affiliate atau Shopee Affiliate, sistemnya dikelola platform — kamu sebagai seller ikut aturan mereka, bayar fee platform, dan data pembeli ada di tangan mereka.
Kalau kamu bangun sendiri, semua itu jadi milik kamu. Tidak ada platform fee. Komisi langsung masuk ke rekening affiliate. Dan kamu bisa atur sendiri: berapa persen komisi, siapa yang boleh jadi affiliate, kapan payout dilakukan.
Untuk toko produk digital skala kecil-menengah, ini sangat worth it.
Cara Kerja yang Kita Bangun
Sebelum lihat satu baris kode pun, pahami dulu alurnya:
[1] Affiliate daftar → dapat referral code unik (contoh: "DITO20")
↓
[2] Affiliate share link: <https://toko.com/?ref=DITO20>
↓
[3] Visitor klik link → browser visitor menyimpan "DITO20" selama 30 hari
↓
[4] Visitor beli produk (kapan saja dalam 30 hari)
↓
[5] Sistem deteksi → catat komisi untuk affiliate DITO20
↓
[6] Admin approve komisi
↓
[7] Xendit transfer uang langsung ke rekening bank affiliate
Ada satu istilah yang perlu kamu kenalan dulu: cookie. Di langkah [3], kita menyimpan referral code ke cookie — semacam catatan kecil yang disimpan di browser visitor. Catatan ini tidak hilang meski browser ditutup, dan akan otomatis terbaca saat visitor melakukan pembelian, selama masih dalam batas waktu 30 hari. Nanti di Bagian 3 kita akan bahas ini lebih detail.
Bikin Sendiri vs Pakai Platform Affiliate
Sebelum lanjut, wajar kalau kamu bertanya: repot-repot bikin sendiri, kenapa tidak pakai platform yang sudah jadi?
| Platform Affiliate (Pihak Ketiga) | Bikin Sendiri | |
|---|---|---|
| Setup | Cepat, tinggal daftar | Perlu development |
| Platform fee | Ada (biasanya 1-5% per transaksi) | Tidak ada |
| Kontrol komisi | Terbatas, ikut aturan platform | Bebas, kamu yang tentukan |
| Data buyer | Milik platform | Milik kamu |
| Payout | Via platform | Langsung ke rekening affiliate |
| Customization | Terbatas | Bebas |
Kalau kamu baru mulai dan belum yakin sistem affiliate akan jalan, pakai platform pihak ketiga dulu tidak apa-apa. Tapi kalau toko kamu sudah punya traffic dan kamu mau scale, bikin sendiri jauh lebih menguntungkan dalam jangka panjang.
Yang Akan Kita Bangun
Sampai akhir artikel ini, sistem affiliate kamu akan punya fitur:
- Halaman pendaftaran affiliate dengan input data bank
- Referral link unik per affiliate
- Tracking otomatis via cookie (30 hari)
- Komisi tercatat otomatis setiap ada pembelian lewat referral
- Dashboard affiliate: lihat klik, komisi pending, total earnings
- Admin panel: approve komisi, trigger payout
- Auto transfer ke rekening bank affiliate via Xendit Disbursement API
Semua dibangun dengan pendekatan yang sama seperti artikel sebelumnya: tulis prompt ke AI → review hasilnya → pakai kode yang sudah divalidasi. Setiap bagian akan saya tunjukkan prompt persis yang saya pakai, apa yang perlu kamu cek dari hasilnya, dan kode final yang siap digunakan.
Satu Hal yang Perlu Kamu Siapkan
Artikel ini adalah ekstensi dari toko digital yang dibangun di artikel sebelumnya — jadi kita asumsikan kamu sudah punya struktur Laravel 12 dengan tabel users, products, orders, dan order_items. Kalau belum, bisa baca artikel sebelumnya dulu, atau langsung ikuti saja karena tiap bagian akan saya jelaskan dari awal.
Satu tambahan yang perlu disiapkan: akun Xendit dengan fitur Disbursement aktif. Berbeda dengan Invoice API yang langsung bisa dipakai setelah daftar, Disbursement butuh verifikasi bisnis di Xendit Dashboard. Proses verifikasinya biasanya 1-3 hari kerja. Daftar sekarang sambil ikuti artikel ini, supaya saat sampai di Bagian 5 akun kamu sudah siap.
Di bagian selanjutnya kita mulai dari fondasi: desain database untuk sistem affiliate. Saya akan jelaskan dulu apa fungsi tiap tabel dengan analogi yang mudah dipahami, baru kemudian kita buat migration dan model-nya lewat vibe coding.
Bagian 2: Database Design — Dari Nol, Pelan-Pelan
Sebelum tulis satu baris kode pun, kita pahami dulu struktur data yang dibutuhkan. Ini kebiasaan yang bagus dalam vibe coding — kalau kamu tidak paham strukturnya, prompt yang kamu tulis ke AI akan kabur dan hasilnya tidak optimal.
Kita butuh 4 tabel baru. Biar mudah, bayangkan tiap tabel seperti dokumen fisik yang biasa kamu temui di dunia bisnis.
affiliates — seperti kartu member reseller. Satu orang, satu kartu, ada kode unik, ada info rekening bank untuk transfer komisi, ada status apakah aktif atau belum.
referral_clicks — seperti buku tamu. Setiap ada orang yang klik referral link, kita catat: siapa yang punya link itu, dari IP mana, jam berapa. Berguna untuk analytics nanti.
commissions — seperti slip komisi yang belum dicairkan. Setiap ada pembelian lewat referral, terbit satu slip. Awalnya statusnya pending, nanti admin approve jadi approved, setelah ditransfer jadi paid.
payouts — seperti bukti transfer ke rekening. Satu payout bisa cover banyak slip komisi sekaligus — lebih efisien daripada transfer satu-satu per komisi.
Relasinya kalau digambarkan:
users
└── 1:1 ──→ affiliates
├── 1:N ──→ referral_clicks
├── 1:N ──→ commissions ←── N:1 ── orders
└── 1:N ──→ payouts
Sekarang kita buat migration-nya lewat vibe coding.
Migration Files
Prompt yang saya pakai:
Saya mau tambahkan sistem affiliate ke toko digital Laravel 12 yang sudah ada.
Database yang sudah ada: users, products, orders (pakai UUID), order_items, downloads.
Buatkan migration untuk 4 tabel baru berikut:
1. affiliates
Kolom: id (auto increment), user_id (FK ke users, unique — satu user satu akun affiliate),
referral_code (string 8, unique), commission_rate (decimal 5,2 — contoh: 10.00 = 10%),
bank_name (string), bank_account_number (string), bank_account_holder (string),
status (enum: pending, active, suspended, default pending),
total_earned (unsignedBigInteger, default 0), timestamps.
2. referral_clicks
Kolom: id (auto increment), affiliate_id (FK ke affiliates, cascade delete),
ip_address (string 45 — support IPv6), user_agent (text, nullable),
landed_at (timestamp — waktu klik, bukan created_at biasa).
Tidak perlu kolom timestamps standar, cukup landed_at.
3. commissions
Kolom: id (UUID), affiliate_id (FK ke affiliates, cascade delete),
order_id (FK ke orders — tipe UUID karena orders pakai UUID),
amount (unsignedBigInteger — dalam Rupiah, tidak pakai desimal),
status (enum: pending, approved, paid, rejected, default pending),
paid_at (timestamp, nullable), timestamps.
4. payouts
Kolom: id (UUID), affiliate_id (FK ke affiliates, cascade delete),
total_amount (unsignedBigInteger),
xendit_disbursement_id (string, nullable, index),
xendit_reference_id (string, nullable),
status (enum: processing, completed, failed, default processing),
processed_at (timestamp, nullable), timestamps.
Tambahkan indexes untuk kolom yang sering di-query:
- affiliates: referral_code, user_id
- referral_clicks: affiliate_id, landed_at
- commissions: affiliate_id + status (composite), order_id
- payouts: affiliate_id
Cara review hasilnya:
Ada beberapa hal yang perlu kamu cek sebelum jalankan migration ini:
Pertama, tipe data uang. Pastikan commission_rate pakai decimal(5, 2) bukan float. Ini penting karena float punya masalah presisi di beberapa operasi matematika — kalau komisi 10% dari Rp 150.000 hasilnya 14999.9999 bukan 15000, itu masalah. decimal tidak punya issue ini.
Kedua, kolom amount dan total_amount harus unsignedBigInteger. Kalau pakai integer biasa, nilai maksimalnya sekitar Rp 2,1 miliar — untuk toko yang ramai, ini bisa overflow.
Ketiga, foreign key order_id di tabel commissions. Karena tabel orders pakai UUID, foreign key-nya tidak bisa pakai $table->foreignId('order_id') — harus $table->foreignUuid('order_id'). Ini yang paling sering salah dari hasil generate AI.
Keempat, unique di user_id tabel affiliates. Ini penting supaya satu user tidak bisa daftar jadi affiliate dua kali.
Hasil generate (sudah divalidasi):
// database/migrations/xxxx_create_affiliates_table.php
public function up(): void
{
Schema::create('affiliates', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->unique()->constrained()->cascadeOnDelete();
$table->string('referral_code', 8)->unique();
$table->decimal('commission_rate', 5, 2)->default(10.00); // persentase
$table->string('bank_name');
$table->string('bank_account_number');
$table->string('bank_account_holder');
$table->enum('status', ['pending', 'active', 'suspended'])->default('pending');
$table->unsignedBigInteger('total_earned')->default(0);
$table->timestamps();
$table->index('referral_code'); // sering di-query saat tracking klik
});
}
// database/migrations/xxxx_create_referral_clicks_table.php
public function up(): void
{
Schema::create('referral_clicks', function (Blueprint $table) {
$table->id();
$table->foreignId('affiliate_id')->constrained()->cascadeOnDelete();
$table->string('ip_address', 45); // 45 char untuk support format IPv6
$table->text('user_agent')->nullable();
$table->timestamp('landed_at'); // pakai ini, bukan created_at
$table->index(['affiliate_id', 'landed_at']); // query: "berapa klik minggu ini?"
});
}
// database/migrations/xxxx_create_commissions_table.php
public function up(): void
{
Schema::create('commissions', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignId('affiliate_id')->constrained()->cascadeOnDelete();
// order_id harus foreignUuid karena orders pakai UUID
$table->foreignUuid('order_id')->constrained()->cascadeOnDelete();
$table->unsignedBigInteger('amount'); // dalam Rupiah, tanpa desimal
$table->enum('status', ['pending', 'approved', 'paid', 'rejected'])->default('pending');
$table->timestamp('paid_at')->nullable();
$table->timestamps();
// Composite index — query paling sering: "komisi affiliate ini yang pending"
$table->index(['affiliate_id', 'status']);
$table->index('order_id');
});
}
// database/migrations/xxxx_create_payouts_table.php
public function up(): void
{
Schema::create('payouts', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignId('affiliate_id')->constrained()->cascadeOnDelete();
$table->unsignedBigInteger('total_amount');
$table->string('xendit_disbursement_id')->nullable()->index();
$table->string('xendit_reference_id')->nullable(); // ID dari sisi kita
$table->enum('status', ['processing', 'completed', 'failed'])->default('processing');
$table->timestamp('processed_at')->nullable();
$table->timestamps();
$table->index('affiliate_id');
});
}
Jalankan:
php artisan migrate
Eloquent Models
Sekarang kita buat model-nya. Bagian yang paling perlu diperhatikan di sini adalah generateReferralCode() — method static yang generate kode unik 8 karakter.
Prompt yang saya pakai:
Buatkan Eloquent Models untuk sistem affiliate Laravel 12.
Satu file per model.
1. Affiliate model (app/Models/Affiliate.php):
- belongsTo User
- hasMany ReferralClick
- hasMany Commission
- hasMany Payout
- Scope: active() — filter status = 'active'
- Accessor: formatted_total_earned — format Rupiah (contoh: "Rp 150.000")
- Static method: generateReferralCode() — generate string 8 karakter alphanumeric
uppercase yang belum dipakai di tabel affiliates. Loop sampai dapat yang unique.
- Cast: commission_rate sebagai decimal, total_earned sebagai integer
2. Commission model (app/Models/Commission.php):
- use HasUuids
- belongsTo Affiliate
- belongsTo Order
- Scope: pending(), approved(), paid(), rejected()
- Accessor: formatted_amount — format Rupiah
3. Payout model (app/Models/Payout.php):
- use HasUuids
- belongsTo Affiliate
- Scope: completed(), failed(), processing()
- Accessor: formatted_total_amount — format Rupiah
Cara review hasilnya:
generateReferralCode()harus ada loopdo...whileyang cek ke database sampai dapat code yang benar-benar belum ada. Kalau AI generate tanpa pengecekan unique, ada risiko collision meski kecil — dan collision di referral code berarti komisi bisa salah orang.HasUuidstrait diCommissiondanPayoutharus di-import dariIlluminate\\Database\\Eloquent\\Concerns\\HasUuids. AI kadang lupa import-nya.- Cast
commission_ratesebaiknya'decimal:2'bukan'float'— konsisten dengan tipe kolom di database.
Hasil generate (sudah divalidasi):
<?php
// app/Models/Affiliate.php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;
use Illuminate\\Support\\Str;
class Affiliate extends Model
{
protected $fillable = [
'user_id', 'referral_code', 'commission_rate',
'bank_name', 'bank_account_number', 'bank_account_holder',
'status', 'total_earned',
];
protected $casts = [
'commission_rate' => 'decimal:2',
'total_earned' => 'integer',
];
// Relationships
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function clicks(): HasMany
{
return $this->hasMany(ReferralClick::class);
}
public function commissions(): HasMany
{
return $this->hasMany(Commission::class);
}
public function payouts(): HasMany
{
return $this->hasMany(Payout::class);
}
// Scope
public function scopeActive($query)
{
return $query->where('status', 'active');
}
// Accessor
public function getFormattedTotalEarnedAttribute(): string
{
return 'Rp ' . number_format($this->total_earned, 0, ',', '.');
}
// Generate referral code unik — loop sampai dapat yang belum ada di DB
public static function generateReferralCode(): string
{
do {
// 8 karakter uppercase alphanumeric, mudah dibaca dan di-share
$code = strtoupper(Str::random(8));
} while (self::where('referral_code', $code)->exists());
return $code;
}
}
<?php
// app/Models/Commission.php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Concerns\\HasUuids;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;
class Commission extends Model
{
use HasUuids;
protected $fillable = [
'affiliate_id', 'order_id', 'amount', 'status', 'paid_at',
];
protected $casts = [
'amount' => 'integer',
'paid_at' => 'datetime',
];
public function affiliate(): BelongsTo
{
return $this->belongsTo(Affiliate::class);
}
public function order(): BelongsTo
{
return $this->belongsTo(Order::class);
}
// Scopes — berguna untuk filter di controller dan query
public function scopePending($query) { return $query->where('status', 'pending'); }
public function scopeApproved($query) { return $query->where('status', 'approved'); }
public function scopePaid($query) { return $query->where('status', 'paid'); }
public function scopeRejected($query) { return $query->where('status', 'rejected'); }
public function getFormattedAmountAttribute(): string
{
return 'Rp ' . number_format($this->amount, 0, ',', '.');
}
}
<?php
// app/Models/Payout.php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Concerns\\HasUuids;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;
class Payout extends Model
{
use HasUuids;
protected $fillable = [
'affiliate_id', 'total_amount',
'xendit_disbursement_id', 'xendit_reference_id',
'status', 'processed_at',
];
protected $casts = [
'total_amount' => 'integer',
'processed_at' => 'datetime',
];
public function affiliate(): BelongsTo
{
return $this->belongsTo(Affiliate::class);
}
public function scopeCompleted($query) { return $query->where('status', 'completed'); }
public function scopeFailed($query) { return $query->where('status', 'failed'); }
public function scopeProcessing($query) { return $query->where('status', 'processing'); }
public function getFormattedTotalAmountAttribute(): string
{
return 'Rp ' . number_format($this->total_amount, 0, ',', '.');
}
}
Satu model lagi yang sederhana — tidak perlu prompt ke AI, cukup buat manual karena strukturnya straightforward:
<?php
// app/Models/ReferralClick.php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;
class ReferralClick extends Model
{
// Tidak pakai timestamps standar — kita punya kolom landed_at sendiri
public $timestamps = false;
protected $fillable = ['affiliate_id', 'ip_address', 'user_agent', 'landed_at'];
protected $casts = ['landed_at' => 'datetime'];
public function affiliate(): BelongsTo
{
return $this->belongsTo(Affiliate::class);
}
}
Database sudah siap. Di bagian selanjutnya kita masuk ke bagian yang paling menarik secara teknis: bagaimana cara sistem kita "ingat" bahwa seorang visitor datang lewat referral link tertentu, bahkan setelah mereka tutup browser dan buka lagi keesokan harinya — ini yang kita implementasikan lewat cookie tracking dan middleware.
Bagian 3: Referral Link dan Cookie Tracking
Ini bagian yang paling menarik secara teknis — dan juga bagian di mana kita akan lihat langsung bagaimana proses iterasi dalam vibe coding bekerja. Prompt pertama yang kita tulis ternyata punya kelemahan logika, dan kita akan perbaiki bersama.
Tapi sebelum itu, kita pahami dulu mekanismenya.
Cookie Itu Apa?
Bayangkan kamu masuk ke sebuah toko fisik. Kasir kasih kamu stiker kecil di tangan: "Kamu datang dari rekomendasi Dito." Stiker itu tidak hilang meski kamu keluar dan masuk lagi. Kasir akan selalu tahu kamu datang dari siapa, sampai stikernya kadaluarsa.
Cookie di browser bekerja persis seperti itu. Saat visitor klik referral link ?ref=DITO20, browser mereka menyimpan catatan kecil: "affiliate ID = 5". Catatan itu bertahan 30 hari. Kalau visitor beli produk kapan saja dalam 30 hari itu, sistem kita bisa baca catatan tersebut dan catat komisi untuk affiliate yang tepat.
Kenapa 30 hari? Ini standar industri yang umum. Cukup panjang untuk memberi waktu visitor yang butuh waktu mikir sebelum beli, tapi tidak terlalu lama sampai tidak masuk akal.
Alur yang Akan Kita Bangun
Visitor buka: toko.com/?ref=DITO20
↓
Middleware baca query param ?ref
↓
Cari affiliate dengan referral_code = "DITO20"
↓
Simpan affiliate_id ke cookie (30 hari)
Catat click ke tabel referral_clicks
↓
Redirect ke toko.com (URL bersih, tanpa ?ref)
↓
Visitor jalan-jalan, lihat produk...
↓
Visitor klik "Beli" → masuk cart → checkout
↓
CheckoutController baca cookie → simpan affiliate_id ke order
↓
Xendit Webhook masuk → baca affiliate_id dari order → catat komisi
Perhatikan alurnya baik-baik, terutama dua langkah terakhir. Komisi tidak dicatat di webhook berdasarkan cookie — tapi berdasarkan affiliate_id yang sudah disimpan di tabel orders saat checkout. Ini penting dan akan jadi teachable moment di bagian ini.
Middleware untuk Tracking Referral
Prompt yang saya pakai:
Buatkan middleware Laravel 12 bernama ReferralTrackingMiddleware
di app/Http/Middleware/ReferralTrackingMiddleware.php.
Cara kerja:
- Cek apakah ada query parameter ?ref di URL
- Kalau ada:
1. Cari Affiliate berdasarkan referral_code = nilai ?ref, status harus 'active'
2. Kalau affiliate ditemukan:
a. Simpan affiliate->id ke cookie bernama 'ref_affiliate_id',
expiry 30 hari (60 * 24 * 30 menit)
b. Catat ke tabel referral_clicks: affiliate_id, ip_address dari request,
user_agent dari request header, landed_at = now()
c. Redirect ke URL yang sama tapi hapus query param ?ref
(supaya URL di address bar terlihat bersih)
3. Kalau tidak ditemukan, lanjutkan request seperti biasa
- Kalau tidak ada ?ref, lanjutkan request seperti biasa
Daftarkan sebagai global web middleware di bootstrap/app.php
supaya aktif di semua halaman.
Cara review hasilnya:
- Pastikan redirect setelah simpan cookie menggunakan
redirect()->away()atauredirect($cleanUrl)— bukanback(). Kalau pakaiback(), user akan redirect ke halaman sebelumnya, bukan URL yang sedang dibuka. - Cookie harus di-attach ke response redirect:
redirect($cleanUrl)->withCookie(...). KalauCookie::queue()dipakai tapi redirect tidak return response dengan cookie tersebut, cookie tidak akan tersimpan. - Cek cara hapus query param
?refdari URL — AI kadang generate regex yang rumit padahal bisa pakairequest()->fullUrlWithoutQuery(['ref'])yang sudah built-in di Laravel. - Cek: middleware ini harus toleran terhadap referral code yang tidak valid — tidak boleh throw error, cukup lanjutkan request.
Hasil generate (sudah divalidasi):
<?php
// app/Http/Middleware/ReferralTrackingMiddleware.php
namespace App\\Http\\Middleware;
use App\\Models\\Affiliate;
use App\\Models\\ReferralClick;
use Closure;
use Illuminate\\Http\\Request;
use Symfony\\Component\\HttpFoundation\\Response;
class ReferralTrackingMiddleware
{
public function handle(Request $request, Closure $next): Response
{
// Hanya proses kalau ada query param ?ref
if ($request->has('ref')) {
$refCode = $request->query('ref');
$affiliate = Affiliate::active()
->where('referral_code', $refCode)
->first();
if ($affiliate) {
// Catat klik ke database
ReferralClick::create([
'affiliate_id' => $affiliate->id,
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
'landed_at' => now(),
]);
// Hapus ?ref dari URL supaya address bar bersih
// request()->fullUrlWithoutQuery() sudah built-in di Laravel
$cleanUrl = $request->fullUrlWithoutQuery(['ref']);
// Redirect dengan cookie — expiry 30 hari dalam menit
return redirect($cleanUrl)
->withCookie(cookie('ref_affiliate_id', $affiliate->id, 60 * 24 * 30));
}
}
return $next($request);
}
}
Daftarkan sebagai global middleware di bootstrap/app.php:
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
$middleware->web(append: [
\\App\\Http\\Middleware\\ReferralTrackingMiddleware::class,
]);
})
Simpan Affiliate ID ke Order Saat Checkout
Nah, di sinilah teachable moment-nya.
Waktu pertama kali saya prompt untuk bagian ini, saya minta AI buat logic seperti ini: "saat webhook Xendit masuk dan status PAID, baca cookie ref_affiliate_id dan catat komisi."
Kelihatannya masuk akal. Tapi ada masalah besar: webhook tidak punya akses ke cookie browser.
Webhook adalah request yang dikirim dari server Xendit ke server kita — bukan dari browser user. Jadi tidak ada cookie di sana. Kalau kita coba baca cookie di webhook controller, hasilnya selalu kosong.
Ini contoh nyata kenapa review hasil AI itu penting. Kodenya akan berjalan tanpa error, tapi komisi tidak pernah tercatat. Bug yang susah di-debug karena tidak ada pesan error sama sekali.
Solusinya: simpan affiliate_id ke tabel orders saat checkout, bukan saat webhook. Saat checkout, user masih di browser — cookie masih bisa dibaca. Nanti webhook tinggal baca dari kolom affiliate_id di order.
Pertama, tambahkan kolom ke tabel orders:
php artisan make:migration add_affiliate_id_to_orders_table
// migration
public function up(): void
{
Schema::table('orders', function (Blueprint $table) {
$table->foreignId('affiliate_id')
->nullable()
->constrained()
->nullOnDelete()
->after('user_id');
});
}
Prompt lanjutan yang saya pakai:
Update CheckoutController yang sudah ada.
Di method process(), sebelum create order, tambahkan logic:
- Baca cookie 'ref_affiliate_id' dari request
- Kalau ada, validasi: affiliate harus exist dan status 'active'
- Kalau valid, simpan affiliate_id ke data order yang akan dibuat
- Kalau tidak ada cookie atau affiliate tidak valid, affiliate_id = null
Jangan ubah logic lain di method ini, hanya tambahkan bagian affiliate_id.
Cara review hasilnya:
- Pastikan
affiliate_iddi data order di-set kenullkalau tidak ada cookie — bukan di-skip. Kalau di-skip, Laravel bisa complain tentang kolom yang tidak nullable tanpa default value. - Pastikan tidak ada error kalau cookie ada tapi affiliate ID-nya sudah tidak valid (affiliate di-suspend setelah cookie tersimpan). Harus ada null coalescing atau pengecekan.
Hasil generate (sudah divalidasi):
// Update di CheckoutController.php — bagian awal method process()
public function process(Request $request, CartService $cart, XenditService $xendit)
{
$items = $cart->get();
if (empty($items)) {
return back()->with('error', 'Cart kamu kosong.');
}
// Baca affiliate dari cookie — null kalau tidak ada atau tidak valid
$affiliateId = null;
$cookieAffiliateId = $request->cookie('ref_affiliate_id');
if ($cookieAffiliateId) {
$affiliate = \\App\\Models\\Affiliate::active()->find($cookieAffiliateId);
$affiliateId = $affiliate?->id; // null kalau affiliate tidak ditemukan/aktif
}
try {
DB::transaction(function () use ($items, $cart, $xendit, $affiliateId, &$invoiceUrl) {
// ... order_number generation yang sudah ada ...
$order = Order::create([
'user_id' => auth()->id(),
'affiliate_id' => $affiliateId, // null kalau tidak ada referral
'order_number' => $orderNumber,
'total' => $cart->total(),
'status' => 'pending',
]);
// ... sisa logic yang sudah ada ...
});
return Inertia::location($invoiceUrl);
} catch (Exception $e) {
return back()->with('error', $e->getMessage());
}
}
Catat Komisi di Webhook
Sekarang webhook bisa dengan mudah baca affiliate_id dari order — tanpa perlu akses cookie sama sekali.
Prompt yang saya pakai:
Update XenditWebhookController yang sudah ada.
Setelah order berhasil di-update status ke 'paid',
tambahkan logic untuk catat komisi affiliate:
1. Cek apakah order->affiliate_id tidak null
2. Kalau ada, load affiliate: $order->affiliate
3. Hitung komisi: floor(order->total * affiliate->commission_rate / 100)
Gunakan floor() bukan round() — lebih aman, tidak overpay
4. Buat record Commission:
affiliate_id, order_id, amount = hasil perhitungan, status = 'pending'
5. Increment affiliate->total_earned dengan amount komisi
6. Semua dalam DB transaction yang sudah ada (jangan buat transaction baru)
Jangan ubah logic lain di webhook.
Cara review hasilnya:
- Pastikan
floor()dipakai, bukanround(). Untuk komisi 10% dari Rp 150.000,floormenghasilkan Rp 15.000 yang tepat. Tapi untuk angka seperti Rp 153.333,floormemberi Rp 15.333 danroundmemberi Rp 15.333 juga — bedanya di angka yang lebih tricky. Prinsipnya: kalau ragu, selalu bayar lebih sedikit dari yang seharusnya, jangan lebih banyak. - Pastikan
Commission::create()ada di dalamDB::transaction()yang sama dengan update order status. Ini krusial — kalau order berhasil diupdate tapi commission gagal dibuat karena error apapun, dua hal itu harus rollback bersama.
Hasil generate (sudah divalidasi):
// Update di XenditWebhookController.php — di dalam blok if ($request->status === 'PAID')
if ($request->status === 'PAID' && $order->status !== 'paid') {
// Update status order — sudah ada sebelumnya
$order->update([
'status' => 'paid',
'paid_at' => now(),
'payment_method' => $request->payment_method,
'payment_channel' => $request->payment_channel,
]);
// Buat download access — sudah ada sebelumnya
foreach ($order->items as $item) {
Download::firstOrCreate(
['user_id' => $order->user_id, 'product_id' => $item->product_id, 'order_id' => $order->id],
['download_count' => 0]
);
}
// Catat komisi affiliate — tambahan baru
if ($order->affiliate_id) {
$affiliate = $order->affiliate; // load relasi
// floor() supaya tidak overpay komisi
$commissionAmount = (int) floor(
$order->total * ($affiliate->commission_rate / 100)
);
Commission::create([
'affiliate_id' => $affiliate->id,
'order_id' => $order->id,
'amount' => $commissionAmount,
'status' => 'pending',
]);
// Tambahkan ke total earnings affiliate
$affiliate->increment('total_earned', $commissionAmount);
}
// Dispatch email konfirmasi — sudah ada sebelumnya
SendOrderConfirmationEmail::dispatch($order);
}
Jangan lupa tambahkan relasi di Order model:
// app/Models/Order.php — tambahkan relasi
public function affiliate(): BelongsTo
{
return $this->belongsTo(Affiliate::class);
}
Sistem tracking sekarang sudah bekerja end-to-end: dari klik referral link, cookie tersimpan, affiliate ID ikut di checkout, sampai komisi tercatat saat pembayaran dikonfirmasi. Di bagian selanjutnya kita bangun tampilan untuk affiliate itu sendiri — dashboard di mana mereka bisa lihat statistik klik, jumlah komisi pending, dan total earnings mereka.
Bagian 4: Affiliate Dashboard — Lihat Earnings dan Statistik
Sekarang kita bangun tampilan untuk affiliate. Mereka butuh tiga hal: cara daftar, cara lihat performa link mereka, dan cara tahu kapan komisi mereka bisa dicairkan.
Kita mulai dari flow pendaftaran, lalu dashboard-nya.
Affiliate Registration
Sebelum prompt, pahami dulu flow-nya:
- User yang sudah punya akun login → buka halaman daftar affiliate
- Isi form: nama bank, nomor rekening, nama pemilik rekening
- Submit → sistem generate referral code otomatis → status
pending - Admin approve → status jadi
active→ affiliate bisa mulai share link
Status pending di awal ini penting. Kamu tidak mau sembarang orang langsung aktif sebagai affiliate tanpa kamu review dulu, terutama karena sistem ini akan transfer uang ke rekening mereka.
Prompt yang saya pakai:
Buatkan AffiliateController di app/Http/Controllers/AffiliateController.php
dengan tiga method untuk Laravel 12 + Inertia + Vue 3.
1. showRegister() GET
- Kalau user sudah punya affiliate account (cek di tabel affiliates),
redirect ke route('affiliate.dashboard')
- Return Inertia render 'Affiliate/Register'
2. register() POST
Validasi:
- bank_name: required, string, max 100
- bank_account_number: required, string, max 30
- bank_account_holder: required, string, max 100
Logic:
- Generate referral_code via Affiliate::generateReferralCode()
- Buat Affiliate baru dengan user_id = auth()->id(),
commission_rate = 10.00 (default), status = 'pending'
- Redirect ke route('affiliate.dashboard') dengan success message
3. dashboard() GET
- Middleware auth wajib
- Ambil affiliate milik user yang login, kalau tidak ada redirect ke register
- Data yang dikirim ke view:
a. affiliate: id, referral_code, status, commission_rate, formatted_total_earned
b. referral_link: request()->getSchemeAndHttpHost() . '/?ref=' . affiliate->referral_code
c. stats:
- total_clicks: count semua referral_clicks milik affiliate ini
- pending_amount: sum amount commissions yang status = pending
- approved_amount: sum amount commissions yang status = approved
d. recent_commissions: 10 commission terbaru dengan kolom:
id, formatted_amount, status, created_at (format: 'd M Y')
dan order_number dari relasi order
Semua route dengan middleware 'auth'.
Cara review hasilnya:
referral_linkharus full URL dengan scheme (https/http) — bukan relative path. Kalau affiliate copy link dan share ke WhatsApp, mereka butuh URL lengkap. Pakairequest()->getSchemeAndHttpHost()bukanurl('/')karena yang kedua bisa return URL tanpa trailing context yang benar di beberapa konfigurasi server.pending_amountdanapproved_amountharus dihitung terpisah — jangan dijumlah dulu. Affiliate perlu tahu mana yang belum di-review admin dan mana yang sudah siap dicairkan.- Kalau affiliate status
pending(baru daftar, belum di-approve admin), tetap tampilkan dashboard — tapi dengan banner info bahwa akun sedang dalam review.
Hasil generate (sudah divalidasi):
<?php
// app/Http/Controllers/AffiliateController.php
namespace App\\Http\\Controllers;
use App\\Models\\Affiliate;
use App\\Models\\Commission;
use Illuminate\\Http\\Request;
use Inertia\\Inertia;
class AffiliateController extends Controller
{
public function __construct()
{
$this->middleware('auth');
}
public function showRegister()
{
// Kalau sudah punya akun affiliate, langsung ke dashboard
if (auth()->user()->affiliate) {
return redirect()->route('affiliate.dashboard');
}
return Inertia::render('Affiliate/Register');
}
public function register(Request $request)
{
$request->validate([
'bank_name' => 'required|string|max:100',
'bank_account_number' => 'required|string|max:30',
'bank_account_holder' => 'required|string|max:100',
]);
// Cegah daftar dua kali
if (auth()->user()->affiliate) {
return redirect()->route('affiliate.dashboard');
}
Affiliate::create([
'user_id' => auth()->id(),
'referral_code' => Affiliate::generateReferralCode(),
'commission_rate' => 10.00,
'bank_name' => $request->bank_name,
'bank_account_number' => $request->bank_account_number,
'bank_account_holder' => $request->bank_account_holder,
'status' => 'pending',
]);
return redirect()->route('affiliate.dashboard')
->with('success', 'Pendaftaran berhasil! Akun kamu sedang direview, biasanya 1-2 hari kerja.');
}
public function dashboard(Request $request)
{
$affiliate = auth()->user()->affiliate;
// Belum daftar affiliate
if (!$affiliate) {
return redirect()->route('affiliate.register');
}
// Hitung stats
$stats = [
'total_clicks' => $affiliate->clicks()->count(),
'pending_amount' => $affiliate->commissions()->pending()->sum('amount'),
'approved_amount' => $affiliate->commissions()->approved()->sum('amount'),
];
// 10 komisi terbaru dengan order number
$recentCommissions = $affiliate->commissions()
->with('order:id,order_number')
->latest()
->take(10)
->get()
->map(fn ($c) => [
'id' => $c->id,
'formatted_amount' => $c->formatted_amount,
'status' => $c->status,
'order_number' => $c->order->order_number,
'date' => $c->created_at->format('d M Y'),
]);
return Inertia::render('Affiliate/Dashboard', [
'affiliate' => [
'referral_code' => $affiliate->referral_code,
'status' => $affiliate->status,
'commission_rate' => $affiliate->commission_rate,
'formatted_total_earned'=> $affiliate->formatted_total_earned,
],
// Full URL yang bisa langsung di-copy dan share
'referral_link' => $request->getSchemeAndHttpHost() . '/?ref=' . $affiliate->referral_code,
'stats' => $stats,
'recent_commissions' => $recentCommissions,
]);
}
}
Tambahkan relasi affiliate ke User model:
// app/Models/User.php — tambahkan relasi
public function affiliate(): HasOne
{
return $this->hasOne(Affiliate::class);
}
Routes:
// routes/web.php
Route::middleware('auth')->prefix('affiliate')->name('affiliate.')->group(function () {
Route::get('/register', [AffiliateController::class, 'showRegister'])->name('register');
Route::post('/register', [AffiliateController::class, 'register']);
Route::get('/dashboard', [AffiliateController::class, 'dashboard'])->name('dashboard');
});
Halaman Register Affiliate
Prompt yang saya pakai:
Buatkan Pages/Affiliate/Register.vue untuk halaman pendaftaran affiliate.
Tampilkan:
- Heading: "Daftar Jadi Affiliate"
- Penjelasan singkat: apa yang didapat (referral link unik, komisi 10% per penjualan)
- Form dengan tiga field:
- Nama Bank (text input, contoh: BCA, BNI, Mandiri)
- Nomor Rekening (text input)
- Nama Pemilik Rekening (text input, harus sesuai nama di buku tabungan)
- Tombol "Daftar Sekarang"
- Loading state saat submit
- Tampilkan validation errors di bawah tiap field
Gunakan useForm dari @inertiajs/vue3 untuk handle form dan errors.
Pakai AppLayout dan Tailwind CSS.
Cara review hasilnya:
- Pastikan import
useFormdari@inertiajs/vue3, bukan dari package lain. form.post()harus dipakai, bukanrouter.post()—useFormpunya built-in error handling dan loading state yang lebih convenient untuk form.- Validation errors harus tampil per-field, bukan sebagai satu blok pesan di atas form.
Hasil generate (sudah divalidasi):
<!-- resources/js/Pages/Affiliate/Register.vue -->
<script setup>
import { useForm } from '@inertiajs/vue3'
import AppLayout from '@/Layouts/AppLayout.vue'
const form = useForm({
bank_name: '',
bank_account_number: '',
bank_account_holder: '',
})
const submit = () => {
form.post(route('affiliate.register'))
}
</script>
<template>
<AppLayout>
<head title="Daftar Affiliate" />
<div class="max-w-lg mx-auto">
<div class="mb-8">
<h1 class="text-2xl font-bold text-gray-900">Daftar Jadi Affiliate</h1>
<p class="text-gray-500 mt-2">
Dapatkan komisi <strong class="text-indigo-600">10%</strong> setiap kali
ada pembelian lewat referral link kamu. Komisi langsung ditransfer ke
rekening bank yang kamu daftarkan.
</p>
</div>
<div class="bg-white rounded-xl border border-gray-100 p-6">
<h2 class="font-semibold text-gray-700 mb-4">Info Rekening Bank</h2>
<p class="text-sm text-gray-400 mb-6">
Pastikan data rekening benar — komisi akan ditransfer ke rekening ini.
</p>
<!-- Bank Name -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">
Nama Bank
</label>
<input
v-model="form.bank_name"
type="text"
placeholder="Contoh: BCA, BNI, Mandiri, BRI"
class="w-full border border-gray-200 rounded-lg px-4 py-2.5 text-sm
focus:outline-none focus:ring-2 focus:ring-indigo-500"
:class="{ 'border-red-400': form.errors.bank_name }"
/>
<p v-if="form.errors.bank_name" class="text-red-500 text-xs mt-1">
{{ form.errors.bank_name }}
</p>
</div>
<!-- Account Number -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">
Nomor Rekening
</label>
<input
v-model="form.bank_account_number"
type="text"
placeholder="Contoh: 1234567890"
class="w-full border border-gray-200 rounded-lg px-4 py-2.5 text-sm
focus:outline-none focus:ring-2 focus:ring-indigo-500"
:class="{ 'border-red-400': form.errors.bank_account_number }"
/>
<p v-if="form.errors.bank_account_number" class="text-red-500 text-xs mt-1">
{{ form.errors.bank_account_number }}
</p>
</div>
<!-- Account Holder -->
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 mb-1">
Nama Pemilik Rekening
</label>
<input
v-model="form.bank_account_holder"
type="text"
placeholder="Harus sesuai nama di buku tabungan"
class="w-full border border-gray-200 rounded-lg px-4 py-2.5 text-sm
focus:outline-none focus:ring-2 focus:ring-indigo-500"
:class="{ 'border-red-400': form.errors.bank_account_holder }"
/>
<p v-if="form.errors.bank_account_holder" class="text-red-500 text-xs mt-1">
{{ form.errors.bank_account_holder }}
</p>
</div>
<button
@click="submit"
:disabled="form.processing"
class="w-full bg-indigo-600 text-white py-3 rounded-lg font-semibold
hover:bg-indigo-700 transition-colors disabled:opacity-50
disabled:cursor-not-allowed"
>
{{ form.processing ? 'Mendaftarkan...' : 'Daftar Sekarang' }}
</button>
</div>
</div>
</AppLayout>
</template>
Affiliate Dashboard Vue
Prompt yang saya pakai:
Buatkan Pages/Affiliate/Dashboard.vue untuk halaman dashboard affiliate.
Props yang diterima: affiliate, referral_link, stats, recent_commissions.
Tampilkan:
1. Status banner di paling atas:
- Kalau affiliate.status = 'pending': banner kuning
"Akun kamu sedang direview admin. Biasanya 1-2 hari kerja."
- Kalau affiliate.status = 'suspended': banner merah
"Akun kamu disuspend. Hubungi admin untuk informasi lebih lanjut."
- Kalau 'active': tidak ada banner
2. Referral link box:
- Tampilkan referral_link dalam input read-only
- Tombol "Salin Link" di sebelah kanan
- Saat diklik: copy ke clipboard, teks tombol berubah jadi "Tersalin ✓" selama 2 detik
- Gunakan navigator.clipboard.writeText() dengan fallback document.execCommand('copy')
untuk browser lama atau HTTP (bukan HTTPS)
3. Empat stat cards:
- Total Klik (stats.total_clicks)
- Komisi Pending — belum di-approve admin (stats.pending_amount, format Rupiah)
- Komisi Approved — siap dicairkan (stats.approved_amount, format Rupiah)
- Total Earned — sepanjang waktu (affiliate.formatted_total_earned)
4. Tabel recent commissions:
- Kolom: Tanggal, Order, Komisi, Status
- Status badge: pending=kuning, approved=hijau, paid=biru, rejected=merah
- Kalau recent_commissions kosong: empty state "Belum ada komisi. Mulai share referral link kamu!"
Pakai AppLayout dan Tailwind CSS.
Cara review hasilnya:
- Fallback untuk
clipboard.writeTextpenting — di HTTP (local development),navigator.clipboardtidak tersedia. Tanpa fallback, tombol copy akan diam-diam gagal tanpa feedback apapun ke user. - Format Rupiah untuk stats harus konsisten — kalau
formatted_total_earnedsudah diformat di controller,pending_amountdanapproved_amountjuga harus diformat. Jangan campur integer mentah dengan string yang sudah diformat di satu view yang sama. - Empty state di tabel harus encouraging, bukan sekadar teks "Data tidak ditemukan" yang kering.
Hasil generate (sudah divalidasi):
<!-- resources/js/Pages/Affiliate/Dashboard.vue -->
<script setup>
import { ref } from 'vue'
import AppLayout from '@/Layouts/AppLayout.vue'
const props = defineProps({
affiliate: Object,
referralLink: String,
stats: Object,
recentCommissions: Array,
})
const copyLabel = ref('Salin Link')
const copyLink = async () => {
try {
// Coba clipboard API modern dulu
await navigator.clipboard.writeText(props.referralLink)
} catch {
// Fallback untuk HTTP / browser lama
const el = document.createElement('textarea')
el.value = props.referralLink
document.body.appendChild(el)
el.select()
document.execCommand('copy')
document.body.removeChild(el)
}
copyLabel.value = 'Tersalin ✓'
setTimeout(() => { copyLabel.value = 'Salin Link' }, 2000)
}
const formatRupiah = (value) =>
'Rp ' + Number(value).toLocaleString('id-ID')
const statusConfig = {
pending: { label: 'Pending', class: 'bg-yellow-100 text-yellow-700' },
approved: { label: 'Approved', class: 'bg-green-100 text-green-700' },
paid: { label: 'Paid', class: 'bg-blue-100 text-blue-700' },
rejected: { label: 'Rejected', class: 'bg-red-100 text-red-700' },
}
</script>
<template>
<AppLayout>
<head title="Affiliate Dashboard" />
<!-- Status banner -->
<div v-if="affiliate.status === 'pending'"
class="bg-yellow-50 border border-yellow-200 text-yellow-800 px-4 py-3
rounded-lg mb-6 text-sm">
⏳ Akun kamu sedang direview admin. Biasanya selesai dalam 1-2 hari kerja.
Kamu sudah bisa lihat dashboard, tapi referral link belum aktif.
</div>
<div v-else-if="affiliate.status === 'suspended'"
class="bg-red-50 border border-red-200 text-red-800 px-4 py-3
rounded-lg mb-6 text-sm">
🚫 Akun kamu disuspend. Hubungi admin untuk informasi lebih lanjut.
</div>
<h1 class="text-2xl font-bold text-gray-900 mb-6">Dashboard Affiliate</h1>
<!-- Referral link box -->
<div class="bg-white rounded-xl border border-gray-100 p-5 mb-6">
<p class="text-sm font-medium text-gray-700 mb-2">Referral Link Kamu</p>
<div class="flex gap-2">
<input
:value="referralLink"
readonly
class="flex-1 bg-gray-50 border border-gray-200 rounded-lg px-4 py-2.5
text-sm text-gray-600 focus:outline-none"
/>
<button
@click="copyLink"
class="px-4 py-2.5 bg-indigo-600 text-white rounded-lg text-sm font-medium
hover:bg-indigo-700 transition-colors whitespace-nowrap"
:class="{ 'bg-green-600 hover:bg-green-700': copyLabel !== 'Salin Link' }"
>
{{ copyLabel }}
</button>
</div>
<p class="text-xs text-gray-400 mt-2">
Komisi {{ affiliate.commission_rate }}% dari setiap pembelian lewat link ini.
</p>
</div>
<!-- Stat cards -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<div class="bg-white rounded-xl border border-gray-100 p-4">
<p class="text-xs text-gray-500 uppercase tracking-wide">Total Klik</p>
<p class="text-2xl font-bold text-gray-900 mt-1">
{{ stats.total_clicks.toLocaleString('id-ID') }}
</p>
</div>
<div class="bg-white rounded-xl border border-gray-100 p-4">
<p class="text-xs text-gray-500 uppercase tracking-wide">Komisi Pending</p>
<p class="text-xl font-bold text-yellow-600 mt-1">
{{ formatRupiah(stats.pending_amount) }}
</p>
</div>
<div class="bg-white rounded-xl border border-gray-100 p-4">
<p class="text-xs text-gray-500 uppercase tracking-wide">Komisi Approved</p>
<p class="text-xl font-bold text-green-600 mt-1">
{{ formatRupiah(stats.approved_amount) }}
</p>
</div>
<div class="bg-white rounded-xl border border-gray-100 p-4">
<p class="text-xs text-gray-500 uppercase tracking-wide">Total Earned</p>
<p class="text-xl font-bold text-indigo-600 mt-1">
{{ affiliate.formatted_total_earned }}
</p>
</div>
</div>
<!-- Recent commissions -->
<div class="bg-white rounded-xl border border-gray-100 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-100">
<h2 class="font-semibold text-gray-900">Riwayat Komisi</h2>
</div>
<!-- Empty state -->
<div v-if="recentCommissions.length === 0"
class="text-center py-16 text-gray-400">
<p class="text-4xl mb-3">🔗</p>
<p class="font-medium text-gray-500">Belum ada komisi</p>
<p class="text-sm mt-1">Mulai share referral link kamu dan dapatkan komisi pertama!</p>
</div>
<!-- Table -->
<table v-else class="w-full text-sm">
<thead>
<tr class="text-xs text-gray-400 uppercase tracking-wide border-b border-gray-50">
<th class="text-left px-6 py-3">Tanggal</th>
<th class="text-left px-6 py-3">Order</th>
<th class="text-right px-6 py-3">Komisi</th>
<th class="text-center px-6 py-3">Status</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-50">
<tr v-for="commission in recentCommissions" :key="commission.id"
class="hover:bg-gray-50 transition-colors">
<td class="px-6 py-3 text-gray-400 text-xs">{{ commission.date }}</td>
<td class="px-6 py-3 font-mono text-xs text-gray-600">
{{ commission.order_number }}
</td>
<td class="px-6 py-3 text-right font-semibold text-gray-900">
{{ commission.formatted_amount }}
</td>
<td class="px-6 py-3 text-center">
<span class="px-2 py-1 rounded-full text-xs font-medium"
:class="statusConfig[commission.status]?.class">
{{ statusConfig[commission.status]?.label }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
</AppLayout>
</template>
Affiliate dashboard sudah lengkap — dari pendaftaran, lihat referral link, sampai pantau riwayat komisi. Di bagian selanjutnya kita pindah ke sisi admin: bagaimana admin approve komisi, trigger payout, dan Xendit otomatis transfer ke rekening bank affiliate.
Bagian 5: Admin Panel Affiliate — Approve Komisi dan Trigger Payout
Ini bagian yang paling berdampak langsung ke bisnis. Di sini admin punya kendali penuh: siapa yang boleh jadi affiliate aktif, komisi mana yang di-approve, dan kapan transfer uang ke rekening affiliate dijalankan.
Sebelum masuk ke kode, pahami dulu alur kerja admin untuk sistem affiliate:
[1] Affiliate baru daftar → status 'pending'
↓
[2] Admin review → klik Activate → status jadi 'active'
↓
[3] Affiliate mulai share link → ada pembelian → komisi tercatat (status: pending)
↓
[4] Admin review komisi → klik Approve → status komisi jadi 'approved'
↓
[5] Admin klik "Proses Payout" untuk affiliate tersebut
↓
[6] Sistem kumpulkan semua komisi 'approved' → hitung total
↓
[7] Kirim via Xendit Disbursement API → uang masuk ke rekening bank affiliate
↓
[8] Status komisi jadi 'paid', status payout jadi 'completed'
Ada dua entitas terpisah yang perlu dipahami: komisi dan payout. Komisi adalah unit terkecil — satu komisi = satu pembelian. Payout adalah batch transfer — satu payout bisa cover banyak komisi sekaligus. Ini lebih efisien karena biaya transfer Xendit per transaksi, bukan per rupiah.
Admin Affiliate Management
Prompt yang saya pakai:
Tambahkan fitur manajemen affiliate ke admin area Laravel 12 + Inertia yang sudah ada.
Admin area sudah pakai AdminLayout dan middleware ['auth', 'admin'].
Buatkan AdminAffiliateController di app/Http/Controllers/Admin/AdminAffiliateController.php
dengan method berikut:
1. index()
List semua affiliates dengan:
- nama user (dari relasi), referral_code, commission_rate,
formatted_total_earned, status, tanggal daftar (format: 'd M Y')
- count pending commissions untuk tiap affiliate
Pagination 20/halaman.
Return Inertia render 'Admin/Affiliates/Index'.
2. show(Affiliate $affiliate)
Detail satu affiliate:
- Data affiliate lengkap + user name + user email
- Semua commissions milik affiliate ini, paginate 15/halaman
Tiap commission: id, formatted_amount, status, order_number, tanggal
- Stats: total pending amount, total approved amount
- Apakah ada approved commissions (untuk tampil/sembunyikan tombol payout)
Return Inertia render 'Admin/Affiliates/Show'.
3. activate(Affiliate $affiliate)
POST — update status ke 'active'
Redirect back dengan success message.
4. suspend(Affiliate $affiliate)
POST — update status ke 'suspended'
Redirect back dengan success message.
5. approveCommission(Affiliate $affiliate, Commission $commission)
POST — update status commission dari 'pending' ke 'approved'
Validasi: commission harus milik affiliate ini, status harus 'pending'
Redirect back dengan success message.
6. approveAllCommissions(Affiliate $affiliate)
POST — bulk update semua pending commissions milik affiliate ini ke 'approved'
Gunakan DB transaction.
Return count komisi yang di-approve.
Redirect back dengan success message.
Tambahkan routes di dalam admin route group yang sudah ada.
Cara review hasilnya:
- Method
approveCommission()harus validasi bahwa commission memang milik affiliate yang dimaksud. Tanpa ini, request yang manipulatif bisa approve komisi affiliate lain. approveAllCommissions()harus pakaiDB::transaction()— kalau ada 50 komisi dan yang ke-30 gagal di-update, semua harus rollback, bukan sebagian approved sebagian tidak.- Count
pending commissionsdiindex()sebaiknya pakaiwithCountdengan constraint, bukan load semua dan count di PHP — lebih efisien di database.
Hasil generate (sudah divalidasi):
<?php
// app/Http/Controllers/Admin/AdminAffiliateController.php
namespace App\\Http\\Controllers\\Admin;
use App\\Http\\Controllers\\Controller;
use App\\Models\\Affiliate;
use App\\Models\\Commission;
use Illuminate\\Support\\Facades\\DB;
use Inertia\\Inertia;
class AdminAffiliateController extends Controller
{
public function index()
{
return Inertia::render('Admin/Affiliates/Index', [
'affiliates' => Affiliate::with('user:id,name,email')
->withCount(['commissions as pending_commissions_count' => function ($q) {
$q->where('status', 'pending');
}])
->latest()
->paginate(20)
->through(fn ($a) => [
'id' => $a->id,
'user_name' => $a->user->name,
'user_email' => $a->user->email,
'referral_code' => $a->referral_code,
'commission_rate' => $a->commission_rate,
'formatted_total_earned' => $a->formatted_total_earned,
'status' => $a->status,
'pending_commissions_count' => $a->pending_commissions_count,
'joined_at' => $a->created_at->format('d M Y'),
]),
]);
}
public function show(Affiliate $affiliate)
{
$affiliate->load('user:id,name,email');
$commissions = $affiliate->commissions()
->with('order:id,order_number')
->latest()
->paginate(15)
->through(fn ($c) => [
'id' => $c->id,
'formatted_amount' => $c->formatted_amount,
'status' => $c->status,
'order_number' => $c->order->order_number,
'date' => $c->created_at->format('d M Y'),
]);
$pendingAmount = $affiliate->commissions()->pending()->sum('amount');
$approvedAmount = $affiliate->commissions()->approved()->sum('amount');
$hasApproved = $affiliate->commissions()->approved()->exists();
return Inertia::render('Admin/Affiliates/Show', [
'affiliate' => [
'id' => $affiliate->id,
'user_name' => $affiliate->user->name,
'user_email' => $affiliate->user->email,
'referral_code' => $affiliate->referral_code,
'commission_rate' => $affiliate->commission_rate,
'bank_name' => $affiliate->bank_name,
'bank_account_number' => $affiliate->bank_account_number,
'bank_account_holder' => $affiliate->bank_account_holder,
'status' => $affiliate->status,
'formatted_total_earned' => $affiliate->formatted_total_earned,
],
'commissions' => $commissions,
'pending_amount' => 'Rp ' . number_format($pendingAmount, 0, ',', '.'),
'approved_amount'=> 'Rp ' . number_format($approvedAmount, 0, ',', '.'),
'has_approved' => $hasApproved,
]);
}
public function activate(Affiliate $affiliate)
{
$affiliate->update(['status' => 'active']);
return back()->with('success', "Affiliate {$affiliate->user->name} diaktifkan.");
}
public function suspend(Affiliate $affiliate)
{
$affiliate->update(['status' => 'suspended']);
return back()->with('success', "Affiliate {$affiliate->user->name} disuspend.");
}
public function approveCommission(Affiliate $affiliate, Commission $commission)
{
// Pastikan commission memang milik affiliate ini
abort_if($commission->affiliate_id !== $affiliate->id, 403);
abort_if($commission->status !== 'pending', 422, 'Komisi ini bukan status pending.');
$commission->update(['status' => 'approved']);
return back()->with('success', 'Komisi diapprove.');
}
public function approveAllCommissions(Affiliate $affiliate)
{
$count = 0;
DB::transaction(function () use ($affiliate, &$count) {
$count = $affiliate->commissions()
->pending()
->update(['status' => 'approved']);
});
return back()->with('success', "{$count} komisi berhasil diapprove.");
}
}
Tambahkan routes di dalam admin route group:
// routes/web.php — di dalam Route::middleware(['auth', 'admin'])->prefix('admin') group
Route::prefix('affiliates')->name('affiliates.')->group(function () {
Route::get('/', [AdminAffiliateController::class, 'index'])->name('index');
Route::get('/{affiliate}', [AdminAffiliateController::class, 'show'])->name('show');
Route::post('/{affiliate}/activate', [AdminAffiliateController::class, 'activate'])->name('activate');
Route::post('/{affiliate}/suspend', [AdminAffiliateController::class, 'suspend'])->name('suspend');
Route::post('/{affiliate}/commissions/{commission}/approve', [AdminAffiliateController::class, 'approveCommission'])->name('commissions.approve');
Route::post('/{affiliate}/commissions/approve-all', [AdminAffiliateController::class, 'approveAllCommissions'])->name('commissions.approve-all');
Route::post('/{affiliate}/payout', [AdminAffiliateController::class, 'processPayout'])->name('payout');
});
Xendit Disbursement Service
Sebelum buat method processPayout(), kita perlu service class untuk komunikasi dengan Xendit Disbursement API. Mirip seperti XenditService untuk invoice, tapi kali ini untuk transfer uang ke rekening bank.
Prompt yang saya pakai:
Buatkan XenditDisbursementService di app/Services/XenditDisbursementService.php
menggunakan Xendit PHP SDK v7.
Method: disburse(Payout $payout, Affiliate $affiliate): array
Yang dilakukan method ini:
1. Inisialisasi Xendit Configuration dengan secret key dari config
2. Buat DisbursementApi instance
3. Panggil create disbursement dengan data:
- external_id: 'PAYOUT-' . $payout->id (harus unique)
- amount: $payout->total_amount
- bank_code: strtoupper($affiliate->bank_name)
(Xendit pakai uppercase: BCA, BNI, MANDIRI, dll)
- account_holder_name: $affiliate->bank_account_holder
- account_number: $affiliate->bank_account_number
- description: 'Komisi Affiliate - ' . $affiliate->referral_code
4. Return response sebagai array
5. Wrap dalam try-catch, throw Exception dengan pesan yang jelas kalau gagal
Gunakan Xendit\\Disbursement\\DisbursementApi dan
Xendit\\Disbursement\\CreateDisbursementRequest dari SDK v7.
Cara review hasilnya:
external_iddi Xendit harus benar-benar unique per akun. Pakai'PAYOUT-' . $payout->id— karena$payout->idadalah UUID, collision practically impossible.bank_codeharus uppercase dan sesuai kode yang Xendit support. Kalau affiliate input "bca" (lowercase) atau "Bank BCA" (nama lengkap), ini akan ditolak Xendit. Tambahkanstrtoupper()tapi juga perlu validasi saat affiliate daftar — idealnya pakai dropdown bank, bukan free text. Catat ini sebagai improvement untuk nanti.- Response dari SDK v7 mungkin return object, bukan array langsung. Tambahkan
(array)cast untuk konsistensi.
Hasil generate (sudah divalidasi):
<?php
// app/Services/XenditDisbursementService.php
namespace App\\Services;
use App\\Models\\Affiliate;
use App\\Models\\Payout;
use Exception;
use Xendit\\Configuration;
use Xendit\\Disbursement\\DisbursementApi;
use Xendit\\Disbursement\\CreateDisbursementRequest;
class XenditDisbursementService
{
private DisbursementApi $disbursementApi;
public function __construct()
{
Configuration::setXenditKey(config('services.xendit.secret_key'));
$this->disbursementApi = new DisbursementApi();
}
public function disburse(Payout $payout, Affiliate $affiliate): array
{
try {
$request = new CreateDisbursementRequest([
'external_id' => 'PAYOUT-' . $payout->id,
'amount' => $payout->total_amount,
'bank_code' => strtoupper($affiliate->bank_name),
'account_holder_name' => $affiliate->bank_account_holder,
'account_number' => $affiliate->bank_account_number,
'description' => 'Komisi Affiliate - ' . $affiliate->referral_code,
]);
$response = $this->disbursementApi->createDisbursement($request);
return (array) $response;
} catch (Exception $e) {
\\Log::error('Xendit disbursement failed', [
'payout_id' => $payout->id,
'affiliate_id' => $affiliate->id,
'error' => $e->getMessage(),
]);
throw new Exception('Gagal memproses payout: ' . $e->getMessage());
}
}
}
Process Payout Method
Ini method yang paling krusial — melibatkan uang sungguhan, jadi harus hati-hati dengan transaction dan error handling.
Prompt yang saya pakai:
Tambahkan method processPayout(Affiliate $affiliate) ke AdminAffiliateController
yang sudah ada.
Alur kerja:
1. Ambil semua commissions milik affiliate dengan status 'approved'
2. Kalau tidak ada, redirect back dengan error "Tidak ada komisi yang siap dicairkan"
3. Hitung total amount dari semua commissions tersebut
4. Validasi total minimal Rp 10.000 (minimum disbursement Xendit)
5. Validasi affiliate punya bank info lengkap (bank_name, bank_account_number, bank_account_holder)
6. Buka DB transaction:
a. Buat record Payout dengan status 'processing', total_amount
b. Panggil XenditDisbursementService->disburse($payout, $affiliate)
c. Update payout: xendit_disbursement_id dari response, xendit_reference_id = external_id
d. Update semua commissions tadi: status = 'paid', paid_at = now()
e. Commit transaction
7. Kalau Xendit throw exception:
- Rollback transaction (otomatis karena dalam DB::transaction)
- Redirect back dengan pesan error dari exception
8. Kalau sukses: redirect back dengan success message yang menyebutkan total amount
Inject XenditDisbursementService via method parameter.
Cara review hasilnya:
- Urutan operasi di dalam transaction penting: buat
Payoutdulu, baru panggil Xendit. Kalau Xendit dipanggil sebelum Payout tersimpan dan Xendit sukses tapi Payout gagal tersimpan, kita tidak punya record payout tapi uang sudah dikirim. Ini menyebabkan inkonsistensi data yang susah diperbaiki. DB::transaction()akan otomatis rollback kalau ada exception yang di-throw di dalamnya — termasuk exception dariXenditDisbursementService. Jadi tidak perlu manual rollback.- Update
commissionskepaidharus pakai querywhereInatauupdate()bulk, bukan loop satu per satu — lebih efisien dan lebih aman dalam transaction.
Hasil generate (sudah divalidasi):
// Tambahkan method ini ke AdminAffiliateController
public function processPayout(
Affiliate $affiliate,
XenditDisbursementService $xenditDisburse
) {
// Ambil semua komisi yang sudah approved
$approvedCommissions = $affiliate->commissions()->approved()->get();
if ($approvedCommissions->isEmpty()) {
return back()->with('error', 'Tidak ada komisi yang siap dicairkan.');
}
$totalAmount = $approvedCommissions->sum('amount');
// Minimum disbursement Xendit
if ($totalAmount < 10000) {
return back()->with('error', 'Minimum payout adalah Rp 10.000.');
}
// Pastikan info bank lengkap
if (!$affiliate->bank_name || !$affiliate->bank_account_number || !$affiliate->bank_account_holder) {
return back()->with('error', 'Info bank affiliate tidak lengkap. Minta affiliate update data.');
}
try {
DB::transaction(function () use ($affiliate, $approvedCommissions, $totalAmount, $xenditDisburse) {
// [1] Buat record payout dulu sebelum panggil Xendit
$payout = \\App\\Models\\Payout::create([
'affiliate_id' => $affiliate->id,
'total_amount' => $totalAmount,
'status' => 'processing',
]);
// [2] Kirim disbursement ke Xendit
// Kalau ini throw exception, transaction otomatis rollback
$response = $xenditDisburse->disburse($payout, $affiliate);
// [3] Update payout dengan response dari Xendit
$payout->update([
'xendit_disbursement_id' => $response['id'],
'xendit_reference_id' => 'PAYOUT-' . $payout->id,
]);
// [4] Bulk update semua commissions ke 'paid'
$affiliate->commissions()
->approved()
->update([
'status' => 'paid',
'paid_at' => now(),
]);
});
$formatted = 'Rp ' . number_format($totalAmount, 0, ',', '.');
return back()->with('success', "Payout {$formatted} sedang diproses. Biasanya masuk dalam 1x24 jam.");
} catch (\\Exception $e) {
return back()->with('error', $e->getMessage());
}
}
Admin Affiliate Detail Vue
Prompt yang saya pakai:
Buatkan Pages/Admin/Affiliates/Show.vue untuk halaman detail affiliate di admin.
Props: affiliate, commissions (paginated), pending_amount, approved_amount, has_approved.
Tampilkan:
1. Header: nama affiliate, email, status badge, tombol Activate/Suspend sesuai status saat ini
2. Info rekening bank dalam card: bank_name, account_number, account_holder
3. Dua stat: Komisi Pending (pending_amount) dan Komisi Approved (approved_amount)
4. Tombol "Proses Payout" — hanya tampil kalau has_approved = true
Saat diklik: minta konfirmasi dulu "Yakin proses payout sebesar [approved_amount]?"
Kalau dikonfirmasi, router.post ke route admin.affiliates.payout
5. Tombol "Approve Semua Pending" — hanya tampil kalau ada komisi pending
6. Tabel commissions dengan kolom: Tanggal, Order, Komisi, Status, Aksi
- Kalau status pending: tampilkan tombol "Approve" kecil
- Kalau bukan pending: kolom aksi kosong
Pakai AdminLayout dan Tailwind CSS.
Konfirmasi payout pakai window.confirm() — cukup, tidak perlu modal custom.
Cara review hasilnya:
window.confirm()returntrue/false— pastikan ada pengecekanif (!confirmed) returnsebelumrouter.post().- Tombol Activate/Suspend harus conditional: kalau status
activetampilkan tombol Suspend, kalaupendingataususpendedtampilkan tombol Activate. Jangan tampilkan keduanya sekaligus. - Tabel commissions harus handle empty state — kalau affiliate baru diaktifkan dan belum ada komisi, jangan table kosong yang membingungkan.
Hasil generate (sudah divalidasi):
<!-- resources/js/Pages/Admin/Affiliates/Show.vue -->
<script setup>
import { router } from '@inertiajs/vue3'
import AdminLayout from '@/Layouts/AdminLayout.vue'
const props = defineProps({
affiliate: Object,
commissions: Object,
pendingAmount: String,
approvedAmount: String,
hasApproved: Boolean,
})
const statusConfig = {
pending: { label: 'Pending', class: 'bg-yellow-100 text-yellow-700' },
active: { label: 'Active', class: 'bg-green-100 text-green-700' },
suspended:{ label: 'Suspended', class: 'bg-red-100 text-red-700' },
}
const commissionStatus = {
pending: { label: 'Pending', class: 'bg-yellow-100 text-yellow-700' },
approved: { label: 'Approved', class: 'bg-green-100 text-green-700' },
paid: { label: 'Paid', class: 'bg-blue-100 text-blue-700' },
rejected: { label: 'Rejected', class: 'bg-red-100 text-red-700' },
}
const toggleStatus = () => {
const action = props.affiliate.status === 'active' ? 'suspend' : 'activate'
const routeName = action === 'activate'
? route('admin.affiliates.activate', props.affiliate.id)
: route('admin.affiliates.suspend', props.affiliate.id)
router.post(routeName)
}
const approveAll = () => {
router.post(route('admin.affiliates.commissions.approve-all', props.affiliate.id))
}
const approveOne = (commissionId) => {
router.post(route('admin.affiliates.commissions.approve', {
affiliate: props.affiliate.id,
commission: commissionId,
}))
}
const processPayout = () => {
const confirmed = window.confirm(
`Yakin proses payout sebesar ${props.approvedAmount} ke rekening ${props.affiliate.bank_account_holder} (${props.affiliate.bank_name} - ${props.affiliate.bank_account_number})?`
)
if (!confirmed) return
router.post(route('admin.affiliates.payout', props.affiliate.id))
}
</script>
<template>
<AdminLayout>
<template #header>
<h1 class="text-lg font-semibold text-gray-900">Detail Affiliate</h1>
</template>
<!-- Header affiliate -->
<div class="bg-white rounded-xl border border-gray-100 p-6 mb-6 flex items-center justify-between">
<div>
<div class="flex items-center gap-3 mb-1">
<h2 class="text-xl font-bold text-gray-900">{{ affiliate.user_name }}</h2>
<span class="px-2 py-1 rounded-full text-xs font-medium"
:class="statusConfig[affiliate.status]?.class">
{{ statusConfig[affiliate.status]?.label }}
</span>
</div>
<p class="text-gray-500 text-sm">{{ affiliate.user_email }}</p>
<p class="text-gray-400 text-sm mt-1">
Kode: <span class="font-mono font-bold text-gray-700">{{ affiliate.referral_code }}</span>
· Komisi {{ affiliate.commission_rate }}%
</p>
</div>
<button
@click="toggleStatus"
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors"
:class="affiliate.status === 'active'
? 'bg-red-50 text-red-600 hover:bg-red-100'
: 'bg-green-50 text-green-600 hover:bg-green-100'"
>
{{ affiliate.status === 'active' ? 'Suspend' : 'Activate' }}
</button>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
<!-- Info rekening bank -->
<div class="bg-white rounded-xl border border-gray-100 p-5">
<h3 class="font-semibold text-gray-700 mb-3 text-sm uppercase tracking-wide">
Rekening Bank
</h3>
<p class="font-bold text-gray-900">{{ affiliate.bank_name }}</p>
<p class="text-gray-600 font-mono mt-1">{{ affiliate.bank_account_number }}</p>
<p class="text-gray-500 text-sm mt-1">a.n. {{ affiliate.bank_account_holder }}</p>
</div>
<!-- Stat: Pending -->
<div class="bg-white rounded-xl border border-gray-100 p-5">
<p class="text-xs text-gray-500 uppercase tracking-wide">Komisi Pending</p>
<p class="text-2xl font-bold text-yellow-600 mt-1">{{ pendingAmount }}</p>
<button
v-if="commissions.data?.some(c => c.status === 'pending')"
@click="approveAll"
class="mt-3 text-xs text-indigo-600 hover:underline"
>
Approve semua pending →
</button>
</div>
<!-- Stat: Approved + tombol payout -->
<div class="bg-white rounded-xl border border-gray-100 p-5">
<p class="text-xs text-gray-500 uppercase tracking-wide">Komisi Approved</p>
<p class="text-2xl font-bold text-green-600 mt-1">{{ approvedAmount }}</p>
<button
v-if="hasApproved"
@click="processPayout"
class="mt-3 w-full bg-indigo-600 text-white py-2 rounded-lg text-sm
font-semibold hover:bg-indigo-700 transition-colors"
>
Proses Payout →
</button>
</div>
</div>
<!-- Tabel commissions -->
<div class="bg-white rounded-xl border border-gray-100 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-100">
<h3 class="font-semibold text-gray-900">Riwayat Komisi</h3>
</div>
<div v-if="commissions.data?.length === 0" class="text-center py-12 text-gray-400">
Belum ada komisi untuk affiliate ini.
</div>
<table v-else class="w-full text-sm">
<thead>
<tr class="text-xs text-gray-400 uppercase tracking-wide border-b border-gray-50">
<th class="text-left px-6 py-3">Tanggal</th>
<th class="text-left px-6 py-3">Order</th>
<th class="text-right px-6 py-3">Komisi</th>
<th class="text-center px-6 py-3">Status</th>
<th class="text-center px-6 py-3">Aksi</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-50">
<tr v-for="commission in commissions.data" :key="commission.id"
class="hover:bg-gray-50 transition-colors">
<td class="px-6 py-3 text-gray-400 text-xs">{{ commission.date }}</td>
<td class="px-6 py-3 font-mono text-xs text-gray-600">
{{ commission.order_number }}
</td>
<td class="px-6 py-3 text-right font-semibold">
{{ commission.formatted_amount }}
</td>
<td class="px-6 py-3 text-center">
<span class="px-2 py-1 rounded-full text-xs font-medium"
:class="commissionStatus[commission.status]?.class">
{{ commissionStatus[commission.status]?.label }}
</span>
</td>
<td class="px-6 py-3 text-center">
<button
v-if="commission.status === 'pending'"
@click="approveOne(commission.id)"
class="text-xs text-indigo-600 hover:underline"
>
Approve
</button>
</td>
</tr>
</tbody>
</table>
</div>
</AdminLayout>
</template>
Bagian admin selesai. Sekarang admin punya kontrol penuh: review affiliate baru, approve komisi satu per satu atau bulk, dan trigger payout ke rekening bank affiliate dengan satu klik. Di bagian selanjutnya kita handle satu skenario penting yang sering terlupa: apa yang terjadi kalau payout gagal di tengah jalan — dan bagaimana sistem kita pulih secara otomatis.
Bagian 6: Xendit Disbursement Webhook
Saat kita panggil Xendit Disbursement API di Bagian 5, Xendit tidak langsung transfer uangnya. Prosesnya ada di background — bisa selesai dalam beberapa menit, bisa sampai beberapa jam tergantung bank tujuan dan jam operasional.
Nah, bagaimana kita tahu kapan transfer selesai? Atau kapan transfer gagal?
Jawabannya: webhook. Sama seperti webhook payment yang kita buat di artikel sebelumnya, Xendit akan kirim notifikasi ke server kita saat status disbursement berubah — baik jadi COMPLETED maupun FAILED.
Kenapa ini penting? Bayangkan skenario ini: payout sudah diproses, status di database kita processing. Tiba-tiba Xendit kirim callback bahwa transfer gagal — nomor rekening tidak valid. Tanpa webhook handler, kita tidak akan pernah tahu. Status tetap processing selamanya, dan affiliate tidak pernah dapat uangnya — tapi kita juga tidak tahu ada masalah.
Dengan webhook yang benar, sistem kita akan otomatis: update status payout jadi failed, dan kembalikan status semua komisi yang terkait ke approved supaya admin bisa retry payout.
Disbursement Webhook Handler
Prompt yang saya pakai:
Tambahkan method handleDisbursement(Request $request) ke XenditWebhookController
yang sudah ada.
Cara kerja:
1. Verifikasi x-callback-token dari header — sama seperti invoice webhook
Kalau tidak cocok, return 401
2. Payload dari Xendit berisi: id (xendit disbursement id), status (COMPLETED / FAILED),
dan beberapa field lain
3. Cari Payout berdasarkan xendit_disbursement_id = $request->id
Kalau tidak ditemukan, return JSON {"status": "ok"} — mungkin dari sistem lain
4. Kalau status = 'COMPLETED':
- Update payout->status = 'completed'
- Update payout->processed_at = now()
- Log info: payout berhasil
5. Kalau status = 'FAILED':
- Update payout->status = 'failed'
- Rollback semua commissions yang terkait:
cari commissions milik affiliate yang sama dengan paid_at
dalam range waktu payout ini dibuat sampai sekarang, dan status = 'paid'
Kembalikan ke status = 'approved', null-kan paid_at
- Log error dengan detail payout dan alasan gagal (kalau ada di payload)
6. Idempotent — kalau webhook COMPLETED masuk dua kali, tidak boleh double process
Cek: kalau payout->status sudah 'completed', langsung return ok
7. Semua perubahan database dalam DB transaction
8. Return JSON {"status": "ok"} dengan HTTP 200
Tambahkan juga route POST /webhook/xendit/disbursement (exclude CSRF)
dan daftarkan di Xendit Dashboard.
Cara review hasilnya:
- Cara rollback commissions perlu diperhatikan betul. Jangan query commissions berdasarkan range waktu — terlalu brittle dan bisa salah kalau ada payout lain di waktu yang berdekatan. Cara yang lebih tepat: simpan relasi antara
payoutsdancommissionssecara eksplisit. Ini berarti kita perlu sedikit revisi di methodprocessPayoutyang sudah kita buat sebelumnya — kita perlu catatpayout_iddi tabel commissions. - Idempotency check harus di awal handler, sebelum masuk ke logic apapun.
- Log
FAILEDharus menyertakan cukup detail untuk debugging: payout ID, affiliate ID, dan pesan error dari Xendit kalau ada.
Sama seperti di Bagian 3, kita menemukan bahwa pendekatan pertama punya kelemahan. Mari kita perbaiki dulu sebelum lanjut.
Revisi: Tambahkan payout_id ke Commissions
Untuk bisa rollback commissions dengan akurat, kita perlu tahu persis komisi mana yang di-cover oleh payout tertentu. Cara paling clean: tambahkan kolom payout_id ke tabel commissions.
php artisan make:migration add_payout_id_to_commissions_table
// migration
public function up(): void
{
Schema::table('commissions', function (Blueprint $table) {
$table->foreignUuid('payout_id')
->nullable()
->constrained()
->nullOnDelete()
->after('order_id');
});
}
Update processPayout() di AdminAffiliateController — tambahkan satu baris setelah bulk update commissions:
// Di dalam DB::transaction() di processPayout(), setelah update commissions ke 'paid'
$affiliate->commissions()
->approved()
->update([
'status' => 'paid',
'paid_at' => now(),
'payout_id' => $payout->id, // ← tambahkan ini
]);
Sekarang setiap komisi tahu persis payout mana yang mentransfer uangnya. Rollback jadi sangat simpel.
Hasil generate webhook handler (sudah divalidasi):
<?php
// Tambahkan method ini ke XenditWebhookController yang sudah ada
public function handleDisbursement(Request $request)
{
// Verifikasi token — sama seperti invoice webhook
$token = $request->header('x-callback-token');
if ($token !== config('services.xendit.webhook_token')) {
return response()->json(['error' => 'Unauthorized'], 401);
}
$payout = \\App\\Models\\Payout::where('xendit_disbursement_id', $request->id)->first();
// Tidak ditemukan — mungkin dari sistem lain, abaikan
if (!$payout) {
return response()->json(['status' => 'ok']);
}
// Idempotency check — kalau sudah completed, tidak perlu proses lagi
if ($payout->status === 'completed') {
return response()->json(['status' => 'ok']);
}
try {
DB::transaction(function () use ($payout, $request) {
if ($request->status === 'COMPLETED') {
$payout->update([
'status' => 'completed',
'processed_at' => now(),
]);
\\Log::info('Payout completed', [
'payout_id' => $payout->id,
'affiliate_id' => $payout->affiliate_id,
'amount' => $payout->total_amount,
]);
} elseif ($request->status === 'FAILED') {
$payout->update(['status' => 'failed']);
// Rollback commissions yang terkait payout ini ke status 'approved'
// Pakai payout_id yang sudah kita simpan saat processPayout
\\App\\Models\\Commission::where('payout_id', $payout->id)
->update([
'status' => 'approved', // kembali ke approved, siap retry
'paid_at' => null,
'payout_id' => null,
]);
\\Log::error('Payout failed', [
'payout_id' => $payout->id,
'affiliate_id' => $payout->affiliate_id,
'amount' => $payout->total_amount,
'failure_reason' => $request->failure_reason ?? 'unknown',
]);
}
});
} catch (\\Exception $e) {
\\Log::error('Error processing disbursement webhook', [
'payout_id' => $payout->id,
'error' => $e->getMessage(),
]);
}
return response()->json(['status' => 'ok']);
}
Tambahkan route:
// routes/web.php — tambahkan di samping webhook invoice yang sudah ada
Route::post('/webhook/xendit/disbursement', [XenditWebhookController::class, 'handleDisbursement'])
->withoutMiddleware([\\App\\Http\\Middleware\\VerifyCsrfToken::class])
->name('webhook.xendit.disbursement');
Daftarkan URL ini di Xendit Dashboard → Settings → Callbacks → Disbursement:
<https://yourdomain.com/webhook/xendit/disbursement>
Ingat: dua webhook URL yang berbeda di Xendit Dashboard — satu untuk Invoice, satu untuk Disbursement. Keduanya pakai token yang sama dari XENDIT_WEBHOOK_TOKEN.
Dengan webhook ini, sistem kita sekarang bisa pulih sendiri kalau payout gagal. Admin cukup buka halaman detail affiliate, lihat status payout failed, dan klik "Proses Payout" lagi — karena komisi sudah dikembalikan ke status approved secara otomatis.
Di bagian selanjutnya kita tutup dengan hal-hal yang sering lupa ditest sebelum sistem affiliate ini benar-benar dipakai — edge cases yang kecil tapi bisa menyebabkan masalah finansial kalau tidak ditangani.
Bagian 7: Testing dan Edge Cases
Ini bagian yang paling sering di-skip developer — terutama saat semangat building lagi tinggi-tingginya. Padahal untuk sistem yang melibatkan uang, edge cases yang tidak ditangani bisa menyebabkan masalah nyata: komisi salah orang, affiliate bisa abuse sistem, atau data keuangan inkonsisten.
Kita tidak perlu tulis automated tests yang lengkap sekarang. Yang penting adalah tahu apa yang perlu ditest dan cara cepat memverifikasinya sebelum sistem ini benar-benar dipakai.
Checklist Edge Cases
Prompt yang saya pakai:
Saya baru selesai bangun sistem affiliate Laravel 12 dengan fitur:
- Cookie tracking via middleware (30 hari)
- Komisi otomatis saat order paid via webhook
- Payout via Xendit Disbursement
- Rollback komisi kalau payout gagal
Berikan daftar edge cases yang wajib ditest sebelum go live,
beserta cara test masing-masing menggunakan php artisan tinker
atau langsung dari browser. Fokus ke edge cases yang melibatkan
data finansial atau bisa di-abuse user.
Hasil generate (sudah divalidasi dan ditambahkan):
EDGE CASES WAJIB DITEST:
[ ] 1. Self-referral — affiliate beli pakai link sendiri
[ ] 2. Cookie overwrite — visitor klik dua referral link berbeda
[ ] 3. Affiliate pending share link — link tidak boleh tracking
[ ] 4. Order dari user yang tidak login saat klik referral
[ ] 5. Payout dengan total di bawah Rp 10.000
[ ] 6. Nomor rekening tidak valid di Xendit sandbox
[ ] 7. Affiliate update bank info saat ada payout processing
[ ] 8. Duplikat webhook disbursement (idempotency)
Mari kita bahas satu per satu.
1. Self-Referral Prevention
Skenario: affiliate share link mereka sendiri ke diri sendiri, lalu beli produk. Tanpa pencegahan, mereka dapat diskon efektif sebesar nilai komisi.
Ini yang paling sering lupa diimplementasi. Kita perlu tambahkan satu pengecekan di XenditWebhookController saat catat komisi:
// Di dalam handleDisbursement, bagian catat komisi affiliate
// Tambahkan pengecekan sebelum Commission::create()
if ($order->affiliate_id) {
$affiliate = $order->affiliate;
// Cegah self-referral — affiliate tidak boleh dapat komisi dari pembelian sendiri
if ($affiliate->user_id === $order->user_id) {
\\Log::info('Self-referral detected, skipping commission', [
'order_id' => $order->id,
'affiliate_id' => $affiliate->id,
'user_id' => $order->user_id,
]);
// Tidak catat komisi, lanjutkan proses order seperti biasa
} else {
// Catat komisi seperti biasa
$commissionAmount = (int) floor($order->total * ($affiliate->commission_rate / 100));
Commission::create([...]);
$affiliate->increment('total_earned', $commissionAmount);
}
}
Cara test dengan Tinker:
php artisan tinker
# Simulasi: set affiliate_id di order milik user yang sama dengan affiliate
>>> $affiliate = App\\Models\\Affiliate::active()->first();
>>> $order = App\\Models\\Order::factory()->create([
... 'user_id' => $affiliate->user_id,
... 'affiliate_id' => $affiliate->id,
... 'status' => 'pending'
... ]);
# Simulasi webhook PAID
>>> $order->update(['status' => 'paid', 'paid_at' => now()]);
# Cek: tidak boleh ada commission baru
>>> App\\Models\\Commission::where('order_id', $order->id)->count();
# Expected: 0
2. Cookie Overwrite — Last Click Wins
Skenario: visitor klik referral link Dito, lalu besoknya klik referral link Budi. Komisi siapa yang tercatat saat visitor beli?
Kebijakan yang paling umum di industri: last click wins — komisi ke affiliate terakhir yang linknya diklik. Ini sudah yang kita implementasikan secara default karena middleware kita langsung overwrite cookie dengan affiliate baru setiap kali ada ?ref di URL.
Tapi ada yang perlu dicek: pastikan middleware tidak skip overwrite kalau cookie sudah ada. Cek kode middleware dari Bagian 3 — tidak ada kondisi if (!$request->hasCookie('ref_affiliate_id')), jadi overwrite terjadi setiap kali ada ?ref baru. Ini sudah benar.
Cara test di browser:
1. Buka: localhost/?ref=KODE_DITO
2. Cek cookie di DevTools → Application → Cookies
Harus ada: ref_affiliate_id = [id Dito]
3. Buka tab baru: localhost/?ref=KODE_BUDI
4. Cek cookie lagi
Harus berubah: ref_affiliate_id = [id Budi]
5. Lakukan pembelian
6. Cek tabel commissions — harus milik Budi, bukan Dito
3. Affiliate Pending — Link Tidak Boleh Tracking
Middleware kita sudah handle ini dengan Affiliate::active()->where(...) — hanya affiliate dengan status active yang cookienya tersimpan. Tapi perlu ditest eksplisit.
Cara test dengan Tinker:
php artisan tinker
>>> $affiliate = App\\Models\\Affiliate::where('status', 'pending')->first();
>>> $affiliate->referral_code; // catat kode ini
Buka di browser: localhost/?ref=KODE_PENDING
Cek: tidak ada cookie ref_affiliate_id yang tersimpan. Cek juga tabel referral_clicks — tidak boleh ada record baru.
4. Order dari Guest (Tidak Login)
Di setup kita, checkout membutuhkan login (middleware('auth') di route checkout). Jadi scenario ini secara teknis tidak bisa terjadi — tapi worth verifikasi.
Kalau kamu berencana tambahkan guest checkout nanti, ingat: cookie masih terbaca meski user tidak login. Yang perlu ditambahkan adalah logic untuk associate affiliate_id ke order guest berdasarkan email, bukan user_id.
5. Minimum Payout
Kita sudah ada validasi if ($totalAmount < 10000) di processPayout(). Test ini sederhana:
Cara test: Buat affiliate dengan satu komisi kecil (misalnya dari order Rp 50.000 dengan komisi 10% = Rp 5.000). Approve komisi. Coba klik "Proses Payout" di admin.
Expected: redirect back dengan error "Minimum payout adalah Rp 10.000."
6. Nomor Rekening Tidak Valid di Sandbox
Ini perlu ditest di Xendit sandbox sebelum go live. Xendit punya beberapa nomor rekening test yang akan trigger response tertentu.
Untuk test rekening tidak valid: gunakan nomor 1111111111 dengan bank BCA di sandbox. Xendit akan return status FAILED saat disbursement diproses.
Flow yang harus terjadi:
- Admin trigger payout → status
processing - Xendit kirim webhook
FAILED - Payout jadi
failed, commissions kembali keapproved - Admin bisa retry payout dengan bank info yang sudah diupdate
Cara test webhook FAILED di local dengan ngrok:
# Terminal 1: jalankan Laravel
php artisan serve
# Terminal 2: expose ke internet via ngrok
ngrok http 8000
# Daftarkan URL ngrok ke Xendit Dashboard → Disbursement webhook
# <https://abc123.ngrok.io/webhook/xendit/disbursement>
7. Update Bank Info Saat Ada Payout Processing
Skenario: admin trigger payout, status processing. Sebelum webhook masuk, affiliate update nomor rekeningnya. Xendit sudah pakai nomor lama yang ada di external_id disbursement — update di sisi kita tidak akan mengubah transfer yang sudah dikirim.
Ini bukan bug — ini batasan bisnis yang perlu dikomunikasikan. Cara mudah handle ini: tambahkan validasi di halaman edit bank info affiliate — kalau ada payout dengan status processing, tidak boleh update bank info.
// Tambahkan di controller update bank info affiliate
$hasPendingPayout = auth()->user()->affiliate
->payouts()
->processing()
->exists();
if ($hasPendingPayout) {
return back()->with('error', 'Tidak bisa update info bank saat ada payout yang sedang diproses.');
}
8. Duplikat Webhook — Idempotency
Xendit bisa kirim webhook yang sama lebih dari sekali (retry mechanism mereka). Kita sudah handle ini di handleDisbursement() dengan idempotency check:
if ($payout->status === 'completed') {
return response()->json(['status' => 'ok']); // diam-diam abaikan
}
Cara test: Kirim request POST manual ke endpoint webhook dua kali dengan payload yang sama menggunakan curl atau Insomnia.
curl -X POST <http://localhost:8000/webhook/xendit/disbursement> \\
-H "Content-Type: application/json" \\
-H "x-callback-token: your_webhook_token" \\
-d '{"id": "disb_xxxx", "status": "COMPLETED"}'
# Kirim lagi dengan payload yang sama
curl -X POST <http://localhost:8000/webhook/xendit/disbursement> \\
-H "Content-Type: application/json" \\
-H "x-callback-token: your_webhook_token" \\
-d '{"id": "disb_xxxx", "status": "COMPLETED"}'
Cek database: payout tetap satu record dengan status completed. Tidak ada duplikat.
Final Checklist Sebelum Go Live
AFFILIATE SYSTEM — GO LIVE CHECKLIST:
[ ] FUNGSIONAL
[ ] Referral link tracking bekerja (cookie tersimpan)
[ ] Komisi tercatat setelah order paid
[ ] Self-referral tidak menghasilkan komisi
[ ] Affiliate pending tidak bisa tracking
[ ] Payout berhasil dikirim ke rekening sandbox
[ ] Rollback komisi bekerja saat payout failed
[ ] XENDIT PRODUCTION
[ ] Switch ke live mode di Xendit Dashboard
[ ] Daftarkan webhook disbursement URL (production)
[ ] Test disbursement kecil (Rp 10.000) ke rekening sendiri
[ ] Verifikasi saldo Xendit cukup untuk payout pertama
[ ] ADMIN
[ ] Setidaknya satu admin account sudah di-set is_admin = true
[ ] Admin tahu alur: activate affiliate → approve komisi → proses payout
[ ] Ada SOP untuk verifikasi data bank affiliate sebelum activate
[ ] KOMUNIKASI KE AFFILIATE
[ ] Informasikan commission rate dan minimum payout
[ ] Jelaskan kapan payout diproses (misalnya: setiap Jumat)
[ ] Informasikan bahwa butuh waktu 1x24 jam setelah payout diproses
Di bagian terakhir kita wrap up semua yang sudah dipelajari dan saya kasih rekomendasi kelas di BuildWithAngga untuk kamu yang mau lanjut lebih dalam.
Bagian 8: Apa Selanjutnya?
Kita sudah bangun sistem affiliate yang cukup lengkap — dari tracking klik, komisi otomatis, sampai payout langsung ke rekening bank. Dan yang lebih penting, kita bangunnya dengan pendekatan yang bisa kamu ulangi untuk fitur apapun berikutnya.
Yang Sudah Kamu Kuasai
Sistem affiliate ini bukan sekadar kumpulan kode. Ada beberapa pola pikir yang seharusnya sudah lebih jelas setelah artikel ini:
Iterasi adalah bagian dari prosesnya. Di Bagian 3, kita menemukan bahwa cookie tidak bisa dibaca di webhook — dan kita perbaiki. Di Bagian 6, kita menemukan bahwa rollback berdasarkan range waktu itu brittle — dan kita perbaiki dengan tambah kolom payout_id. Dua kali kita revisi approach di tengah artikel, dan itu normal. Vibe coding yang baik bukan berarti prompt pertama selalu sempurna — tapi kemampuan mengenali kelemahan dan iterasi dengan cepat.
Sistem finansial butuh extra teliti. DB transaction bukan pilihan, tapi keharusan. Idempotency bukan nice-to-have, tapi wajib. Self-referral prevention sering lupa tapi dampaknya nyata. Ini pelajaran yang berlaku jauh melampaui sistem affiliate.
Dari sisi teknis, kamu sekarang sudah familiar dengan:
- Cookie tracking via middleware Laravel
- Menyimpan context (affiliate_id) ke order saat checkout — bukan bergantung state yang tidak persisten
- Xendit Disbursement API untuk transfer uang ke rekening bank
- Rollback data yang konsisten saat operasi finansial gagal
- Edge cases yang sering terlupa di sistem affiliate
Yang Bisa Dikembangkan Selanjutnya
Sistem yang kita bangun ini sudah production-ready untuk skala kecil. Kalau mau dikembangkan lebih jauh:
Multi-tier affiliate — affiliate bisa rekrut affiliate lain, dan dapat komisi dari downline mereka. Ini butuh struktur data tree dan recursive commission calculation yang lebih kompleks.
Affiliate request withdrawal sendiri — daripada admin yang trigger payout, beri affiliate tombol "Request Payout" dengan minimum amount. Admin tinggal approve, sistem yang transfer.
Analytics lebih detail — conversion rate per affiliate (berapa persen klik yang jadi pembelian), earnings per klik, trending affiliate bulan ini. Data ini berguna untuk affiliate yang mau optimasi strategi promosi mereka.
Email notifikasi otomatis — kirim email ke affiliate saat komisi baru masuk, saat komisi di-approve, dan saat payout berhasil dikirim. Sekarang affiliate harus buka dashboard untuk tahu statusnya.
Semua extension ini butuh pemahaman yang lebih dalam tentang Laravel, arsitektur aplikasi, dan sistem queue. BuildWithAngga punya kelas yang dirancang persis untuk itu.
Belajar Lebih Dalam di BuildWithAngga
Kalau ada bagian dari artikel ini yang masih terasa berat — mungkin bagian Eloquent relationships, DB transaction, atau cara kerja middleware — itu sinyal yang bagus. Artinya kamu sudah tahu persis celah yang perlu diisi.
Mulai dari kelas gratis dulu kalau fondasinya masih perlu diperkuat:
| Kelas Gratis | Yang Kamu Dapat |
|---|---|
| Laravel Fundamental | MVC, Eloquent, routing, middleware dari nol |
| Vue.js Fundamental | Composition API, reactivity, component patterns |
| JavaScript Fundamentals | ES6+, async/await, array methods |
| Tailwind CSS | Utility-first, responsive design |
| SQL for Beginners | Query, JOIN, database design |
Kalau fondasi sudah oke dan mau bangun project yang lebih kompleks dan production-ready:
| Kelas Premium | Cocok Untuk |
|---|---|
| Full-Stack Laravel + Inertia + Vue | Bangun aplikasi lengkap dari nol sampai deploy |
| Laravel E-Commerce Complete | Payment, sistem referral, inventory, laporan keuangan |
| Laravel API Development | Backend untuk mobile app, dokumentasi API |
| SaaS dengan Laravel | Subscription, multi-tenancy, billing otomatis |
┌─────────────────────────────────────────────────────────────┐
│ BENEFIT KELAS PREMIUM BWA │
├─────────────────────────────────────────────────────────────┤
│ │
│ 📦 PROJECT PORTFOLIO-READY │
│ Source code lengkap dari nol sampai deploy │
│ Bisa langsung dipakai atau dijual ke klien │
│ │
│ ♾️ AKSES SEUMUR HIDUP │
│ Beli sekali, akses selamanya │
│ Update materi gratis │
│ Termasuk semua materi bonus │
│ │
│ 👨🏫 KONSULTASI MENTOR │
│ Tanya langsung via forum diskusi │
│ Code review dari praktisi │
│ Career guidance │
│ │
│ 📜 SERTIFIKAT RESMI │
│ Bukti kompetensi yang bisa di-share │
│ LinkedIn-ready │
│ Nilai plus untuk CV dan portofolio │
│ │
│ 👥 KOMUNITAS 900.000+ STUDENTS │
│ Networking sesama developer Indonesia │
│ Info lowongan dan project freelance │
│ Sharing pengalaman real dari dunia kerja │
│ │
└─────────────────────────────────────────────────────────────┘
Penutup
Sistem affiliate yang kita bangun di artikel ini adalah fitur yang biasanya hanya ada di platform besar. Dengan stack Laravel 12 + Xendit dan pendekatan vibe coding yang terstruktur, kamu bisa bangun dan launch-nya dalam beberapa hari — bukan beberapa bulan.
Yang membedakan developer yang bisa deliver dengan yang terus stuck bukan seberapa banyak yang mereka hafal. Tapi seberapa cepat mereka bisa identifikasi masalah, cari solusi, dan iterate. Itu yang kamu latih setiap kali baca artikel seperti ini dan langsung praktek.
Sekarang tinggal satu langkah: buka code editor, mulai dari Bagian 2, dan bangun.
Angga Risky Setiawan Founder, BuildWithAngga