Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,126 @@ $status = $pay->verifyPayment("invoice_number","trx_id"); //this will retrieve
</pre>


## Step:3 Merchant IPN (Instant Payment Notification)

After every **successful** transaction Paystation posts a server-to-server notification to your IPN url.
The url is configured per merchant, so you share it with Paystation once - there is no `ipn_url` parameter
on create-payment. Failed, cancelled and pending transactions are never reported here.

The notification carries **no signature and no authentication** (the gateway documents it as `Auth: None`),
so anyone who learns your url can post to it. Treat the body as untrusted input.

### Reading the notification

<pre>

use Xenon\Paystation\Ipn\IpnHandler;
use Xenon\Paystation\Ipn\IpnResponse;
use Xenon\Paystation\Exception\PaystationIpnException;

require 'vendor/autoload.php';

$handler = new IpnHandler();

try {
//reads php://input, or pass the body yourself: $handler->capture($body)
$ipn = $handler->capture();

$order = findOrderByInvoice($ipn->invoiceNumber());

if (!$order) {
//permanently unusable, so acknowledge rather than collect retries
IpnResponse::rejected('unknown invoice')->send();
return;
}

//trx_status === 'Success' and the amount matches what you initiated
$handler->validate($ipn, $order['amount']);

//idempotency is yours: this is the one documented check the library
//cannot do, because it needs your order state
if ($order['status'] === 'paid') {
IpnResponse::acknowledged()->send();
return;
}

markOrderPaid($order, $ipn->trxId(), $ipn->paymentMethod(), $ipn->orderDateTimeString());

IpnResponse::acknowledged()->send();
} catch (PaystationIpnException $e) {
//the exception knows which status keeps the gateway from retrying
IpnResponse::fromException($e)->send();
}
</pre>

### Confirming against the gateway

Because the notification is unsigned, the only check that does not rely on trusting the request is asking
the gateway itself. `confirm()` calls `retrive-transaction` with your own credentials and refuses unless the
transaction exists, succeeded, and carries the same `trx_id` and amount:

<pre>
$paystation = new Paystation([
'merchantId' => 'xxx',
'password' => 'xxxx',
'environment' => 'live',
]);

$handler = new IpnHandler($paystation);

$ipn = $handler->capture();
$handler->validate($ipn, $order['amount']);
$handler->confirm($ipn); //throws PaystationIpnException when not confirmed
</pre>

It costs one round trip, and the documentation asks for a fast acknowledgement - so either use it where a
wrong answer is expensive, or acknowledge first and confirm in a background job.

Optionally restrict by source address. Paystation does not publish its sending ips, so ask them for the
current list first - an out of date allow-list rejects real payments:

<pre>
$handler->trustIps(['203.0.113.7', '198.51.100.0/24']);
$handler->validate($ipn, $order['amount'], $_SERVER['REMOTE_ADDR']);
</pre>

### Notification fields

| Method | Field | Notes |
|---|---|---|
| `invoiceNumber()` | `invoice_number` | your order reference, use it to match the order |
| `trxStatus()` | `trx_status` | always `Success` for this notification |
| `trxId()` | `trx_id` | gateway transaction id, keep it for reconciliation |
| `amount()` | `trx_amount` | returned as `float`, in BDT |
| `orderDateTimeString()` | `order_date_time` | raw, format `Y-m-d H:i:s` |
| `orderDateTime()` | `order_date_time` | `DateTimeImmutable`, or `null` when unparsable |
| `paymentMethod()` | `payment_method` | Nagad, bKash, Rocket, Visa, ... |
| `reference()` | `reference` | optional, `null` when absent |
| `get()` / `toArray()` | any | including fields this library does not know |
| `isSuccess()` | - | `trx_status` is `Success`, trimmed and case insensitive |
| `matchesAmount($expected)` | - | numeric comparison with tolerance, not string equality |
| `loggable()` | - | the fields worth putting in a log line |

The gateway masks `trx_id` as `****` in the documentation examples; a real notification carries the actual id.

### Answering the gateway

The status code is the whole protocol: Paystation stops on `2xx` and retries on `4xx`, `5xx` and timeouts.

| Helper | Status | Meaning |
|---|---|---|
| `IpnResponse::acknowledged()` | 200 | processed, or already processed - no further attempt |
| `IpnResponse::retry($reason)` | 503 | your side failed, please deliver again |
| `IpnResponse::rejected($reason)` | 200 | permanently unusable, stop retrying |
| `IpnResponse::fromException($e)` | from the exception | the status that fits the failure |

`send()` sets the status, the json content type and echoes the body. It deliberately does **not** call `exit`,
so work you deferred until after acknowledging still runs. In a framework, use `statusCode()` and `body()`
and build your own response instead.

`rejected()` answers `200` on purpose - a body that can never be processed should not be redelivered for the
rest of the retry window. Pass a 4xx yourself if you would rather the gateway kept trying.

