Her SaaS faturalama entegrasyonu şu şekilde başlar: bir sağlayıcı seçersiniz, paketi ekler, bağlarsınız — ve üç ay sonra iş, ikinci bir ödeme geçidi eklemek (veya Stripe’ın yerel destek sunmadığı bir Malasyalça ödemesini BayarCash veya ToyyibPay gibi başka bir sağlayıcıya geçmek) istediğinde, tüm abonelik katmanınızın ilk sağlayıcının paketiyle kenetli olduğunu keşfedersiniz. Farklı webhook yapıları, farklı durum kelimeleri, farklı model varsayımları. Bir geçit eklemiyorsunuz; yeniden yapılandırıyorsunuz.
<p>Bunun sıkça yaşandığını görmekten rahatsız oldum — özellikle Malasya pazarında, "açık" olan global paketlerin birçok müşterimin aslında kullanamadığı bir geçidi varsaydığını gözlemledim. Bu yüzden <a href="https://github.com/cleaniquecoders/laravel-billing" target="_blank" rel="noopener noreferrer"><code>cleaniquecoders/laravel-billing</code></a> paketini oluşturduk. Burada bir ters dönüşüm yapıyoruz: <strong>geçit eklentidir, paket değildir.</strong> Motor, abonelik ve fatura durumunu yönetir. Bir geçit, uygulamanızın uyguladığı tek bir kontrattır. Bu makale, API yüzeyine odaklanmaktan ziyade <em>neden böyle şekillendiği</em> ile ilgilidir — çünkü şekil tüm konunun kendisidir.</p>
<h2>
<a name="the-core-decision-one-package-gateways-as-a-contract" href="#the-core-decision-one-package-gateways-as-a-contract"></a>
Temel karar: bir paket, geçitler sözleşme olarak
</h2>
<p>Bir faturalama kütüphanesi oluşturma konusunda en büyük cazibe, <code>laravel-billing-stripe</code>, <code>laravel-billing-bayarcash</code>, <code>laravel-billing-toyyibpay</code> gibi birçok paket göndermektir. Modüler olduğu hissini verir. Ama aslında bu bir bakım tuzağıdır – her geçit alt paketi, aynı abonelik yaşam döngüsünü biraz farklı bir şekilde yeniden uygular ve çekirdek hiçbir zaman kararlı bir şekil varsayamaz çünkü her adaptör onu bükebilir.</p>
<p>Bu paket diğer şekilde hareket eder. <strong>Bir paket, bir repo</strong> vardır ve gerçek bir sağlayıcı adını asla referans almaz. Bunun yerine tek bir genişletme noktası vardır:<br/></p>
<div class="highlight js-code-highlight">
<pre class="highlight php"><code>
namespace CleaniqueCoders\LaravelBilling\Contracts;
interface PaymentGateway
{
public function createCheckout(
Billable $billable,
Plan $plan,
PlanInterval $interval,
string $returnUrl,
): CheckoutIntent;
public function cancel(Subscription $subscription): void;
public function parseWebhook(Request $request): ?WebhookEvent;
}
</code></pre>
</div>
<p>Üç metot. BayarCash, ToyyibPay, Chip, senangPay, Stripe veya başka bir şeyi eklemek için uygulamanızın uyguladığı yüzey budur. Bunu tutarlı hale getiren, sınırdaki iki DTO’dur — <code>CheckoutIntent</code> çıkışta, <code>WebhookEvent</code> geri dönüşte:<br/></p>
<div class="highlight js-code-highlight">
<pre class="highlight php"><code>
final class CheckoutIntent
{
public function __construct(
public string $redirectUrl, // müşteriyi nereye göndereceğimiz
public string $externalId, // webhook tarafından geri döndürülecek
) {}
}
</code></pre>
</div>
<p>Geçidin görevi, sağlayıcının alışılmadık dünyasını bu iki tarafsız forma dönüştürmektir. Bir kez bunu yaptığında, motor — abonelik geçişleri, fatura tahsisi, olaylar — hangi sağlayıcıyla konuştuğunu bilmeden devam edebilir. <strong>Sağlayıcıya özel karmaşa, bir sınıf içinde karantinaya alınmıştır</strong>, böylece tüm faturalama katmanınıza yayılmaz. Buradaki paket değerli bir ders: N dış hizmetlerle entegrasyon yaptığınızda, aynı şeyleri yapan, sınırda kendi DTO'nuzu tanımlayın ve her bir adaptörü çeviri ile sorumlu tutun. Sağlayıcı şekillerinin içeriye yayılmasına izin vermeyin.</p>
<h2>
<a name="batteries-included-a-gateway-that-needs-no-merchant-account" href="#batteries-included-a-gateway-that-needs-no-merchant-account"></a>
Pil dahil: bir ticaret hesabı gerektirmeyen bir geçit
</h2>
<p>En memnun kaldığım kısım burası. Yeni bir kurulum <code>BILLING_GATEWAY=local</code> varsayılanı ile başlar ve paketlenmiş <code>LocalGateway</code> gerçek para ve ticaret hesabı olmadan <strong>tam</strong> bir abonelik → aktif etme → fatura → makbuz akışını çalıştırır. <code>composer require</code> edersiniz, geçişleri çalıştırırsınız ve faturalama akışı hemen çalışır — demo, geliştirme, UAT, CI’de.</p>
<p>Ama bu bir stub değil. Önemli olan detay şu:<br/></p>
<div class="highlight js-code-highlight">
<pre class="highlight php"><code>
// LocalGateway::createCheckout — onay akışları TAMAMEN aynı
// WebhookEvent yolu
return new WebhookEvent(
type: WebhookEventType::SubscriptionActivated,
externalId: $payload['external_id'],
amountCents: $payload['amount_cents'] ?? null,
providerEventId: 'local-' . $payload['external_id'],
rawPayload: $payload,
);
</code></pre>
</div>
<p>Yerel geliştirme kontrol sayfasında "Onayla" düğmesine tıkladığınızda, <code>WebhookEvent</code> üretir ve bunu <code>Billing::handle()</code> işleme geçirir — gerçek bir BayarCash webhook’un geçmesi gereken <em>tam aynı kod yolu</em>. Hatta kontrol tokenını <code>app.key</code> ile HMAC imzalar ve geri gelirken imzayı doğrular, böylece imza doğrulama mantığı da işlenir:<br/></p>
<div class="highlight js-code-highlight">
<pre class="highlight php"><code>
public static function verify(string $token): ?array
{
[$data, $signature] = explode('.', $token, 2);
$expected = hash_hmac('sha256', $data, static::key());
if (!hash_equals($expected, $signature)) {
return null; // değiştirilmiş veya geçersiz
}
// ...
}
</code></pre>
</div>
<p>Bunun için "geliştirici" geçidi olarak bu kadar zahmete girmeye neden var? Çünkü farklı bir yoldan geçen bir sahte, gerçek akıştan daha kötü — sahte bir güven sunar. Yerel geçit akışını gerçek doğrulama hattından geçirerek, <code>local</code> üzerinden yaptığınız testler, gerçek bir ödeyenin deneyimleyeceği akışı doğrular. <code>BILLING_LOCAL_AUTO=true</code> ayarlarsanız, tüm şey belirli bir istekte senkron olarak çalışır, bu da CI ve özellik testleri için mükemmeldir. Yerel yollar, üretimde kaydedilmez, bu nedenle yanlış bir adım olamaz.</p>
<h2>
<a name="headless-core-optional-ui" href="#headless-core-optional-ui"></a>
Başsız çekirdek, isteğe bağlı UI
</h2>
<p>Motor — modeller, servisler, sözleşmeler, olaylar, yönetici — herhangi bir UI olmadan çalışır. Eğer hızlı bir şekilde faturalama sayfaları istiyorsanız, hızlıca bütün döngüyü tamamlamak için gönüllü bir Livewire + Flux UI (plan seçici, faturalama portalı, makbuz kartı) mevcuttur. Koruma temizdir:<br/></p>
<div class="highlight js-code-highlight">
<pre class="highlight php"><code>
if (config('billing.routes.enabled') && class_exists(Livewire::class)) {
// kayıt et /billing yolları
}
</code></pre>
</div>
<p>Livewire yüklü değilse veya <code>BILLING_UI_ENABLED=false</code> ayarlarsanız, paket tamamen başsız kalır ve aynı modellerle ve yüzeyle kendi sayfalarınızı oluşturursunuz. Çekirdek üzerine UI yığınından zorunlu bir bağımlılık yoktur. Bu, bir kütüphane için doğru varsayılandır: Fikrini savunan bir rahatlık katmanı varsa, ancak bu bir <code>class_exists</code> kontrolü ve bir yapılandırma bayrağı ile gerçekleştirilir, asla zorunlu değildir.</p>
<h2>
<a name="the-webhook-flow-and-a-replay-guard-worth-stealing" href="#the-webhook-flow-and-a-replay-guard-worth-stealing"></a>
Webhook akışı ve çalınmaya değer bir tekrar koruma
</h2>
<p>Uygulamanız yolu yönetir; paket iş yapar:<br/></p>
<div class="highlight js-code-highlight">
<pre class="highlight php"><code>
Route::post('/webhooks/{gateway}', function (Request $request, string $gateway) {
$event = Billing::gateway($gateway)->parseWebhook($request);
abort_if($event === null, 401);
Billing::handle($event); // deduplar, durum geçişleri, faturalara çıkarır, olayları başlatır
return response()->noContent();
});
</code></pre>
</div>
<p><code>parseWebhook()</code> (geçidinizin kodu) imzayı doğrular ve yükü normalleştirir veya bunu reddetmek için <code>null</code> döndürür. Sonra <code>Billing::handle()</code> bir <code>WebhookProcessor</code>'e devreder; tekrar koruma uygular, aboneliği bulur, durumu değiştirir, aktif/yenileme sırasında bir fatura çıkarır ve eşleşen alan olayını ateşler.</p>
<p>Tekrar koruma küçük ama hoş bir anlayış:<br/></p>
<div class="highlight js-code-highlight">
<pre class="highlight php"><code>
protected function isReplay(WebhookEvent $event): bool
{
if ($event->providerEventId === null) {
return false;
}
$key = 'billing:webhook:' . $event->providerEventId;
$ttl = (int) config('billing.webhook.replay_ttl', 60 * 60 * 24 * 30);
// Cache::add, anahtar zaten mevcut olduğunda false döner → tekrar.
return Cache::add($key, true, $ttl) === false;
}
</code></pre>
</div>
<p>Geçitler tekrar deneyebilir. Aynı olayı iki, üç kez gönderirler çünkü yeterince hızlı <code>200</code> alamadılar. Eğer deduplar yapmazsanız, iki kat fatura çıkarabilirsiniz. Güzel bir kısım ise <code>Cache::add</code>'ın atomikliğine dayanmak — bu yalnızca anahtar yoksa yazar ve yarışmada kazananını belirtir, tek operasyonda. Paralel bir çoğaltmanın kayma olasılığının olması için okuma-sonrası-yazma penceresi yok. Bu, yalnızca faturalama için değil, her idempotent etkinlik işleme için yeniden kullanılabilir bir modeldir.</p>
<h2>
<a name="state-transitions-live-in-one-place" href="#state-transitions-live-in-one-place"></a>
Durum geçişleri tek bir yerde yaşar
</h2>
<p><code>WebhookProcessor</code>, sağlayıcı olaylarının abonelik durumuna dönüşüm noktasıdır ve bir durum makinasi gibi okunur:<br/></p>
<div class="highlight js-code-highlight">
<pre class="highlight php"><code>
match ($event->type) {
WebhookEventType::SubscriptionActivated => $this->activate($subscription),
WebhookEventType::SubscriptionRenewed => $this->renew($subscription),
WebhookEventType::PaymentSucceeded => $this->paymentSucceeded($subscription, $event),
WebhookEventType::PaymentFailed => $this->paymentFailed($subscription, $event),
WebhookEventType::SubscriptionCanceled => $this->cancel($subscription),
};
</code></pre>
</div>
<p>Geçidin tek sorumluluğu, kendi sağlayıcısının sözlüğünü bu beş <code>WebhookEventType</code> durumuna eşlemektir. Aşağıya bakan her şey — "aktifleştirme"nin dönem tarihleri için ne ifade ettiği, bir faturanın ne zaman çıkarılacağı, hangi olayın ateşlenir olduğu — bir defada belirlenir, motorda, sağlayıcıdan bağımsız olarak. Bir <code>SubscriptionStatus</code> enum, kendi erişim mantığını taşır bu nedenle kural yerlerde dağılmaz:<br/></p>
<div class="highlight js-code-highlight">
<pre class="highlight php"><code>
public function grantsAccess(): bool
{
return match ($this) {
self::Trialing, self::Active, self::PastDue => true,
self::Canceled, self::Incomplete => false,
};
}
</code></pre>
</div>
<p>Dikkatinizi çekin, <code>PastDue</code> hala erişim izni verir — başarısız bir yenilemenin, bir dönemin ortasında birini hemen kilitlememesi gerektiği. Bu kasıtlı bir cümle dostu seçimidir ve çünkü enum'da yaşar, erişim kontrol edilen her yerde tutarlıdır.</p>
<h2>
<a name="polymorphic-billing-tenancy-is-optional" href="#polymorphic-billing-tenancy-is-optional"></a>
Polimorfik faturalama: Tenellik isteğe bağlı
</h2>
<p>Faturalama hedefi polimorfiktir, bu yüzden aynı motor tekli kiracı (<code>User</code>) ve çok kiracı (<code>Team</code>/<code>Workspace</code>/<code>Organization</code>) hizmet eder, hangisi olursa olsun:<br/></p>
<div class="highlight js-code-highlight">
<pre class="highlight php"><code>
class User extends Authenticatable implements Billable
{
use HasSubscriptions;
}
</code></pre>
</div>
<p><code>HasSubscriptions</code>, <code>Billable</code> sözleşmesini tamamen karşılar ve motor ve UI'nın bağımlı olduğu erişim sağlayıcıları sunar — <code>subscription()</code>, <code>subscribedTo('pro')</code>, <code>onTrial()</code>, <code>onGracePeriod()</code>, <code>plan()</code>, <code>invoices()</code>, ayrıca metered kullanım konusunda <code>canConsume('seats', 1)</code> / <code>recordUsage('seats', 1)</code> ile arama yapar. Faturalama işleminin bir takıma yönlendirilmesi için, bir yapılandırma kapanışını buna yönlendirebilirsiniz:<br/></p>
<div class="highlight js-code-highlight">
<pre class="highlight php"><code>
'billable_resolver' => fn($request) => $request->user()->currentTeam,
</code></pre>
</div>
<p>Her UI sorgusu ve her fatura indirme, çözülen faturalama ile sınırlıdır ve indirme yolları yabancı bir fatura üzerinde <code>403</code> döner — bu nedenle bir kiracı asla diğerinin faturalarını göremez. Kiracılık, bir kiracılık <em>özelliği</em> gerektirmeden ortaya çıktı; hedefin polimorfik hale getirilip tüm erişimlerin bir çözümleyici üzerinden aktarılması sayesinde.</p>
<h2>
<a name="a-few-more-details-worth-noting" href="#a-few-more-details-worth-noting"></a>
Dikkate değer birkaç detay
</h2>
<p><strong>Anlık görüntü vs canlı.</strong> Bir abonelik, <code>plan_tier</code> değerini bir <em>anlık görüntü</em> dizesi olarak saklar, ancak canlı <code>Plan</code> okuma zamanında deposundan çözülür. Böylece plan tanımları yapılandırmada veya bir veritabanı tablosunda yaşayabilir (her ikisi de aynı <code>PlanRepository</code> arayüzü), bir abone'nin katman referansı, plan modellerinizi yeniden yapılandırsanız bile kalır.</p>
<p><strong>Atomik fatura numaraları.</strong> Ardışık numaralandırma (<code>INV-2026-000001</code>) bir satır kilitli işlemde tahsis edilir, bu nedenle eş zamanlı tahsis hiçbir numara çakışmasına neden olmaz:<br/></p>
<div class="highlight js-code-highlight">
<pre class="highlight php"><code>
$sequence = $sequenceModel::query()->where('year', $year)->lockForUpdate()->first();
$current = (int)$sequence->next_number;
$sequence->next_number = $current + 1;
$sequence->save();
</code></pre>
</div>
<p><strong>Malezya dostu, nötr.</strong> MYR varsayılı, SST/SSM farkında bir vergi-fatura şablonu, yapılandırılabilir satıcı detayları — ama varsayılan olarak hepsi nötr olduğundan, yalnızca Malezya'ya özgü değildir. Vergi matematiği sadece <code>round(subtotal * rate)</code> olup, fatura üzerinde doğru bir şekilde render edilmesi için bir döküm olarak saklanır.</p>
<p><strong>Etkinlikler, uzatma dikişiniz.</strong> Motor yalnızca durumu güncelleyip fatura oluşturur. Erişim verme, dunning e-postaları, Slack bildirimleri — bunlar <code>SubscriptionActivated</code>, <code>SubscriptionRenewed</code>, <code>SubscriptionCanceled</code>, <code>PaymentSucceeded</code>, <code>PaymentFailed</code>, <code>InvoiceIssued</code> etkinlikleriniz dolayısıyla okuğuna bağlanacak dinleyicilerdir. Paket yan etkilerinizi tahmin etmez.</p>
<h2>
<a name="when-youd-reach-for-this" href="#when-youd-reach-for-this"></a>
Ne zaman bu paketi kullanmalısınız
</h2>
<p>Bu, Laravel'de abonelik + faturalandırma istiyorsanız uygun bir yapıdadır ve:</p>
<ul>
<li>Başka bir <strong>geçit gerekli olduğunda</strong>, veya <strong>Malezyalı bir geçit</strong> veya daha sonra yeniden yapılandırma özgürlüğü aradığınızda;</li>
<li>İlk günden <strong>tam akışın çalışmasını</strong> istiyorsanız — demo, UAT, CI — herhangi bir ticaret hesabı olmadan;</li>
<li>Kendi UI'nizden kontrol edebileceğiniz <strong>başsız bir motor</strong> istiyorsanız, hızlıca hareket ederken isteğe bağlı bir kullanıcı arayüzü ile;</li>
<li>Sadece kullanıcıları değil, <strong>takımları veya iş alanlarını faturalıyorsanız;</li>
<li>Bir <strong>SST/SSM</strong> bağlamında iseniz ve sağlayıcıya bir kilitlenme olmadan makul bir yerel faturalandırma istiyorsanız.</li>
</ul>
<p>Tek bir global geçit için her şey dahil iseniz ve ilk taraf Laravel paketi sizi kapsıyorsa, o zaman onu kullanın. Buradaki değer, "hangi geçit" haline geldiği anda kendini gösterir; bunun için birden fazla yanıt olduğunda — Malezya pazarında, bu her zaman geçerlidir.</p>
<p>MIT lisanslıdır ve Packagist’te mevcuttur:<br/></p>
<div class="highlight js-code-highlight">
<pre class="highlight shell"><code>composer require cleaniquecoders/laravel-billing
</code></pre>
</div>
<p>Repo ve tam belgeler (mimari, geçitler, tam faturalama döngüsü, kendi sürücünüzü yazmak): <a href="https://github.com/cleaniquecoders/laravel-billing" target="_blank" rel="noopener noreferrer">github.com/cleaniquecoders/laravel-billing</a>.</p>
<p>Bir geçit uygulamak bir sınıf ve üç metot gerektirir — bir BayarCash veya ToyyibPay sürücüsü yazarsanız, görmekten çok mutlu olurum.</p>Kaynak: Orijinal Makale


