birkof/netopia-mobilpay-bundle
A thin, typed Symfony integration layer over the birkof/netopia-mobilpay low-level SDK. It wires the SDK into the Symfony service container and exposes two responsibilities of the Netopia (MobilPay) card-payment protocol:
<crc> acknowledgement it expects.The bundle holds no state, no persistence, and no HTTP controllers. It builds request objects and parses notifications; the consuming application owns routing, fulfilment, and storage.
Source of truth for this document: the files under
src/(14 PHP files, ~1090 LOC),composer.json,phpunit.xml.dist, and.github/workflows/ci.yml. Usage examples live insrc/Resources/doc/index.md.
From composer.json:
| Dependency | Constraint | Role |
|---|---|---|
php |
^8.3 |
runtime |
birkof/netopia-mobilpay |
^4.0 |
low-level Netopia SDK (Mobilpay\…): crypto + XML protocol |
symfony/routing |
^4.4 || ^5.0 || ^6.0 |
generates the absolute confirm/return URLs |
symfony/yaml |
^4.4 || ^5.0 || ^6.0 |
loads Resources/config/services.yaml |
symfony/monolog-bundle |
^3.7 |
provides the logger service injected into both services |
phpunit/phpunit (dev) |
^11.5 |
test suite |
Autoload (PSR‑4): birkof\NetopiaMobilPay\ → src/.
composer require birkof/netopia-mobilpay-bundle
With Symfony Flex this is automatic. Otherwise add it to config/bundles.php:
// config/bundles.php
return [
// ...
birkof\NetopiaMobilPay\NetopiaMobilPayBundle::class => ['all' => true],
];
signature is required — the bundle fails to boot without it. payment_url,
public_cert and private_key are optional (defaults: sandbox URL, null, null).
Both public_cert and private_key accept either a file path (resolved relative to
%kernel.project_dir%) or the inline PEM content.
# config/packages/netopia_mobilpay.yaml
netopia_mobilpay:
payment_url: '%env(NETOPIA_MOBILPAY_PAYMENT_URL)%' # e.g. https://secure.mobilpay.ro (prod)
public_cert: '%env(NETOPIA_MOBILPAY_PUBLIC_CERT)%' # path or PEM — seals outbound requests
private_key: '%env(NETOPIA_MOBILPAY_PRIVATE_KEY)%' # path or PEM — opens inbound IPNs
signature: '%env(NETOPIA_MOBILPAY_SIGNATURE)%' # REQUIRED
# .env (or .env.local)
NETOPIA_MOBILPAY_PAYMENT_URL=http://sandboxsecure.mobilpay.ro
NETOPIA_MOBILPAY_PUBLIC_CERT=%kernel.project_dir%/config/netopia/sandbox.public.cer
NETOPIA_MOBILPAY_PRIVATE_KEY=%kernel.project_dir%/config/netopia/sandbox.private.key
NETOPIA_MOBILPAY_SIGNATURE=XXXX-XXXX-XXXX-XXXX-XXXX
The bundle generates absolute URLs from two route names it expects you to define
(NetopiaMobilPayConfiguration::CONFIRM_URL / ::RETURN_URL). These names are mandatory —
missing them breaks URL generation at container build time:
# config/routes.yaml
netopia_mobilpay_confirm_url: # server-to-server IPN endpoint
path: /payment/netopia/confirm
controller: App\Controller\PaymentController::ipn
methods: [POST]
netopia_mobilpay_return_url: # browser lands here after the gateway
path: /payment/netopia/return
controller: App\Controller\PaymentController::return
Both entry points are autowired by their interface:
public function __construct(
private NetopiaMobilPayServiceInterface $payments, // outbound — build a request
private NetopiaMobilPayIpnHandlerInterface $ipn, // inbound — handle the callback
) {}
createCreditCardPaymentObject() validates the input, builds and RSA‑seals the request,
and returns the SDK request object exposing the sealed env_key / data (and cipher / iv
for block ciphers). Render an auto‑submitting form that POSTs them to the gateway URL.
use birkof\NetopiaMobilPay\Configuration\NetopiaMobilPayConfiguration;
use birkof\NetopiaMobilPay\Service\NetopiaMobilPayServiceInterface;
use Symfony\Component\HttpFoundation\Response;
public function checkout(NetopiaMobilPayServiceInterface $payments): Response
{
$request = $payments->createCreditCardPaymentObject(
'ORDER-1001', // orderId (required, non-empty)
'49.99', // amount (required, positive numeric)
NetopiaMobilPayConfiguration::CURRENCY_RON, // currency (RON | EUR | USD)
'Order #1001', // details
[ // billing address (optional)
'type' => 'person',
'firstName' => 'Ion',
'lastName' => 'Popescu',
'address' => 'Str. Exemplu 1',
'email' => 'ion@example.com',
'mobilePhone' => '0700000000',
],
// [], shipping [], creditCard [], extraParameters (token payments)
);
return $this->render('payment/redirect.html.twig', [
'paymentUrl' => $payments->getMobilPayConfiguration()->getPaymentUrl(),
'envKey' => $request->getEnvKey(),
'data' => $request->getEncData(),
'cipher' => $request->getCipher(),
'iv' => $request->getIv(),
]);
}
{# templates/payment/redirect.html.twig — auto-submits to Netopia #}
<form id="netopia" method="post" action="{{ paymentUrl }}">
<input type="hidden" name="env_key" value="{{ envKey }}">
<input type="hidden" name="data" value="{{ data }}">
{% if cipher %}<input type="hidden" name="cipher" value="{{ cipher }}">{% endif %}
{% if iv %}<input type="hidden" name="iv" value="{{ iv }}">{% endif %}
</form>
<script>document.getElementById('netopia').submit();</script>
PCI note: leave the
creditCardargument empty and let the gateway's hosted page collect the card details. Passing a raw PAN/CVV through your server places the whole application in PCI‑DSS SAQ‑D scope.
Netopia POSTs the encrypted notification to your netopia_mobilpay_confirm_url route.
Decrypt it, verify the amount/order yourself, then acknowledge with <crc>:
use birkof\NetopiaMobilPay\Exception\NetopiaMobilPayException;
use birkof\NetopiaMobilPay\Notification\NetopiaMobilPayIpnHandlerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
public function ipn(Request $request, NetopiaMobilPayIpnHandlerInterface $ipn): Response
{
try {
$result = $ipn->decrypt(
(string) $request->request->get('env_key'),
(string) $request->request->get('data'),
$request->request->get('cipher'), // null for legacy RC4 payloads
$request->request->get('iv'), // null for legacy RC4 payloads
);
} catch (NetopiaMobilPayException $e) {
// transient failure on our side → ask Netopia to retry
return new Response(
$ipn->errorResponse('cannot process', $ipn::ERROR_TYPE_TEMPORARY),
200, ['Content-Type' => 'application/xml'],
);
}
if ($result->isConfirmed()) {
// SECURITY: decrypt() proves the sender is Netopia, NOT the amount.
// Load the order by $result->purchaseId and confirm $result->processedAmount
// matches what you expected BEFORE marking it paid.
}
return new Response(
$ipn->confirmResponse(),
200, ['Content-Type' => 'application/xml'],
);
}
IpnResult predicates: isConfirmed(), isPaid(), isPending(), isCanceled(), isError().
See src/Resources/doc/index.md for the full integration guide.
Symfony application
│
┌─────────────────────┼──────────────────────┐
│ outbound │ inbound │
▼ │ ▼
NetopiaMobilPayService │ NetopiaMobilPayIpnHandler
(netopia_mobilpay. │ (netopia_mobilpay.
payment) │ ipn_handler)
│ │ │
│ both depend on ▼ │
│ NetopiaMobilPayConfiguration ◄───────┘
│ (netopia_mobilpay.configuration, private)
│ · signature, certs, payment URL, routes
▼ ▼
birkof/netopia-mobilpay (Mobilpay\…)
Request\Card / Request\Sms Request\RequestAbstract::factoryFromEncrypted
::encrypt() ──► openssl_seal ::decrypt ──► openssl_open ──► Request\Notify
The SDK does the cryptography and XML; this bundle adapts it to Symfony DI and gives the inbound side a typed, vendor‑agnostic result.
Grouped by directory under src/:
| File | Responsibility |
|---|---|
NetopiaMobilPayBundle.php |
The bundle. const ALIAS = 'netopia_mobilpay', const VERSION = '1.4.0'; returns NetopiaMobilPayExtension from getContainerExtension(). |
DependencyInjection/Configuration.php |
Config tree (getConfigTreeBuilder): nodes payment_url, public_cert, private_key, signature. signature is isRequired()->cannotBeEmpty() — a missing signature fails config processing rather than booting with a placeholder. |
DependencyInjection/NetopiaMobilPayExtension.php |
Loads services.yaml, processes config, and registers the service graph (below). inflateServicesInConfig() turns any @service‑prefixed config string into a Reference. Secrets are passed to the configuration object only via method calls — they are never written to container parameters (which Symfony would dump to the compiled‑container cache in cleartext). |
Service graph built by the extension:
netopia_mobilpay.configuration (NetopiaMobilPayConfiguration, private)
← Reference('router')
← setPaymentUrl, setProjectDir('%kernel.project_dir%'),
setPublicCert, setPrivateKey, setSignature
│
├─► netopia_mobilpay.payment (NetopiaMobilPayService, public)
│ ← configuration, router, logger
│
└─► netopia_mobilpay.ipn_handler (NetopiaMobilPayIpnHandler, public)
← configuration, logger
Resources/config/services.yaml aliases each interface to its service for autowiring:
NetopiaMobilPayServiceInterface → netopia_mobilpay.payment,
NetopiaMobilPayIpnHandlerInterface → netopia_mobilpay.ipn_handler.
Configuration/NetopiaMobilPayConfiguration.php — runtime holder for the gateway settings, built once and shared by both services.
public_cert / private_key as either a file path (resolved under %kernel.project_dir%) or the literal PEM content (setPublicCert/setPrivateKey fall back to the raw value when it is not a readable file).netopia_mobilpay_confirm_url (const CONFIRM_URL)netopia_mobilpay_return_url (const RETURN_URL)resolvePaymentUrl(bool $useTokenEndpoint) derives the per‑request endpoint from an immutable base URL: the base for a normal payment, base + '/card4' for a token payment. The base is never mutated, so repeated/token calls cannot accumulate /card4 or leak across requests on long‑running workers.CURRENCY_RON, CURRENCY_EUR, CURRENCY_USD.Service/NetopiaMobilPayServiceInterface.php + NetopiaMobilPayService.php.
createCreditCardPaymentObject($orderId, $amount, $currency, $details, $billingAddress, $shippingAddress, $creditCard, $extraParameters):
orderId (non‑empty), amount (positive numeric) and currency (one of the CURRENCY_* constants) before the try/catch, so a specific input error is not masked;Mobilpay\Payment\Request\Card — signature, confirmUrl, returnUrl, a Mobilpay\Payment\Invoice (currency/amount/details), optional billing/shipping Mobilpay\Payment\Address, optional Mobilpay\Payment\Instrument\Card, and token extraParameters;->encrypt($publicCert) (RSA envelope) and returns the request object.createSmsPaymentObject($orderId, $serviceId) — the SMS‑payment equivalent.composeAddressObject() sets only the fields the SDK's Address actually serializes (type, firstName, lastName, address, email, mobilePhone).composeCreditCardObject() builds a raw‑PAN CardInstrument. Its docblock warns that this server‑side card path places the application in PCI‑DSS SAQ‑D scope; the hosted payment page (leave $creditCard empty) is the recommended flow.The returned request object exposes the sealed env_key / data; the application renders an auto‑submitting HTML form POSTing them to getPaymentUrl().
src/Notification/)| File | Responsibility |
|---|---|
IpnAction.php |
Backed enum IpnAction: string of gateway actions (confirmed, confirmed_pending, paid_pending, paid, canceled, credit) plus an Unknown fallback. fromValue(?string) maps unrecognized/null to Unknown (forward‑compatible — never throws on a new action). |
IpnResult.php |
final readonly DTO — an immutable, vendor‑agnostic view of a decrypted notification. fromNotify(Notify) maps the SDK object (money kept as string, errorCode cast to int). Predicates: isError(), isConfirmed(), isPaid(), isPending(), isCanceled(). |
NetopiaMobilPayIpnHandlerInterface.php / NetopiaMobilPayIpnHandler.php |
decrypt(string $envKey, string $encData, ?string $cipher = null, ?string $iv = null): IpnResult opens the envelope via RequestAbstract::factoryFromEncrypted() using the configured private key, then returns IpnResult::fromNotify(). confirmResponse() / errorResponse($message, $errorType, $errorCode) build the <crc> reply with DOMDocument (values XML‑escaped). Constants ERROR_TYPE_TEMPORARY = 1 (gateway retries) and ERROR_TYPE_PERMANENT = 2. Decrypt failures log only the error code (never the vendor message, key material, or ciphertext) and surface a generic NetopiaMobilPayException. |
Exception/NetopiaMobilPayException.php — the single extends \Exception type thrown by the bundle (invalid input, failed encryption, failed/empty/garbage IPN payload).
All src/ classes declare declare(strict_types=1).
Outbound (start a payment):
app ──► NetopiaMobilPayServiceInterface::createCreditCardPaymentObject(...)
→ validate input → build Card(Invoice, [Address], [CardInstrument], [token])
→ resolvePaymentUrl() → Card::encrypt(public_cert) [openssl_seal]
◄ returns sealed request (env_key + data)
app renders auto-submit <form> POST → getPaymentUrl() ──► Netopia hosted page
Inbound (gateway confirmation / IPN):
Netopia ──POST env_key,data[,cipher,iv]──► your route `netopia_mobilpay_confirm_url`
your controller ──► NetopiaMobilPayIpnHandlerInterface::decrypt(...)
factoryFromEncrypted(private_key) [openssl_open] → Notify → IpnResult
your controller verifies purchaseId + processedAmount against YOUR order, fulfils,
then returns confirmResponse() ──► "<crc></crc>"
(on a transient failure: errorResponse(..., ERROR_TYPE_TEMPORARY) → gateway retries)
openssl_open it. A successful decrypt() therefore authenticates the sender — there is no separate signature on the callback.IpnResult::$purchaseId against a real, unfulfilled order and confirm IpnResult::$processedAmount before fulfilling. This boundary is documented in src/Resources/doc/index.md.private_key/signature to the configuration object via method calls, not container parameters.src/
├── NetopiaMobilPayBundle.php # bundle entry point
├── Configuration/
│ └── NetopiaMobilPayConfiguration.php # runtime settings value object
├── DependencyInjection/
│ ├── Configuration.php # config tree (signature required)
│ └── NetopiaMobilPayExtension.php # service graph wiring
├── Exception/
│ └── NetopiaMobilPayException.php
├── Notification/ # inbound IPN
│ ├── IpnAction.php
│ ├── IpnResult.php
│ ├── NetopiaMobilPayIpnHandlerInterface.php
│ └── NetopiaMobilPayIpnHandler.php
├── Service/ # outbound requests
│ ├── NetopiaMobilPayServiceInterface.php
│ └── NetopiaMobilPayService.php
└── Resources/
├── config/services.yaml # interface → service aliases
└── doc/index.md # usage / integration guide
tests/ # mirrors src/ (Configuration, DependencyInjection,
# Notification, Service)
Defined in config/packages/netopia_mobilpay.yaml (see src/Resources/doc/index.md for full examples):
| Key | Required | Default | Notes |
|---|---|---|---|
signature |
yes | — | merchant signature; processing fails if absent |
payment_url |
no | http://sandboxsecure.mobilpay.ro |
gateway base URL (set the production URL in prod) |
public_cert |
no | null |
file path or inline PEM; used to seal outbound requests |
private_key |
no | null |
file path or inline PEM; used to open inbound IPNs |
The application must also define two routes the configuration generates URLs for: netopia_mobilpay_confirm_url (server‑to‑server IPN) and netopia_mobilpay_return_url (browser return).
Inject by interface:
public function __construct(
private NetopiaMobilPayServiceInterface $payments, // outbound
private NetopiaMobilPayIpnHandlerInterface $ipn, // inbound
) {}
phpunit.xml.dist): bootstraps vendor/autoload.php, runs the tests/ suite, declares src/ as the coverage source, and is strict — failOnWarning="true", failOnRisky="true". Run with vendor/bin/phpunit (or composer test).src/ (config tree, DI wiring, value object, outbound service, IPN enum/DTO/handler). The IPN handler test performs a real openssl_seal → decrypt round‑trip with a generated key pair..github/workflows/ci.yml): on push to main and on pull requests; matrix PHP 8.3 and 8.4 (extensions openssl, mbstring, dom); steps: composer validate --strict, install, vendor/bin/phpunit, and a non‑blocking composer audit.MIT — see LICENSE.md.
How can I help you explore Laravel packages today?