myopensoft/ipayment

Laravel integration with JANM iPayment (Sistem Terimaan Elektronik Kerajaan Persekutuan)

Maintainers

Package info

gitlab.com/myopensoft/laravel-ipayment

Homepage

Issues

pkg:composer/myopensoft/ipayment

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

1.0.0 2026-05-28 15:48 UTC

This package is auto-updated.

Last update: 2026-05-29 08:52:35 UTC


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:

Supported transactions

CodeDirectionChannelPurpose
BILEXT001outHTTPS realtimeSubmit / cancel / increase / decrease / update a bill
RECEXT201inHTTPS realtimeiPayment posts a receipt to your callback
RECEXT302inSFTP batchEnd-of-day receipts (GPG-encrypted)
ERROR9999inSFTP batchAsync 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:

CodeEnglish labelConstantCharge lines
01Bill (with amount)INFO_TYPE_BILLMultiple
02Bill without amountINFO_TYPE_BILL_NO_AMOUNTSingle
03Payment without bill or amountINFO_TYPE_PAYMENT_NO_BILL_AMOUNTSingle
04Payment without rate (open amount)INFO_TYPE_PAYMENT_NO_RATEMultiple

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:

ColumnPurpose
directionin / out
process_codeBILEXT001, RECEXT201, RECEXT302, ERROR9999
message_ref40-char nombor_rujukan_message (indexed)
receipt_numbernombor_resit for RECEXT201 / RECEXT302 (indexed)
response_codekod_respond00 / 01 / 02
request_payloadthe JSON envelope sent or received
response_payloadthe JSON sent back, or exception details
source_filebatch filename for SFTP rows
http_statusfor 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