Add Paynet (Moldova) payment gateway

PaymentSettings:
- New "🇲🇩 Paynet" section: enabled toggle, mode (test/live), merchant_code,
  service_id, user, password, secret (HMAC), webhook URL hint
- Webhook URL: https://service.mir.md/payments/paynet/webhook

PaymentController:
- startPaynet() — builds Paynet redirect (stub mode prints flow)
- paynetWebhook() — verifies HMAC-SHA256 signature canonical
  Merchant_Code|Order_ID|Amount|Status, marks subscription paid on Status=OK,
  matches by invoice_number = Order_ID
- availableMethods() includes paynet

Tenant /billing:
- 4th payment button "🇲🇩 Paynet" — visible only when configured.
  Description: Card MAIB / MICB / Victoriabank, MD Cash, e-money

Routes:
- POST /payments/paynet/webhook (CSRF excluded)
This commit is contained in:
2026-05-08 06:20:11 +00:00
parent 827bf12d89
commit 93a69dd826
4 changed files with 141 additions and 3 deletions
+65 -1
View File
@@ -55,7 +55,7 @@ class PaymentController
$method = $request->input('method');
if (! in_array($method, ['stripe', 'paypal', 'bank'], true)) {
if (! in_array($method, ['stripe', 'paypal', 'paynet', 'bank'], true)) {
abort(422, 'Method invalid');
}
@@ -80,6 +80,10 @@ class PaymentController
if ($method === 'paypal') {
return $this->startPaypal($sub);
}
if ($method === 'paynet') {
return $this->startPaynet($sub);
}
}
private function startStripe(Subscription $sub)
@@ -114,6 +118,26 @@ class PaymentController
]);
}
private function startPaynet(Subscription $sub)
{
$paynet = PlatformSetting::get('payments.paynet', []);
if (empty($paynet['enabled']) || empty($paynet['merchant_code']) || empty($paynet['secret'])) {
return back()->with('error', 'Paynet nu este configurat. Contactează operatorul.');
}
$base = $paynet['mode'] === 'live'
? 'https://paynet.md/payment'
: 'https://test.paynet.md/payment';
// Real flow: build POST form to Paynet with HMAC-SHA256 signature.
// Stubbed for now until merchant credentials are set + tested.
return view('site.checkout-stub', [
'gateway' => 'Paynet (' . strtoupper($paynet['mode']) . ')',
'sub' => $sub,
'note' => "În prod: redirect către {$base} cu form ascuns (Merchant_Code, Service_ID, Order_ID={$sub->invoice_number}, Amount, Currency, Notify_URL, Return_URL, semnătură HMAC-SHA256). La success, webhook semnat verifică plata și marchează factura.",
]);
}
public function success(int $subscription)
{
$sub = Subscription::findOrFail($subscription);
@@ -166,6 +190,44 @@ class PaymentController
return response()->json(['ok' => true]);
}
public function paynetWebhook(Request $request)
{
$paynet = PlatformSetting::get('payments.paynet', []);
$secret = $paynet['secret'] ?? null;
if (! $secret) {
Log::warning('Paynet webhook hit but no secret configured');
return response('error', 400);
}
// Paynet sends form-encoded notification. Verify HMAC-SHA256 signature
// computed over the canonical concatenated payload.
$orderId = $request->input('Order_ID'); // matches our invoice_number
$status = $request->input('Status'); // OK / FAIL
$amount = $request->input('Amount');
$signature = $request->input('Signature');
// Canonical signature payload (Merchant_Code|Order_ID|Amount|Status).
$canonical = ($paynet['merchant_code'] ?? '') . '|' . $orderId . '|' . $amount . '|' . $status;
$expected = hash_hmac('sha256', $canonical, $secret);
if (! hash_equals($expected, (string) $signature)) {
Log::warning('Paynet webhook signature mismatch', ['order' => $orderId]);
return response('invalid signature', 400);
}
if ($status !== 'OK') {
return response('ok', 200);
}
$sub = Subscription::where('invoice_number', $orderId)->first();
if ($sub) {
$this->markPaid($sub, 'paynet', $request->input('Reference_ID'));
}
return response('ok', 200);
}
private function markPaid(Subscription $sub, string $method, ?string $reference): void
{
if ($sub->status === 'paid') return;
@@ -188,11 +250,13 @@ class PaymentController
{
$stripe = PlatformSetting::get('payments.stripe', []);
$paypal = PlatformSetting::get('payments.paypal', []);
$paynet = PlatformSetting::get('payments.paynet', []);
$bank = PlatformSetting::get('payments.bank', []);
return [
'stripe' => ! empty($stripe['enabled']) && ! empty($stripe['publishable_key']),
'paypal' => ! empty($paypal['enabled']) && ! empty($paypal['client_id']),
'paynet' => ! empty($paynet['enabled']) && ! empty($paynet['merchant_code']),
'bank' => ! empty($bank['enabled']) && ! empty($bank['iban']),
];
}