2025-04-24 14:22:05
Dalam aplikasi SaaS modern, menyediakan opsi pembayaran yang fleksibel sangat penting untuk mengakomodasi kebutuhan pelanggan yang beragam. Artikel ini akan membahas implementasi penagihan pembayaran manual menggunakan Xendit, penyedia layanan pembayaran populer di Asia Tenggara.
Pertama, siapkan kredensial API Xendit di file .env:
XENDIT_MANUAL_PUBLISHABLE_KEY=your_publishable_key
XENDIT_MANUAL_SECRET_KEY=your_secret_key
XENDIT_MANUAL_WEBHOOK_SIGNING_SECRET=your_webhook_signing_secret
XENDIT_MANUAL_BASE_URL=https://api.xendit.com
Kredensial ini harus dikonfigurasi dengan benar di file services.php:
<?php
'xendit-manual' => [
'publishable_key' => env('XENDIT_MANUAL_PUBLISHABLE_KEY'),
'secret_key' => env('XENDIT_MANUAL_SECRET_KEY'),
'webhook_signing_secret' => env('XENDIT_MANUAL_WEBHOOK_SIGNING_SECRET'),
'base_url' => env('XENDIT_MANUAL_BASE_URL', 'https://api.xendit.com'),
],
Implementasi utama berada di kelas XenditManualProvider
, yang merupakan inti dari integrasi Xendit Manual:
<?php
namespace App\Services\PaymentProviders\XenditManual;
use App\Constants\PaymentProviderConstants;
use App\Constants\PlanType;
use App\Exceptions\SubscriptionCreationNotAllowedException;
use App\Models\Discount;
use App\Models\Order;
use App\Models\PaymentProvider;
use App\Models\Plan;
use App\Models\Subscription;
use App\Services\PaymentProviders\PaymentProviderInterface;
use GuzzleHttp\Client;
class XenditManualProvider implements PaymentProviderInterface
{
protected $baseUrl;
protected $client;
protected $xenditKey;
public function __construct()
{
$this->xenditKey = config('services.xendit.secret_key');
$this->client = new Client;
$this->baseUrl = config('services.xendit.base_url');
}
public function getSlug(): string
{
return PaymentProviderConstants::XENDIT_MANUAL;
}
public function getName(): string
{
return PaymentProvider::where('slug', $this->getSlug())->firstOrFail()->name;
}
}
Metode ini bertanggung jawab untuk membuat invoice Xendit dan mengembalikan URL pembayaran untuk proses checkout:
<?php
/**
* Create a Xendit invoice and return the payment URL for redirecting the user
*
* @param \App\Models\Plan $plan The plan to subscribe to
* @param \App\Models\Subscription $subscription The subscription model
* @param \App\Models\Discount|null $discount Optional discount to apply
* @param int $quantity Number of seats/units
* @return string The URL to redirect the user for payment
* @throws \App\Exceptions\SubscriptionCreationNotAllowedException If payment cannot be processed
*/
public function createSubscriptionCheckoutRedirectLink(
Plan $plan,
Subscription $subscription,
?Discount $discount = null,
int $quantity = 1
): string {
$paymentProvider = $this->assertProviderIsActive();
// Get subscription currency and calculate amount
$currency = $subscription->currency()->firstOrFail();
$amount = $this->calculateSubscriptionAmount($plan, $currency, $quantity, $discount);
// Prepare invoice data
$externalId = 'sub_' . $subscription->uuid . '_' . time();
$description = "Subscription to {$plan->name}";
$invoiceData = $this->prepareInvoiceData(
$externalId,
$amount,
$description,
$plan,
$currency,
$quantity,
$subscription
);
try {
$responseData = $this->createXenditInvoice($invoiceData);
$this->updateSubscriptionWithPaymentInfo($subscription, $paymentProvider, $responseData['id']);
$this->createTransaction(
$subscription,
$responseData,
$currency,
$paymentProvider,
$discount ? ($discount->amount) : 0,
0,
[]
);
return $responseData['invoice_url'];
} catch (\Throwable $exception) {
throw new SubscriptionCreationNotAllowedException(
'Failed to create payment invoice: ' . $exception->getMessage()
);
}
}
Bagian penting dari implementasi adalah mempersiapkan data invoice sesuai dengan kebutuhan API Xendit:
<?php
/**
* Prepare the invoice data for Xendit API
*
* @param string $externalId
* @param int $amount
* @param string $description
* @param \App\Models\Plan $plan
* @param \App\Models\Currency $currency
* @param int $quantity
* @param \App\Models\Subscription $subscription
* @return array
*/
private function prepareInvoiceData(
string $externalId,
int $amount,
string $description,
Plan $plan,
$currency,
int $quantity,
Subscription $subscription
): array {
$user = auth()->user();
// If user is null, try to get from subscription user_id
if ($user === null) {
$user = \App\Models\User::find($subscription->user_id);
}
$customerData = [
'given_names' => $user ? $user->name : 'System',
'email' => $user ? $user->email : '[email protected]',
];
if ($user && $user->phone) {
$customerData['mobile_number'] = $user->phone;
}
$items = [
[
'name' => $plan->name,
'quantity' => $quantity,
'price' => (int)$amount,
'category' => 'Subscription',
]
];
return [
'external_id' => $externalId,
'amount' => (int)$amount,
'description' => $description,
'invoice_duration' => 86400 * 3, // 3 days
'customer' => $customerData,
'currency' => $currency->code,
'items' => $items,
'reminder_time_unit' => 'days',
'reminder_time' => 1,
'customer_notification_preference' => [
'invoice_created' => ['whatsapp', 'email'],
'invoice_reminder' => ['whatsapp', 'email'],
'invoice_paid' => ['whatsapp', 'email']
],
'success_redirect_url' => route('subscription.purchase.success', [
'tenantSlug' => $subscription->tenant->slug,
]),
'failure_redirect_url' => route('checkout.subscription', [
'planSlug' => $plan->slug,
]),
];
}
Metode createXenditInvoice
melakukan panggilan API ke Xendit untuk membuat invoice:
<?php
/**
* Create an invoice using Xendit API
*
* @param array $invoiceData
* @return array
*/
private function createXenditInvoice(array $invoiceData): array
{
$client = new Client();
$response = $client->post($this->baseUrl . '/v2/invoices', [
'headers' => [
'Authorization' => $this->getAuthorizationHeader(),
'Content-Type' => 'application/json',
],
'json' => $invoiceData,
]);
return json_decode($response->getBody(), true);
}
/**
* Get the authorization header for Xendit API
*
* @return string
*/
private function getAuthorizationHeader(): string
{
$credentials = base64_encode($this->xenditKey.':');
return 'Basic '.$credentials;
}
Setelah invoice dibuat, kita perlu memperbarui informasi subscription:
<?php
/**
* Update the subscription with payment provider information
*
* @param \App\Models\Subscription $subscription
* @param \App\Models\PaymentProvider $paymentProvider
* @param string $paymentProviderSubscriptionId
* @return void
*/
private function updateSubscriptionWithPaymentInfo(
Subscription $subscription,
PaymentProvider $paymentProvider,
string $paymentProviderSubscriptionId
): void {
$subscription->payment_provider_id = $paymentProvider->id;
$subscription->payment_provider_subscription_id = $paymentProviderSubscriptionId;
$subscription->payment_provider_status = 'PENDING';
$subscription->save();
}
Setiap invoice yang dibuat harus dicatat sebagai transaksi:
<?php
/**
* Create a transaction record for the subscription
*
* @param \App\Models\Subscription $subscription The subscription
* @param array $data Response data from payment provider
* @param \App\Models\Currency $currency The currency
* @param \App\Models\PaymentProvider $paymentProvider The payment provider
* @param int $discount Discount amount
* @param int $tax Tax amount
* @param array $metadata Additional metadata
* @return void
*/
private function createTransaction(
$subscription,
$data,
$currency,
$paymentProvider,
$discount,
$tax,
$metadata
): void {
\Illuminate\Support\Facades\DB::transaction(function () use ($subscription, $data, $currency, $paymentProvider, $discount, $tax) {
\App\Models\Transaction::create([
'uuid' => (string) \Illuminate\Support\Str::uuid(),
'user_id' => $subscription->user_id,
'currency_id' => $currency->id,
'amount' => $data['amount'],
'total_tax' => $tax,
'total_discount' => $discount,
'total_fees' => 0,
'status' => \App\Constants\TransactionStatus::PENDING->value,
'subscription_id' => $subscription->id,
'payment_provider_id' => $paymentProvider->id,
'payment_provider_status' => 'PENDING',
'payment_provider_transaction_id' => $data['id'],
'tenant_id' => $subscription->tenant_id,
'url_external' => $data['invoice_url'] ?? null,
'external_id' => $data['external_id'] ?? null,
'due_date' => now()->addDays(3),
]);
});
}
Metode calculateSubscriptionAmount
digunakan untuk menghitung total pembayaran, dengan optional diskon:
<?php
/**
* Calculate total subscription amount with optional discount
*
* @param \App\Models\Plan $plan
* @param \App\Models\Currency $currency
* @param int $quantity
* @param \App\Models\Discount|null $discount
* @return int
* @throws \App\Exceptions\SubscriptionCreationNotAllowedException
*/
private function calculateSubscriptionAmount(
Plan $plan,
$currency,
int $quantity,
?Discount $discount = null
): int {
$planPrice = $plan->prices()->where('currency_id', $currency->id)->first();
if (!$planPrice) {
throw new SubscriptionCreationNotAllowedException('No price found for this plan and currency');
}
$amount = $planPrice->price * $quantity;
if ($discount !== null) {
if ($discount->type === 'fixed') {
$amount = $amount - $discount->amount;
} else {
$discountValue = ($discount->amount / 100) * $amount;
$amount = $amount - $discountValue;
}
}
return $amount <= 0 ? 1000 : $amount;
}
Untuk memproses update status pembayaran dari Xendit, implementasikan webhook handler seperti berikut:
<?php
namespace App\Services\PaymentProviders\XenditManual;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class XenditManualWebhookHandler
{
public function handleWebhook(Request $request): JsonResponse
{
$receivedToken = $request->header('x-callback-token');
$webhookToken = config('services.xendit-manual.webhook_signing_secret');
if (!$this->isValidWebhookToken($receivedToken, $webhookToken)) {
return response()->json(['error' => 'Unauthorized'], 401);
}
$this->processWebhookData($request->all());
return response()->json(['success' => true]);
}
private function processWebhookData(array $data): void
{
try {
$externalId = $data['external_id'] ?? null;
if (!$this->validateExternalId($externalId)) {
return;
}
$subscriptionUuid = $this->extractSubscriptionUuid($externalId);
if (empty($subscriptionUuid)) {
return;
}
$transaction = $this->findRelatedTransaction($externalId);
if (!$transaction) {
return;
}
$this->updateTransactionStatus($transaction, $data);
} catch (\Exception $e) {
// Silent exception handling
}
}
private function isValidWebhookToken($receivedToken, $webhookToken): bool
{
return $receivedToken === $webhookToken;
}
private function validateExternalId(?string $externalId): bool
{
return !empty($externalId) && strpos($externalId, 'sub_') === 0;
}
private function extractSubscriptionUuid(string $externalId): ?string
{
$parts = explode('_', $externalId);
return $parts[1] ?? null;
}
private function findRelatedTransaction(string $externalId)
{
return \App\Models\Transaction::where('external_id', $externalId)->first();
}
private function updateTransactionStatus($transaction, array $data): void
{
$status = $data['status'] ?? '';
if ($status === 'PAID') {
$transaction->status = \App\Constants\TransactionStatus::PAID->value;
$transaction->payment_provider_status = 'PAID';
$transaction->paid_at = now();
// Update subscription status
$subscription = $transaction->subscription;
if ($subscription) {
$subscription->payment_provider_status = 'PAID';
$subscription->status = 'active';
// Set ends_at to the next billing cycle
$subscription->ends_at = now()->addMonth();
$subscription->save();
}
} else if ($status === 'EXPIRED') {
$transaction->status = \App\Constants\TransactionStatus::FAILED->value;
$transaction->payment_provider_status = 'EXPIRED';
}
$transaction->save();
}
}
Berikut controller webhook:
<?php
namespace App\Http\Controllers\PaymentProviders;
use App\Http\Controllers\Controller;
use App\Services\PaymentProviders\XenditManual\XenditManualWebhookHandler;
use Illuminate\Http\Request;
class XenditManualController extends Controller
{
public function handleWebhook(Request $request, XenditManualWebhookHandler $handler)
{
return $handler->handleWebhook($request);
}
}
Untuk otomatisasi pembuatan invoice untuk subscription yang mendekati tanggal akhir, kita bisa membuat job terjadwal:
<?php
namespace App\Jobs;
use App\Models\PaymentProvider;
use App\Models\Subscription;
use App\Services\PaymentProviders\PaymentManager;
use Carbon\Carbon;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class GenerateXenditInvoices implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function handle(PaymentManager $paymentManager)
{
$today = Carbon::now()->startOfDay();
$threeDaysFromNow = Carbon::now()->addDays(3)->endOfDay();
$xenditManualPaymentProvider = PaymentProvider::where('slug', 'xendit-manual')->first();
if (!$xenditManualPaymentProvider) {
Log::error('Xendit Manual payment provider not found');
return;
}
$subscriptions = Subscription::where('payment_provider_id', $xenditManualPaymentProvider->id)
->where('status', 'active')
->whereNotNull('payment_provider_subscription_id')
->where(function ($query) {
$query->where('payment_provider_status', 'PAID')
->orWhere('payment_provider_status', 'TRIAL');
})
->whereBetween('ends_at', [$today, $threeDaysFromNow])
->where('is_canceled_at_end_of_cycle', false)
->get();
$xenditManualProvider = $paymentManager->getPaymentProviderBySlug('xendit-manual');
foreach ($subscriptions as $subscription) {
try {
$invoiceUrl = $xenditManualProvider->createSubscriptionCheckoutRedirectLink(
$subscription->plan,
$subscription,
null,
$subscription->quantity
);
Log::info('Generated invoice for subscription: ' . $subscription->uuid);
// Kirim notifikasi ke pengguna tentang invoice baru
// ...
} catch (\Exception $e) {
Log::error('Error generating invoice: ' . $e->getMessage());
}
}
}
}
Daftarkan job untuk dijalankan secara terjadwal di app/Console/Kernel.php
:
<?php
protected function schedule(Schedule $schedule)
{
// ...
$schedule->job(new GenerateXenditInvoices)->dailyAt('00:00');
// ...
}
Untuk memudahkan administrator mengonfigurasi pengaturan Xendit Manual, buat interface admin menggunakan Filament:
<?php
namespace App\Filament\Pages\Settings;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Pages\Page;
use Illuminate\Support\HtmlString;
class XenditManualSettings extends Page
{
protected static string $view = 'filament.pages.settings.xendit-manual-settings';
public function form(Form $form): Form
{
return $form
->schema([
Section::make()
->schema([
TextInput::make('publishable_key')
->label(__('Publishable Key'))
->helperText(new HtmlString(__('Publishable Key untuk Xendit')))
->required(),
TextInput::make('secret_key')
->label(__('Secret Key'))
->password()
->helperText(new HtmlString(__('Secret Key untuk Xendit')))
->required(),
TextInput::make('webhook_signing_secret')
->label(__('Webhook Signing Secret'))
->helperText(new HtmlString(__('Webhook Secret untuk memvalidasi callback dari Xendit')))
->required(),
])
->columnSpan([
'sm' => 6,
'xl' => 8,
'2xl' => 8,
]),
]);
}
public function getFormValues(): array
{
return [
'publishable_key' => config('services.xendit-manual.publishable_key'),
'secret_key' => config('services.xendit-manual.secret_key'),
'webhook_signing_secret' => config('services.xendit-manual.webhook_signing_secret'),
];
}
public function submit(array $data)
{
// Simpan konfigurasi ke database atau update .env file
// ...
$this->notify('success', 'Xendit Manual settings updated successfully');
}
}
Jangan lupa untuk mendaftarkan provider di service container:
<?php
// AppServiceProvider.php
$this->app->tag([
// provider lainnya...
XenditManualProvider::class,
], 'payment-providers');
$this->app->bind(PaymentManager::class, function () {
return new PaymentManager(...$this->app->tagged('payment-providers'));
});
Dengan implementasi ini, Anda dapat menawarkan opsi pembayaran manual yang fleksibel menggunakan Xendit invoices. Sistem secara otomatis akan mengirimkan invoice, melacak pembayaran, dan memperbarui status subscription berdasarkan notifikasi webhook dari Xendit.
Pendekatan ini sangat berguna untuk bisnis yang:
Dengan memanfaatkan API Xendit dan sistem webhook yang robust, Anda dapat menciptakan pengalaman pembayaran manual yang andal dan terintegrasi dengan mulus ke dalam aplikasi Laravel Anda.