Implementasi Generate Tagihan Pembayaran Manual dengan Xendit

Damar Huda

2025-04-24 14:22:05

blog cover
Share on :

Pengantar

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.

Persiapan Konfigurasi

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'),
],

Membuat Provider Xendit Manual

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;
    }
}

Membuat Redirect Link untuk Checkout

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()
        );
    }
}

Menyiapkan Data Invoice

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,
        ]),
    ];
}

Membuat Invoice Xendit

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;
}

Memperbarui Informasi Pembayaran pada Subscription

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();
}

Mencatat Transaksi

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),
        ]);
    });
}

Menghitung Jumlah Pembayaran

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;
}

Webhook Handler

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();
    }
}

Konfigurasi Controller untuk Webhook

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);
    }
}

Otomatisasi Pembuatan Invoice

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());
            }
        }
    }
}

Mendaftarkan Job Terjadwal

Daftarkan job untuk dijalankan secara terjadwal di app/Console/Kernel.php:

<?php
protected function schedule(Schedule $schedule)
{
    // ...
    $schedule->job(new GenerateXenditInvoices)->dailyAt('00:00');
    // ...
}

Konfigurasi Admin Interface

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');
    }
}

Registrasi Provider

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'));
});

Hasil implementasi



 

 

Kesimpulan

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:

  • Membutuhkan kontrol lebih atas proses pembayaran
  • Ingin menawarkan metode pembayaran alternatif kepada pelanggan
  • Perlu menangani skenario penagihan khusus yang tidak sesuai dengan model langganan otomatis

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.