承接 gardi/dcb-kit 相关项目开发

从需求分析到上线部署,全程专人跟进,保证项目质量与交付效率

邮箱:yvsm@zunyunkeji.com | QQ:316430983 | 微信:yvsm316

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 implement Authentication for request signing.
  • Callback verification (Gardi\DcbKit\Verification\*): HmacVerifier (HMAC of the raw body) or NoVerification — or implement CallbackVerifier.
  • 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), or CarrierManager::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 behind HmacVerifier.
  • RetryingTransport — a Transport decorator: 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. HttpCarrierGateway gets 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. IdempotentGateway dedupes charges by reference, but InMemoryIdempotencyStore is single-process — back the IdempotencyStore interface with Redis or a unique-indexed table in production.
  • Retries, not queueing. RetryingTransport handles 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

GitHub 信息

  • Stars: 0
  • Watchers: 0
  • Forks: 0
  • 开发语言: PHP

其他信息

  • 授权协议: MIT
  • 更新时间: 2026-06-26