gardi/dcb-kit
Composer 安装命令:
composer require gardi/dcb-kit
包简介
A framework-agnostic toolkit for direct carrier billing (DCB): a carrier-gateway interface, normalized callback events, idempotent charging and signature verification.
README 文档
README
A small, framework-agnostic toolkit for direct carrier billing (DCB) in PHP — the part that's the same across every carrier: one gateway interface, normalized callback events, idempotent charging, and signature verification. Zero runtime dependencies.
I built and ran 20+ carrier-billing integrations across ~10 countries on a
production platform (subscriptions, one-off charges, and millions of async
billing callbacks). The carriers were all different — different APIs, different
notification formats, different quirks — but the shape of the problem was
always the same. dcb-kit is that shape, distilled. It is not any carrier's
proprietary integration (those stay under NDA where they belong); it's the
scaffolding you hang your own adapters on.
Install
composer require gardi/dcb-kit
The idea
A carrier integration always comes down to a few operations — subscribe a number,
charge it, cancel — plus a stream of async notifications (activated, renewed,
charged, out of balance, unsubscribed). Every carrier names these differently;
dcb-kit normalizes them to one enum so the rest of your app never cares which
carrier it's talking to:
Subscribed · Renewed · Charged · InsufficientBalance · Unsubscribed · Failed · Unknown
Write a carrier
Implement one interface per carrier:
use Gardi\DcbKit\Contracts\CarrierGateway; use Gardi\DcbKit\Callbacks\{CallbackEvent, CallbackType}; use Gardi\DcbKit\Results\{ChargeResult, SubscriptionResult}; use Gardi\DcbKit\{Money, Support\Signature}; final class AcmeTelecom implements CarrierGateway { public function __construct(private string $apiKey, private string $secret) {} public function name(): string { return 'acme'; } public function subscribe(string $msisdn, string $plan): SubscriptionResult { /* call carrier */ } public function charge(string $msisdn, Money $amount, string $reference): ChargeResult { /* ... */ } public function unsubscribe(string $msisdn, string $subscriptionId): void { /* ... */ } public function parseCallback(array $payload): CallbackEvent { $type = match ($payload['event']) { 'SUB_OK' => CallbackType::Subscribed, 'BILL_OK' => CallbackType::Charged, 'NO_FUND' => CallbackType::InsufficientBalance, default => CallbackType::Unknown, }; return new CallbackEvent($type, $payload['msisdn'], raw: $payload); } public function verifyCallback(string $rawBody, string $signature): bool { return Signature::verify($rawBody, $signature, $this->secret); } }
Or configure one instead of coding it
Most carriers are just a base URL + a status table + an auth scheme + a signature
scheme. For those, skip the class — hand HttpCarrierGateway the moving parts:
use Gardi\DcbKit\{HttpCarrierGateway, CallbackUrl}; use Gardi\DcbKit\Callbacks\{StatusMap, CallbackType}; use Gardi\DcbKit\Contracts\Transport; use Gardi\DcbKit\Auth\BearerAuth; use Gardi\DcbKit\Verification\HmacVerifier; // 1. The HTTP seam — wrap whatever client you use (Guzzle, Laravel Http, curl). final class GuzzleTransport implements Transport { public function request(string $method, string $url, array $payload, array $headers = []): array { // ...send it (with $headers) and return the decoded JSON response as an array } } // 2. Configure the carrier — no subclass needed. $carriers->extend('acme', fn () => new HttpCarrierGateway( name: 'acme', baseUrl: $config['acme']['base_url'], // <- your base URL transport: new GuzzleTransport(), statusMap: StatusMap::make([ // <- this carrier's status codes 'ACTIVATION' => CallbackType::Subscribed, 'RENEWAL' => CallbackType::Renewed, 'BILL_OK' => CallbackType::Charged, 'NO_FUNDS' => CallbackType::InsufficientBalance, 'CANCEL' => CallbackType::Unsubscribed, ]), verifier: new HmacVerifier($config['acme']['secret']), // <- how callbacks are signed auth: new BearerAuth($config['acme']['token']), // <- how requests authenticate callbackUrl: CallbackUrl::for($config['webhook_base'], 'acme'), statusField: 'event', // <- which payload key holds the status msisdnField: 'phone', transactionIdField: 'data.txn.id', // <- dot paths work for nested responses ));
The pieces that vary most between carriers are all strategies you pick:
- Auth (
Gardi\DcbKit\Auth\*):BearerAuth,ApiKeyAuth,BasicAuth,QueryKeyAuth,NoAuth— or implementAuthenticationfor request signing. - Callback verification (
Gardi\DcbKit\Verification\*):HmacVerifier(HMAC of the raw body) orNoVerification— or implementCallbackVerifier. - Field names accept dot paths (
data.transaction.id), so renamed and nested response/callback shapes need no code.
Carriers that still don't fit extend HttpCarrierGateway and override the one
method that differs.
Or define every carrier in config
When the carriers are config-shaped, build the whole manager from an array — no per-carrier code at all:
use Gardi\DcbKit\CarrierManager; $carriers = CarrierManager::fromArray([ 'acme' => [ 'base_url' => 'https://api.acme.test', 'auth' => ['type' => 'bearer', 'token' => $config['acme']['token']], 'verifier' => ['type' => 'hmac', 'secret' => $config['acme']['secret']], 'statuses' => [ 'ACTIVATION' => 'subscribed', 'BILL_OK' => 'charged', 'NO_FUNDS' => 'insufficient_balance', 'CANCEL' => 'unsubscribed', ], 'status_field' => 'event', 'msisdn_field' => 'phone', 'transaction_id_field' => 'data.txn.id', ], // 'mtn' => [ ... ], 'zain' => [ ... ] ], new GuzzleTransport()); $carriers->gateway('acme')->charge(/* ... */);
auth.type is one of bearer / api_key / basic / query / none;
verifier.type is hmac / none. This maps straight onto a Laravel/Symfony
config file — adding a carrier becomes a config edit, not a code deploy.
Use it
Register your carriers and resolve them by name:
use Gardi\DcbKit\{CarrierManager, Money}; $carriers = new CarrierManager(); $carriers->extend('acme', fn () => new AcmeTelecom($apiKey, $secret)); // lazy $gateway = $carriers->gateway('acme'); // $reference is your idempotency key — never charge the same one twice. $result = $gateway->charge('9647501234567', Money::of(500, 'IQD'), 'order-42'); // In your webhook controller — verify the RAW body, then parse the decoded array: if ($gateway->verifyCallback($rawBody, $signature)) { $event = $gateway->parseCallback($payload); // $payload = json_decode($rawBody, true) if ($event->isSuccessful()) { // mark the subscription/charge as confirmed } }
See tests/FakeCarrier.php for a complete reference gateway.
Resilience (optional)
Three opt-in decorators cover the production concerns — all composable with the above.
Retry transient failures — wrap your Transport. Retrying a charge is safe
because the reference is the carrier's idempotency key, so the same reference is
never a second charge:
use Gardi\DcbKit\Transport\RetryingTransport; $transport = new RetryingTransport( new GuzzleTransport(), maxAttempts: 3, baseDelayMs: 100, // 100ms, then 200ms, then 400ms ... (exponential) retryOn: fn (\Throwable $e) => $e instanceof MyTimeoutException, // optional: scope it );
Don't double-charge your own retries — wrap a gateway with an
IdempotencyStore. A charge already completed under a reference is replayed from
the store instead of charged again (failed charges aren't remembered, so they can
be retried):
use Gardi\DcbKit\Idempotency\IdempotentGateway; $gateway = new IdempotentGateway($carriers->gateway('acme'), new RedisIdempotencyStore()); $gateway->charge('9647501234567', Money::of(500, 'IQD'), 'order-42'); // safe to repeat
The shipped InMemoryIdempotencyStore is for tests / a single process — back the
IdempotencyStore interface with Redis or a unique-indexed table in production.
(This guards against your app repeating a charge; the in-flight case — you
charged, the response was lost, you retry — is covered by the carrier's own
reference idempotency.)
Handle a webhook in one call — resolve the carrier, verify the raw body, decode, and parse:
use Gardi\DcbKit\Webhooks\WebhookHandler; use Gardi\DcbKit\Exceptions\InvalidCallbackSignatureException; $handler = new WebhookHandler($carriers); try { $event = $handler->handle($carrier, $request->getContent(), $request->header('X-Signature')); // ... act on $event, then 200 } catch (InvalidCallbackSignatureException) { // 403 — spoofed or misconfigured }
What's in the box
CarrierGateway— the per-carrier interface.HttpCarrierGateway— a configurable base: stand up a carrier from a base URL +StatusMap+ auth + verifier + (dot-path) field names; override only the unusual bits.CarrierManager— register/resolve carriers by name (eager or lazy), orCarrierManager::fromArray()to build them all from one config array.Authentication(Auth\*) — pluggable outgoing-request auth: bearer, API key, basic, query key, or your own.CallbackVerifier(Verification\*) — pluggable callback verification: HMAC of the raw body, none, or your own.CallbackEvent/CallbackType/StatusMap— normalized notifications + the status table that feeds them.Transport— the HTTP seam (bring your own client; keeps the kit dependency-free).Money,SubscriptionResult,ChargeResult,CallbackUrl— small value objects.Signature— the constant-time HMAC primitive behindHmacVerifier.RetryingTransport— aTransportdecorator: retry transient failures with exponential backoff.IdempotencyStore+IdempotentGateway— dedupe charges by reference (ships an in-memory store; bring your own for production).WebhookHandler— resolve + verify + parse an incoming carrier webhook in one call.
Limitations
Deliberately a toolkit, not a platform:
- No real carrier adapters ship with it. Carrier APIs are proprietary and
under contract.
HttpCarrierGatewaygets a standard one running from config; unusual ones you finish by overriding a method. - Idempotency is opt-in, and the shipped store isn't persistent.
IdempotentGatewaydedupes charges byreference, butInMemoryIdempotencyStoreis single-process — back theIdempotencyStoreinterface with Redis or a unique-indexed table in production. - Retries, not queueing.
RetryingTransporthandles transient failures; durable async work (queues/workers) is still yours to wire.
Development
composer install composer test # pest
License
MIT — see LICENSE.
统计信息
- 总下载量: 1
- 月度下载量: 0
- 日度下载量: 0
- 收藏数: 0
- 点击次数: 1
- 依赖项目数: 0
- 推荐数: 0
其他信息
- 授权协议: MIT
- 更新时间: 2026-06-26