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:
@@ -27,13 +27,14 @@ class PaymentSettings extends Page
|
|||||||
public function mount(): void
|
public function mount(): void
|
||||||
{
|
{
|
||||||
$s = PlatformSetting::many([
|
$s = PlatformSetting::many([
|
||||||
'payments.stripe', 'payments.paypal', 'payments.bank',
|
'payments.stripe', 'payments.paypal', 'payments.paynet', 'payments.bank',
|
||||||
'payments.platform_currency', 'payments.invoice_prefix',
|
'payments.platform_currency', 'payments.invoice_prefix',
|
||||||
'payments.terms', 'payments.company_legal',
|
'payments.terms', 'payments.company_legal',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$stripe = $s['payments.stripe'] ?? [];
|
$stripe = $s['payments.stripe'] ?? [];
|
||||||
$paypal = $s['payments.paypal'] ?? [];
|
$paypal = $s['payments.paypal'] ?? [];
|
||||||
|
$paynet = $s['payments.paynet'] ?? [];
|
||||||
$bank = $s['payments.bank'] ?? [];
|
$bank = $s['payments.bank'] ?? [];
|
||||||
$legal = $s['payments.company_legal'] ?? [];
|
$legal = $s['payments.company_legal'] ?? [];
|
||||||
|
|
||||||
@@ -59,6 +60,14 @@ class PaymentSettings extends Page
|
|||||||
'paypal_client_id' => $paypal['client_id'] ?? null,
|
'paypal_client_id' => $paypal['client_id'] ?? null,
|
||||||
'paypal_secret' => $paypal['secret'] ?? null,
|
'paypal_secret' => $paypal['secret'] ?? null,
|
||||||
|
|
||||||
|
'paynet_enabled' => $paynet['enabled'] ?? false,
|
||||||
|
'paynet_mode' => $paynet['mode'] ?? 'test',
|
||||||
|
'paynet_merchant_code' => $paynet['merchant_code'] ?? null,
|
||||||
|
'paynet_service_id' => $paynet['service_id'] ?? null,
|
||||||
|
'paynet_user' => $paynet['user'] ?? null,
|
||||||
|
'paynet_password' => $paynet['password'] ?? null,
|
||||||
|
'paynet_secret' => $paynet['secret'] ?? null,
|
||||||
|
|
||||||
'bank_enabled' => $bank['enabled'] ?? true,
|
'bank_enabled' => $bank['enabled'] ?? true,
|
||||||
'bank_iban' => $bank['iban'] ?? null,
|
'bank_iban' => $bank['iban'] ?? null,
|
||||||
'bank_bic' => $bank['bic'] ?? null,
|
'bank_bic' => $bank['bic'] ?? null,
|
||||||
@@ -141,6 +150,43 @@ class PaymentSettings extends Page
|
|||||||
->label('Secret')->password()->revealable()->placeholder('EJk...')
|
->label('Secret')->password()->revealable()->placeholder('EJk...')
|
||||||
->visible(fn (Get $get) => $get('paypal_enabled')),
|
->visible(fn (Get $get) => $get('paypal_enabled')),
|
||||||
]),
|
]),
|
||||||
|
Schemas\Components\Section::make('🇲🇩 Paynet (Moldova)')
|
||||||
|
->description('Procesator de plăți Moldova. Acceptă carduri MAIB, MICB, Victoriabank, OTP, MD Cash, Bitcoin, e-money. Comision negociabil ~1.8-2.5%.')
|
||||||
|
->columns(3)
|
||||||
|
->collapsible()
|
||||||
|
->collapsed(fn (Get $get) => ! $get('paynet_enabled'))
|
||||||
|
->schema([
|
||||||
|
Forms\Components\Toggle::make('paynet_enabled')->label('Activează Paynet')->default(false)->live(),
|
||||||
|
Forms\Components\Select::make('paynet_mode')
|
||||||
|
->label('Mod')
|
||||||
|
->options(['test' => '🧪 Test', 'live' => '🟢 Live'])
|
||||||
|
->default('test')
|
||||||
|
->visible(fn (Get $get) => $get('paynet_enabled')),
|
||||||
|
Forms\Components\Placeholder::make('paynet_webhook_info')
|
||||||
|
->label('Webhook (Notify URL)')
|
||||||
|
->content('https://service.mir.md/payments/paynet/webhook')
|
||||||
|
->visible(fn (Get $get) => $get('paynet_enabled')),
|
||||||
|
Forms\Components\TextInput::make('paynet_merchant_code')
|
||||||
|
->label('Merchant Code')
|
||||||
|
->placeholder('AUTOCRM_001')
|
||||||
|
->visible(fn (Get $get) => $get('paynet_enabled')),
|
||||||
|
Forms\Components\TextInput::make('paynet_service_id')
|
||||||
|
->label('Service ID')
|
||||||
|
->numeric()
|
||||||
|
->placeholder('12345')
|
||||||
|
->visible(fn (Get $get) => $get('paynet_enabled')),
|
||||||
|
Forms\Components\TextInput::make('paynet_user')
|
||||||
|
->label('User API')
|
||||||
|
->visible(fn (Get $get) => $get('paynet_enabled')),
|
||||||
|
Forms\Components\TextInput::make('paynet_password')
|
||||||
|
->label('Parolă API')->password()->revealable()
|
||||||
|
->visible(fn (Get $get) => $get('paynet_enabled')),
|
||||||
|
Forms\Components\TextInput::make('paynet_secret')
|
||||||
|
->label('Secret semnătură')->password()->revealable()
|
||||||
|
->columnSpanFull()
|
||||||
|
->helperText('Cheia HMAC pentru semnarea cererilor + verificarea webhook-urilor de la Paynet.')
|
||||||
|
->visible(fn (Get $get) => $get('paynet_enabled')),
|
||||||
|
]),
|
||||||
Schemas\Components\Section::make('🏦 Transfer bancar (manual)')
|
Schemas\Components\Section::make('🏦 Transfer bancar (manual)')
|
||||||
->description('Datele apar pe facturi și pe pagina de plată a tenant-ului. Confirmarea o faci manual.')
|
->description('Datele apar pe facturi și pe pagina de plată a tenant-ului. Confirmarea o faci manual.')
|
||||||
->columns(2)
|
->columns(2)
|
||||||
@@ -198,6 +244,16 @@ class PaymentSettings extends Page
|
|||||||
'secret' => $data['paypal_secret'] ?? null,
|
'secret' => $data['paypal_secret'] ?? null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
PlatformSetting::put('payments.paynet', [
|
||||||
|
'enabled' => (bool) ($data['paynet_enabled'] ?? false),
|
||||||
|
'mode' => $data['paynet_mode'] ?? 'test',
|
||||||
|
'merchant_code' => $data['paynet_merchant_code'] ?? null,
|
||||||
|
'service_id' => $data['paynet_service_id'] ?? null,
|
||||||
|
'user' => $data['paynet_user'] ?? null,
|
||||||
|
'password' => $data['paynet_password'] ?? null,
|
||||||
|
'secret' => $data['paynet_secret'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
PlatformSetting::put('payments.bank', [
|
PlatformSetting::put('payments.bank', [
|
||||||
'enabled' => (bool) ($data['bank_enabled'] ?? false),
|
'enabled' => (bool) ($data['bank_enabled'] ?? false),
|
||||||
'beneficiary' => $data['bank_beneficiary'] ?? null,
|
'beneficiary' => $data['bank_beneficiary'] ?? null,
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ class PaymentController
|
|||||||
|
|
||||||
$method = $request->input('method');
|
$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');
|
abort(422, 'Method invalid');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,6 +80,10 @@ class PaymentController
|
|||||||
if ($method === 'paypal') {
|
if ($method === 'paypal') {
|
||||||
return $this->startPaypal($sub);
|
return $this->startPaypal($sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($method === 'paynet') {
|
||||||
|
return $this->startPaynet($sub);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function startStripe(Subscription $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)
|
public function success(int $subscription)
|
||||||
{
|
{
|
||||||
$sub = Subscription::findOrFail($subscription);
|
$sub = Subscription::findOrFail($subscription);
|
||||||
@@ -166,6 +190,44 @@ class PaymentController
|
|||||||
return response()->json(['ok' => true]);
|
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
|
private function markPaid(Subscription $sub, string $method, ?string $reference): void
|
||||||
{
|
{
|
||||||
if ($sub->status === 'paid') return;
|
if ($sub->status === 'paid') return;
|
||||||
@@ -188,11 +250,13 @@ class PaymentController
|
|||||||
{
|
{
|
||||||
$stripe = PlatformSetting::get('payments.stripe', []);
|
$stripe = PlatformSetting::get('payments.stripe', []);
|
||||||
$paypal = PlatformSetting::get('payments.paypal', []);
|
$paypal = PlatformSetting::get('payments.paypal', []);
|
||||||
|
$paynet = PlatformSetting::get('payments.paynet', []);
|
||||||
$bank = PlatformSetting::get('payments.bank', []);
|
$bank = PlatformSetting::get('payments.bank', []);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'stripe' => ! empty($stripe['enabled']) && ! empty($stripe['publishable_key']),
|
'stripe' => ! empty($stripe['enabled']) && ! empty($stripe['publishable_key']),
|
||||||
'paypal' => ! empty($paypal['enabled']) && ! empty($paypal['client_id']),
|
'paypal' => ! empty($paypal['enabled']) && ! empty($paypal['client_id']),
|
||||||
|
'paynet' => ! empty($paynet['enabled']) && ! empty($paynet['merchant_code']),
|
||||||
'bank' => ! empty($bank['enabled']) && ! empty($bank['iban']),
|
'bank' => ! empty($bank['enabled']) && ! empty($bank['iban']),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,6 +140,22 @@
|
|||||||
</form>
|
</form>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
@if ($methods['paynet'])
|
||||||
|
<form method="POST" action="{{ route('pay.start', ['subscription' => $inv->id]) }}" class="method-form">
|
||||||
|
@csrf
|
||||||
|
<input type="hidden" name="method" value="paynet">
|
||||||
|
<button class="method-btn">
|
||||||
|
<div class="method">
|
||||||
|
<div class="icon">🇲🇩</div>
|
||||||
|
<div>
|
||||||
|
<div class="label">Paynet</div>
|
||||||
|
<div class="desc">Card MAIB / MICB / Victoriabank, MD Cash, e-money</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
@endif
|
||||||
|
|
||||||
@if ($methods['bank'])
|
@if ($methods['bank'])
|
||||||
<form method="POST" action="{{ route('pay.start', ['subscription' => $inv->id]) }}" class="method-form">
|
<form method="POST" action="{{ route('pay.start', ['subscription' => $inv->id]) }}" class="method-form">
|
||||||
@csrf
|
@csrf
|
||||||
@@ -156,7 +172,7 @@
|
|||||||
</form>
|
</form>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if (! $methods['stripe'] && ! $methods['paypal'] && ! $methods['bank'])
|
@if (! $methods['stripe'] && ! $methods['paypal'] && ! $methods['paynet'] && ! $methods['bank'])
|
||||||
<div style="text-align:center;color:#9ca3af;padding:32px 16px;font-size:13px;">
|
<div style="text-align:center;color:#9ca3af;padding:32px 16px;font-size:13px;">
|
||||||
Nicio metodă de plată configurată. Contactează operatorul.
|
Nicio metodă de plată configurată. Contactează operatorul.
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ Route::post('/payments/stripe/webhook', [\App\Http\Controllers\PaymentController
|
|||||||
->withoutMiddleware([\Illuminate\Foundation\Http\Middleware\VerifyCsrfToken::class]);
|
->withoutMiddleware([\Illuminate\Foundation\Http\Middleware\VerifyCsrfToken::class]);
|
||||||
Route::post('/payments/paypal/webhook', [\App\Http\Controllers\PaymentController::class, 'paypalWebhook'])
|
Route::post('/payments/paypal/webhook', [\App\Http\Controllers\PaymentController::class, 'paypalWebhook'])
|
||||||
->withoutMiddleware([\Illuminate\Foundation\Http\Middleware\VerifyCsrfToken::class]);
|
->withoutMiddleware([\Illuminate\Foundation\Http\Middleware\VerifyCsrfToken::class]);
|
||||||
|
Route::post('/payments/paynet/webhook', [\App\Http\Controllers\PaymentController::class, 'paynetWebhook'])
|
||||||
|
->withoutMiddleware([\Illuminate\Foundation\Http\Middleware\VerifyCsrfToken::class]);
|
||||||
|
|
||||||
// Stub `login` route — needed because Laravel's auth middleware tries to
|
// Stub `login` route — needed because Laravel's auth middleware tries to
|
||||||
// route('login') when redirecting unauthenticated requests. We don't have a
|
// route('login') when redirecting unauthenticated requests. We don't have a
|
||||||
|
|||||||
Reference in New Issue
Block a user