myopensoft / ipayment
Laravel integration with JANM iPayment (Sistem Terimaan Elektronik Kerajaan Persekutuan)
Requires
- php: ^8.3
- illuminate/contracts: ^11.0||^12.0||^13.0
- league/flysystem-sftp-v3: ^3.0
- myopensoft/runner: *@dev
- spatie/laravel-package-tools: ^1.16
- symfony/process: ^6.0||^7.0
Requires (Dev)
- larastan/larastan: ^2.9||^3.0
- laravel/pint: ^1.14
- nunomaduro/collision: ^8.1.1
- orchestra/testbench: ^9.0||^10.0||^11.0
- pestphp/pest: ^3.0
- pestphp/pest-plugin-arch: ^3.0
- pestphp/pest-plugin-laravel: ^3.0
- phpstan/extension-installer: ^1.3
- phpstan/phpstan-deprecation-rules: ^1.1||^2.0
- phpstan/phpstan-phpunit: ^1.3||^2.0
README
Laravel integration with JANM's iPayment (Sistem Terimaan Elektronik Kerajaan Persekutuan).
The package owns the conversation with iPayment — envelopes, retries, SFTP, GPG, audit log, idempotency. Your application owns the domain — bills, payments, reconciliation. They meet at events and DTOs.
For the full picture see:
docs/architecture.md— cross-cutting: boundaries, audit log, configuration, testing, operationsdocs/protocols/BILEXT001.md— outgoing bill submissiondocs/protocols/RECEXT201.md— incoming receipt callbackdocs/protocols/RECEXT302.md— batch receipt reportdocs/protocols/ERROR9999.md— error notifications
Supported transactions
| Code | Direction | Channel | Purpose |
|---|---|---|---|
| BILEXT001 | out | HTTPS realtime | Submit / cancel / increase / decrease / update a bill |
| RECEXT201 | in | HTTPS realtime | iPayment posts a receipt to your callback |
| RECEXT302 | in | SFTP batch | End-of-day receipts (GPG-encrypted) |
| ERROR9999 | in | SFTP batch | Async error notifications (GPG-encrypted) |
Architecture at a glance
flowchart LR
H[Your Laravel app] -->|sendBill| F[Ipayment facade]
F --> AC[ApiClient]
AC -->|POST BILEXT001| API[(JANM API)]
AC --> AL[(ipayment_messages<br/>audit log)]
API -->|POST RECEXT201| CB[ReceiptCallbackController]
CB --> AL
CB -. ReceiptReceived .-> H
SFTP[(JANM SFTP)] -->|.gpg files| BP[BatchPuller]
BP --> AL
BP -. BatchReceiptParsed / IpaymentErrorReceived .-> H
The host application listens to events and writes to its own tables; it never has to know about envelopes, GPG, or pipe delimiters.
Installation
composer require myopensoft/ipayment
php artisan vendor:publish --tag=ipayment-config
php artisan migrate
Optional models (only if your app does not already model bills / payments):
php artisan vendor:publish --tag=ipayment-models
php artisan migrate
Configuration
Required .env keys:
IPAYMENT_AGENCY_CODE=0100010001
IPAYMENT_SERVICE_CODE=S00001
IPAYMENT_ENV=sit
IPAYMENT_API_URL=https://ipayment.test/api
IPAYMENT_API_USER=...
IPAYMENT_API_PASS=...
IPAYMENT_SFTP_HOST=esb-ipayment.anm.gov.my
IPAYMENT_SFTP_PORT=2222
IPAYMENT_SFTP_USER=...
IPAYMENT_SFTP_KEY=/path/to/private_key
IPAYMENT_GPG_HOME=/path/to/gnupg-home
IPAYMENT_GPG_RECIPIENT=ipayment@anm.gov.my
IPAYMENT_GPG_SIGNING_KEY=your-key-id
IPAYMENT_GPG_PASSPHRASE=...
Verify everything is wired:
php artisan ipayment:check
Sending a bill (BILEXT001)
use Carbon\Carbon;
use MyOpensoft\Ipayment\Data\AddressDto;
use MyOpensoft\Ipayment\Data\BillDto;
use MyOpensoft\Ipayment\Data\ChargeLineDto;
use MyOpensoft\Ipayment\Data\CustomerDto;
use MyOpensoft\Ipayment\Facades\Ipayment;
$bill = BillDto::newBill(
reference: 'INV-2026-0001',
amount: 150.00,
billDate: Carbon::now(),
customer: CustomerDto::individual(
ic: '900101011234',
name: 'Ahmad Bin Ali',
address: new AddressDto('No 1 Jln A', null, null, '50100', '14', 'MY'),
phone: '0123456789',
email: 'ahmad@example.com',
),
chargeLines: [
ChargeLineDto::make(
classificationCode: 'INV_H0161199',
ptjGroup: '28020101',
voteFund: 'B1024',
programActivity: 'B10010104',
accountCode: 'H0171101',
amount: 150.00,
),
],
);
$response = Ipayment::sendBill($bill);
if ($response->isSuccess()) {
// Persist $response->messageRef alongside your bill.
}
The bill carries a jenis_maklumat_terimaan ("info type") code which
describes what kind of charge it represents. BillDto::newBill()
defaults to "01" — a fixed-amount bill with one or more charge lines.
The other three info types are encoded as constants on BillDto:
| Code | English label | Constant | Charge lines |
|---|---|---|---|
01 | Bill (with amount) | INFO_TYPE_BILL | Multiple |
02 | Bill without amount | INFO_TYPE_BILL_NO_AMOUNT | Single |
03 | Payment without bill or amount | INFO_TYPE_PAYMENT_NO_BILL_AMOUNT | Single |
04 | Payment without rate (open amount) | INFO_TYPE_PAYMENT_NO_RATE | Multiple |
Mutations are first-class — each returns an ApiResponseDto:
Ipayment::cancelBill('INV-2026-0001', 'Paid at counter', now());
Ipayment::addBillAmount('INV-2026-0001', 25.00, now(), 'Surcharge');
Ipayment::reduceBillAmount('INV-2026-0001', 10.00, now(), 'Adjustment');
Ipayment::updateBill($bill);
Listen for outcomes:
use MyOpensoft\Ipayment\Events\BillSent;
use MyOpensoft\Ipayment\Events\BillSendFailed;
Event::listen(BillSent::class, fn ($e) => /* mark sent */ null);
Event::listen(BillSendFailed::class, fn ($e) => /* alert / retry queue */ null);
Receiving receipts (RECEXT201)
The package registers POST /ipayment/receipt (route prefix configurable).
Listen for the event and write to your own tables:
use MyOpensoft\Ipayment\Events\ReceiptReceived;
Event::listen(ReceiptReceived::class, function (ReceiptReceived $e) {
Payment::create([
'gateway' => 'ipayment',
'reference' => $e->receipt->billReference,
'receipt_number' => $e->receipt->receiptNumber,
'amount' => $e->receipt->amount,
'payment_mode' => $e->receipt->paymentMode,
// ...
]);
});
Lock the callback down via middleware:
// config/ipayment.php
'callback' => [
'prefix' => 'ipayment',
'middleware' => ['api', 'auth.basic'],
],
Idempotency
JANM may retry callbacks. To dedupe, point the package at a resolver:
'receipt_resolver' => \MyOpensoft\Ipayment\Support\AuditLogReceiptResolver::class,
The bundled resolver checks the audit log for a settled (response_code='00')
prior row with the same message_ref or receipt_number. Write your
own implementing MyOpensoft\Ipayment\Contracts\ReceiptResolver to
consult your domain tables instead.
Batch SFTP (RECEXT302, ERROR9999)
Schedule the pull (typical cadence: every 15 minutes):
// app/Console/Kernel.php
$schedule->command('ipayment:pull-batch')->everyFifteenMinutes();
Then listen for the per-row events:
use MyOpensoft\Ipayment\Events\BatchReceiptParsed;
use MyOpensoft\Ipayment\Events\IpaymentErrorReceived;
use MyOpensoft\Ipayment\Events\BatchFileProcessed;
Event::listen(BatchReceiptParsed::class, fn ($e) => /* reconcile */);
Event::listen(IpaymentErrorReceived::class, fn ($e) => /* alert */);
Event::listen(BatchFileProcessed::class, fn ($e) => /* metrics */);
Decrypted files are archived under storage/app/ipayment/archive;
files that fail to decrypt or parse go to quarantine/.
Audit log
Every message the package sends or receives is written to
ipayment_messages before any event fires. Schema:
| Column | Purpose |
|---|---|
direction | in / out |
process_code | BILEXT001, RECEXT201, RECEXT302, ERROR9999 |
message_ref | 40-char nombor_rujukan_message (indexed) |
receipt_number | nombor_resit for RECEXT201 / RECEXT302 (indexed) |
response_code | kod_respond — 00 / 01 / 02 |
request_payload | the JSON envelope sent or received |
response_payload | the JSON sent back, or exception details |
source_file | batch filename for SFTP rows |
http_status | for outgoing rows |
This table is the single source of truth for "did we see this message" and powers the default idempotency resolver.
Host responsibilities (out of scope)
- Master data — services, charge lines, PTJ groups, vot/dana mapping
- Bill / payment / receipt domain model (or use the optional models)
- Reconciliation policy
- IP whitelisting, SFTP key exchange, GPG key exchange (operational)
- Scheduling
ipayment:pull-batch
Language
The iPayment protocol's JSON keys are in Malay (mesej, kod_proses,
kod_respond, maklumat_resit, …) and the package keeps them that way
on the wire — that's not negotiable. Everything else — PHP
identifiers, audit log columns, status enum values — is English.
License
MIT