#### Important Methods
* setPaymentParams()
* payNow()
Expand All @@ -130,6 +250,7 @@ $status = $pay->verifyPayment("invoice_number","trx_id"); //this will retrieve
* getEnvironment()
* getBaseUrl()
* isSandbox()
* IpnHandler::capture(), validate(), confirm(), trustIps()

This library is still in beta version and if you are interested to contribute this , we highly encourage you. Make a fork of this repository
and give send a pull request. If you face any issues or error during development or after deployment, you should crate an issue
Expand Down
2 changes: 1 addition & 1 deletion src/Exception/PaystationException.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ class PaystationException extends \Exception
*
* @return void
*/
public function __construct($message, $code = 0, Throwable $previous = null)
public function __construct($message, $code = 0, ?Throwable $previous = null)
{
// Call the parent constructor
parent::__construct($message, $code, $previous);
Expand Down
188 changes: 188 additions & 0 deletions src/Exception/PaystationIpnException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
<?php

namespace Xenon\Paystation\Exception;

use Throwable;

/**
* Raised when an incoming IPN cannot be trusted.
*
* Every instance carries the HTTP status the merchant should answer the gateway
* with, because that status decides whether the notification is retried:
* Paystation retries on 4xx, 5xx and timeouts, and stops on 2xx.
*
* @see https://www.paystation.com.bd/documentation (Merchant IPN)
*/
class PaystationIpnException extends PaystationException
{
/**
* Status the merchant endpoint should respond with.
*/
private $suggestedHttpStatus = 400;

/**
* Whether answering with that status invites the gateway to try again.
*/
private $retryable = false;

/**
* Field the failure is about, when it is about one.
*
* @var string|null
*/
private $field;

public function __construct($message, $code = 0, ?Throwable $previous = null)
{
parent::__construct($message, $code, $previous);
}

/**
* The body was not valid json, so nothing can be read out of it.
*
* Not retryable: the gateway would only send the same bytes again.
*/
public static function malformedJson(string $reason): self
{
return self::make(
'Paystation IPN body is not valid json: ' . $reason,
400,
false
);
}

/**
* Valid json, but not the flat object the documentation describes.
*/
public static function notAnObject(string $type): self
{
return self::make(
'Paystation IPN body must be a flat json object, ' . $type . ' decoded.',
400,
false
);
}

public static function missingField(string $field): self
{
$exception = self::make(
"Paystation IPN is missing the required field '$field'.",
400,
false
);
$exception->field = $field;

return $exception;
}

public static function invalidField(string $field, string $expected): self
{
$exception = self::make(
"Paystation IPN field '$field' is invalid; expected $expected.",
400,
false
);
$exception->field = $field;

return $exception;
}

/**
* The request did not come from an address the merchant allow-listed.
*
* 403 and not retryable: retrying from the same address would be refused
* again, and a forged request should not be handed a retry schedule.
*/
public static function untrustedSource(?string $ip): self
{
return self::make(
'Paystation IPN arrived from untrusted address ' . ($ip === null ? '(unknown)' : $ip) . '.',
403,
false
);
}

/**
* trx_status was not 'Success'. Per the documentation this IPN only fires
* for successful transactions, so anything else is unexpected.
*/
public static function notSuccessful(string $status): self
{
$exception = self::make(
"Paystation IPN reports trx_status '$status'; only 'Success' is sent for this notification.",
422,
false
);
$exception->field = 'trx_status';

return $exception;
}

/**
* The notified amount is not the amount the merchant initiated.
*/
public static function amountMismatch($expected, $received): self
{
$exception = self::make(
'Paystation IPN amount ' . var_export($received, true)
. ' does not match the expected ' . var_export($expected, true) . '.',
422,
false
);
$exception->field = 'trx_amount';

return $exception;
}

/**
* The gateway itself did not confirm the transaction when asked.
*
* Retryable: a confirmation call can fail for reasons that pass later, such
* as the transaction not being queryable the instant the IPN is delivered.
*/
public static function unconfirmed(string $detail): self
{
return self::make(
'Paystation could not confirm the notified transaction: ' . $detail,
503,
true
);
}

private static function make(string $message, int $status, bool $retryable): self
{
$exception = new self($message);
$exception->suggestedHttpStatus = $status;
$exception->retryable = $retryable;

return $exception;
}

/**
* Status to answer the gateway with.
*/
public function suggestedHttpStatus(): int
{
return $this->suggestedHttpStatus;
}

/**
* Whether the suggested status asks the gateway to deliver again.
*
* A permanently bad notification is better answered with a status that
* stops the retries, so the merchant is not woken by the same broken
* payload for the rest of the retry window.
*/
public function isRetryable(): bool
{
return $this->retryable;
}

/**
* @return string|null
*/
public function field()
{
return $this->field;
}
}
2 changes: 1 addition & 1 deletion src/Exception/PaystationPaymentParameterException.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ class PaystationPaymentParameterException extends \Exception
*
* @return void
*/
public function __construct($message, $code = 0, Throwable $previous = null)
public function __construct($message, $code = 0, ?Throwable $previous = null)
{
// Call the parent constructor
parent::__construct($message, $code, $previous);
Expand Down
Loading
Loading