aliziodev / payid-transactions
Payment transaction ledger and webhook event store for PayID ecosystem.
v0.1.0
2026-04-13 18:36 UTC
Requires
- php: ^8.3
- illuminate/database: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
Requires (Dev)
- laravel/pint: ^1.0
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^3.0|^4.0
- pestphp/pest-plugin-laravel: ^3.0|^4.0
README
Shared payment ledger package for PayID ecosystem.
Requirements
- PHP
^8.3 - Laravel
^12.0|^13.0(illuminate/database,illuminate/support)
CI and Release
- CI workflow runs on push/PR to
main:composer validate --strictcomposer lint-checkcomposer test
- Auto tag release workflow reads version from
composer.jsonat:extra.release.version
- On push to
main(whencomposer.jsonchanges), the workflow:- creates git tag
v<version>if not exists - creates GitHub Release with generated notes
- creates git tag
Release steps for maintainers:
- Bump
extra.release.versionincomposer.json. - Merge/push to
main. - Workflow creates tag and GitHub release automatically.
Scope
- Persist payment transaction lifecycle (
payment_transactions). - Persist webhook delivery and processing trail (
payment_webhook_events). - Provide service contract for recording and updating transaction state.
Schema Notes
- Transaction identity is composite:
provider + merchant_order_id. - If
idempotency_keyis provided, transaction upsert identity becomesprovider + idempotency_key. - Optional polymorphic linkage is available via
subject_type+subject_id. - Webhook table stores replay-safe
event_fingerprintand processing audit fields.
Terminology Glossary
This package uses gateway-neutral naming so one schema can serve multiple providers.
provider: payment gateway identifier (midtrans,stripe,xendit,doku,paddle, etc).merchant_order_id: merchant-side business reference for the payment intent. Common equivalents in other systems:external_id,invoice_number,order_code.provider_transaction_id: gateway-side transaction/reference ID (pi_...,txn_..., etc).idempotency_key: client/service generated deduplication key for retry-safe writes.subject_typeandsubject_id: domain link (similar to polymorphic relation by convention). Examples:order,subscription,marketplace_order,wallet_topup.event_fingerprint: deterministic unique value to detect webhook replay/retry.
Naming Guidelines
Recommended conventions for production teams:
- Keep
providerlowercase slug and stable (stripe, notStripe/STRIPE). - Keep
merchant_order_idimmutable after first successful write. - Use
idempotency_keyfor all external-call retries (charge, confirm, capture, etc). - Use consistent
subject_typevocabulary across services (document in one place). - Put gateway-specific extras in
metadata, keep core columns provider-agnostic.
Gateway Field Mapping (Practical)
- Midtrans:
merchant_order_id<=order_id,provider_transaction_id<=transaction_id. - Stripe:
merchant_order_id<= invoice/external order reference,provider_transaction_id<=payment_intent/chargeID. - Xendit:
merchant_order_id<=external_id,provider_transaction_id<= payment/invoice transaction ID. - DOKU:
merchant_order_id<= merchant invoice/order number,provider_transaction_id<= DOKU transaction reference. - Paddle:
merchant_order_id<= merchant checkout/invoice reference,provider_transaction_id<= Paddle transaction ID.
Non-scope
- Invoice domain model.
- Subscription orchestration.
- Provider API adapter logic.
Install
composer require aliziodev/payid-transactions
Publish
php artisan vendor:publish --tag=payid-transactions-config php artisan vendor:publish --tag=payid-transactions-migrations
Usage
Resolve ledger service from container:
$ledger = app(\Aliziodev\PayIdTransactions\Contracts\TransactionLedger::class); $ledger->upsertStatus([ 'provider' => 'midtrans', 'merchant_order_id' => 'ORDER-1001', 'status' => 'paid', 'amount' => 100000, 'currency' => 'IDR', 'subject_type' => 'subscription', 'subject_id' => '01JABCDEF...', ]);
Prune old webhook audit rows:
php artisan payid-transactions:prune-webhooks --days=90
Real-World Scenarios
The same ledger structure can be used across local and global gateways for ecommerce, subscription billing, marketplace payouts, and digital products.
1) Midtrans - ecommerce checkout (one-time payment)
$ledger->recordChargeAttempt([ 'provider' => 'midtrans', 'merchant_order_id' => 'ECOM-20260413-0001', 'idempotency_key' => 'checkout-user-123-cart-888-v1', 'status' => 'pending', 'amount' => 350000, 'currency' => 'IDR', 'subject_type' => 'order', 'subject_id' => 'ORD-01JXYZ123', 'customer_reference' => 'user123@example.com', 'metadata' => [ 'channel' => 'qris', 'cart_id' => 'CART-888', ], ]); $ledger->upsertStatus([ 'provider' => 'midtrans', 'merchant_order_id' => 'ECOM-20260413-0001', 'provider_transaction_id' => 'trx-9f3c1', 'status' => 'paid', 'amount' => 350000, 'currency' => 'IDR', 'subject_type' => 'order', 'subject_id' => 'ORD-01JXYZ123', ]);
2) Stripe - SaaS subscription renewal
$ledger->recordChargeAttempt([ 'provider' => 'stripe', 'merchant_order_id' => 'INV-2026-05-ACME-01', 'idempotency_key' => 'stripe-sub-renew-sub_01ABC-2026-05', 'status' => 'pending', 'amount' => 199900, 'currency' => 'USD', 'subject_type' => 'subscription', 'subject_id' => 'SUB-01ABC', 'customer_reference' => 'cus_Nx12ABC', 'metadata' => [ 'invoice_id' => 'in_1Px...', 'billing_cycle' => '2026-05', ], ]); $ledger->upsertStatus([ 'provider' => 'stripe', 'merchant_order_id' => 'INV-2026-05-ACME-01', 'provider_transaction_id' => 'pi_3Qx...', 'status' => 'paid', 'amount' => 199900, 'currency' => 'USD', 'subject_type' => 'subscription', 'subject_id' => 'SUB-01ABC', ]);
3) Paddle - digital product/license sale
$ledger->recordChargeAttempt([ 'provider' => 'paddle', 'merchant_order_id' => 'LIC-2026-0042', 'idempotency_key' => 'paddle-checkout-ctm_778-prod_42', 'status' => 'pending', 'amount' => 4900, 'currency' => 'USD', 'subject_type' => 'license_order', 'subject_id' => 'LIC-ORD-42', 'customer_reference' => 'ctm_778', 'metadata' => [ 'product_id' => 'pro_plan', 'license_type' => 'lifetime', ], ]); $ledger->upsertStatus([ 'provider' => 'paddle', 'merchant_order_id' => 'LIC-2026-0042', 'provider_transaction_id' => 'txn_01hxyz...', 'status' => 'paid', 'amount' => 4900, 'currency' => 'USD', 'subject_type' => 'license_order', 'subject_id' => 'LIC-ORD-42', ]);
4) DOKU - local VA payment
$ledger->recordChargeAttempt([ 'provider' => 'doku', 'merchant_order_id' => 'DOKU-ORDER-0099', 'idempotency_key' => 'doku-va-order-99-v1', 'status' => 'pending', 'amount' => 275000, 'currency' => 'IDR', 'subject_type' => 'order', 'subject_id' => 'ORD-0099', 'customer_reference' => 'customer-0099', 'metadata' => [ 'channel' => 'va_bni', 'store' => 'jakarta-01', ], ]); $ledger->upsertStatus([ 'provider' => 'doku', 'merchant_order_id' => 'DOKU-ORDER-0099', 'provider_transaction_id' => 'DOKU-TXN-7788', 'status' => 'paid', 'amount' => 275000, 'currency' => 'IDR', 'subject_type' => 'order', 'subject_id' => 'ORD-0099', ]);
5) Xendit - online store with invoice lifecycle
$ledger->recordChargeAttempt([ 'provider' => 'xendit', 'merchant_order_id' => 'XND-INV-2026-01', 'idempotency_key' => 'xendit-invoice-ext_123-v1', 'status' => 'pending', 'amount' => 850000, 'currency' => 'IDR', 'subject_type' => 'order', 'subject_id' => 'ORD-7788', 'customer_reference' => 'customer@example.com', 'metadata' => [ 'invoice_id' => 'inv-123', 'payment_method' => 'ewallet', ], ]); $ledger->upsertStatus([ 'provider' => 'xendit', 'merchant_order_id' => 'XND-INV-2026-01', 'provider_transaction_id' => 'pay-abc-001', 'status' => 'paid', 'amount' => 850000, 'currency' => 'IDR', 'subject_type' => 'order', 'subject_id' => 'ORD-7788', ]);
Webhook Audit Example
Use webhook event store for retries/replay visibility and processing outcomes:
$event = $ledger->recordWebhookEvent([ 'provider' => 'stripe', 'event_fingerprint' => hash('sha256', 'stripe|evt_123|pi_123'), 'external_event_id' => 'evt_123', 'merchant_order_id' => 'INV-2026-05-ACME-01', 'provider_transaction_id' => 'pi_3Qx...', 'signature_valid' => true, 'payload' => ['type' => 'invoice.paid'], 'received_at' => now(), ]); $ledger->markWebhookProcessed($event, true);
Suggested Subject Mapping
- ecommerce order:
subject_type = order,subject_id = order_id - subscription billing:
subject_type = subscription,subject_id = subscription_id - marketplace order:
subject_type = marketplace_order,subject_id = marketplace_order_id - digital goods/license:
subject_type = license_order,subject_id = license_order_id - wallet top-up:
subject_type = wallet_topup,subject_id = topup_id