diff --git a/app/Http/Controllers/ClientPortal/PaymentMethodController.php b/app/Http/Controllers/ClientPortal/PaymentMethodController.php index 2ab293af2397..3dcc0dfe8eef 100644 --- a/app/Http/Controllers/ClientPortal/PaymentMethodController.php +++ b/app/Http/Controllers/ClientPortal/PaymentMethodController.php @@ -152,7 +152,7 @@ class PaymentMethodController extends Controller return $gateway = auth()->user()->client->getCreditCardGateway(); } - if (request()->query('method') == GatewayType::BANK_TRANSFER) { + if (in_array(request()->query('method'), [GatewayType::BANK_TRANSFER, GatewayType::SEPA])) { return $gateway = auth()->user()->client->getBankTransferGateway(); } diff --git a/app/Models/Client.php b/app/Models/Client.php index 1a739f288604..ddb18656c779 100644 --- a/app/Models/Client.php +++ b/app/Models/Client.php @@ -518,6 +518,18 @@ class Client extends BaseModel implements HasLocalePreference } + if ($this->currency()->code == 'EUR' && in_array(GatewayType::SEPA, array_column($pms, 'gateway_type_id'))) { + foreach ($pms as $pm) { + if ($pm['gateway_type_id'] == GatewayType::SEPA) { + $cg = CompanyGateway::find($pm['company_gateway_id']); + + if ($cg && $cg->fees_and_limits->{GatewayType::SEPA}->is_enabled) { + return $cg; + } + } + } + } + return null; } diff --git a/app/Models/Gateway.php b/app/Models/Gateway.php index f1c55fbae701..0a54f275d757 100644 --- a/app/Models/Gateway.php +++ b/app/Models/Gateway.php @@ -155,7 +155,8 @@ class Gateway extends StaticModel break; case 52: return [ - GatewayType::BANK_TRANSFER => ['refund' => true, 'token_billing' => true, 'webhooks' => [' ']] // GoCardless + GatewayType::BANK_TRANSFER => ['refund' => true, 'token_billing' => true, 'webhooks' => [' ']], // GoCardless, + GatewayType::SEPA => ['refund' => false, 'token_billing' => true, 'webhooks' => [' ']] ]; break; case 58: diff --git a/app/PaymentDrivers/GoCardless/SEPA.php b/app/PaymentDrivers/GoCardless/SEPA.php new file mode 100644 index 000000000000..a215b975ed41 --- /dev/null +++ b/app/PaymentDrivers/GoCardless/SEPA.php @@ -0,0 +1,250 @@ +go_cardless = $go_cardless; + + $this->go_cardless->init(); + } + + /** + * Handle authorization for SEPA. + * + * @param array $data + * @return Redirector|RedirectResponse|void + */ + public function authorizeView(array $data) + { + $session_token = \Illuminate\Support\Str::uuid()->toString(); + + try { + $redirect = $this->go_cardless->gateway->redirectFlows()->create([ + 'params' => [ + 'scheme' => 'sepa_core', + 'session_token' => $session_token, + 'success_redirect_url' => route('client.payment_methods.confirm', [ + 'method' => GatewayType::SEPA, + 'session_token' => $session_token, + ]), + 'prefilled_customer' => [ + 'given_name' => auth('contact')->user()->first_name, + 'family_name' => auth('contact')->user()->last_name, + 'email' => auth('contact')->user()->email, + 'address_line1' => auth('contact')->user()->client->address1, + 'city' => auth('contact')->user()->client->city, + 'postal_code' => auth('contact')->user()->client->postal_code, + ], + ], + ]); + + return redirect( + $redirect->redirect_url + ); + } catch (\Exception $exception) { + return $this->processUnsuccessfulAuthorization($exception); + } + } + + /** + * Handle unsuccessful authorization for SEPA. + * + * @param Exception $exception + * @return void + */ + public function processUnsuccessfulAuthorization(\Exception $exception): void + { + $this->go_cardless->sendFailureMail($exception->getMessage()); + + SystemLogger::dispatch( + $exception->getMessage(), + SystemLog::CATEGORY_GATEWAY_RESPONSE, + SystemLog::EVENT_GATEWAY_FAILURE, + SystemLog::TYPE_GOCARDLESS, + $this->go_cardless->client, + $this->go_cardless->client->company, + ); + + throw new PaymentFailed($exception->getMessage(), $exception->getCode()); + } + + /** + * Handle authorization response for SEPA. + * + * @param Request $request + * @return RedirectResponse|void + */ + public function authorizeResponse(Request $request) + { + try { + $redirect_flow = $this->go_cardless->gateway->redirectFlows()->complete( + $request->redirect_flow_id, + ['params' => [ + 'session_token' => $request->session_token + ]], + ); + + $payment_meta = new \stdClass; + $payment_meta->brand = ctrans('texts.sepa'); + $payment_meta->type = GatewayType::SEPA; + $payment_meta->state = 'authorized'; + + $data = [ + 'payment_meta' => $payment_meta, + 'token' => $redirect_flow->links->mandate, + 'payment_method_id' => GatewayType::SEPA, + ]; + + $payment_method = $this->go_cardless->storeGatewayToken($data, ['gateway_customer_reference' => $redirect_flow->links->customer]); + + return redirect()->route('client.payment_methods.show', $payment_method->hashed_id); + } catch (\Exception $exception) { + return $this->processUnsuccessfulAuthorization($exception); + } + } + + /** + * Payment view for SEPA. + * + * @param array $data + * @return View + */ + public function paymentView(array $data): View + { + $data['gateway'] = $this->go_cardless; + $data['amount'] = $this->go_cardless->convertToGoCardlessAmount($data['total']['amount_with_fee'], $this->go_cardless->client->currency()->precision); + $data['currency'] = $this->go_cardless->client->getCurrencyCode(); + + return render('gateways.gocardless.sepa.pay', $data); + } + + /** + * Handle the payment page for SEPA. + * + * @param PaymentResponseRequest $request + * @return RedirectResponse|App\PaymentDrivers\GoCardless\never|void + */ + public function paymentResponse(PaymentResponseRequest $request) + { + $token = ClientGatewayToken::find( + $this->decodePrimaryKey($request->source) + )->firstOrFail(); + + try { + $payment = $this->go_cardless->gateway->payments()->create([ + 'params' => [ + 'amount' => $request->amount, + 'currency' => $request->currency, + 'metadata' => [ + 'payment_hash' => $this->go_cardless->payment_hash->hash, + ], + 'links' => [ + 'mandate' => $token->token, + ], + ], + ]); + + if ($payment->status === 'pending_submission') { + return $this->processPendingPayment($payment, ['token' => $token->hashed_id]); + } + + return $this->processUnsuccessfulPayment($payment); + } catch (\Exception $exception) { + throw new PaymentFailed($exception->getMessage(), $exception->getCode()); + } + } + + /** + * Handle pending payments for Direct Debit. + * + * @param ResourcesPayment $payment + * @param array $data + * @return RedirectResponse + */ + public function processPendingPayment(\GoCardlessPro\Resources\Payment $payment, array $data = []) + { + $data = [ + 'payment_method' => $data['token'], + 'payment_type' => PaymentType::SEPA, + 'amount' => $this->go_cardless->payment_hash->data->amount_with_fee, + 'transaction_reference' => $payment->id, + 'gateway_type_id' => GatewayType::SEPA, + ]; + + $payment = $this->go_cardless->createPayment($data, Payment::STATUS_PENDING); + + SystemLogger::dispatch( + ['response' => $payment, 'data' => $data], + SystemLog::CATEGORY_GATEWAY_RESPONSE, + SystemLog::EVENT_GATEWAY_SUCCESS, + SystemLog::TYPE_GOCARDLESS, + $this->go_cardless->client, + $this->go_cardless->client->company, + ); + + return redirect()->route('client.payments.show', ['payment' => $this->go_cardless->encodePrimaryKey($payment->id)]); + } + + /** + * Process unsuccessful payments for Direct Debit. + * + * @param ResourcesPayment $payment + * @return never + */ + public function processUnsuccessfulPayment(\GoCardlessPro\Resources\Payment $payment) + { + $this->go_cardless->sendFailureMail( + $payment->status + ); + + $message = [ + 'server_response' => $payment, + 'data' => $this->go_cardless->payment_hash->data, + ]; + + SystemLogger::dispatch( + $message, + SystemLog::CATEGORY_GATEWAY_RESPONSE, + SystemLog::EVENT_GATEWAY_FAILURE, + SystemLog::TYPE_GOCARDLESS, + $this->go_cardless->client, + $this->go_cardless->client->company, + ); + + throw new PaymentFailed('Failed to process the payment.', 500); + } +} diff --git a/app/PaymentDrivers/GoCardlessPaymentDriver.php b/app/PaymentDrivers/GoCardlessPaymentDriver.php index 190321b8832e..b59ec7f80be7 100644 --- a/app/PaymentDrivers/GoCardlessPaymentDriver.php +++ b/app/PaymentDrivers/GoCardlessPaymentDriver.php @@ -37,6 +37,7 @@ class GoCardlessPaymentDriver extends BaseDriver public static $methods = [ GatewayType::BANK_TRANSFER => \App\PaymentDrivers\GoCardless\ACH::class, + GatewayType::SEPA => \App\PaymentDrivers\GoCardless\SEPA::class, ]; const SYSTEM_LOG_TYPE = SystemLog::TYPE_GOCARDLESS; @@ -62,6 +63,10 @@ class GoCardlessPaymentDriver extends BaseDriver $types[] = GatewayType::BANK_TRANSFER; } + if ($this->client->currency()->code === 'EUR') { + $types[] = GatewayType::SEPA; + } + return $types; } diff --git a/resources/views/portal/ninja2020/gateways/gocardless/sepa/pay.blade.php b/resources/views/portal/ninja2020/gateways/gocardless/sepa/pay.blade.php new file mode 100644 index 000000000000..7f506a51cc47 --- /dev/null +++ b/resources/views/portal/ninja2020/gateways/gocardless/sepa/pay.blade.php @@ -0,0 +1,56 @@ +@extends('portal.ninja2020.layout.payments', ['gateway_title' => ctrans('texts.payment_type_SEPA'), 'card_title' => ctrans('texts.payment_type_SEPA')]) + +@section('gateway_content') + @if (count($tokens) > 0) +
+ + @include('portal.ninja2020.gateways.includes.payment_details') + + + + @component('portal.ninja2020.components.general.card-element', ['title' => ctrans('texts.pay_with')]) + @if (count($tokens) > 0) + @foreach ($tokens as $token) + + @endforeach + @endisset + @endcomponent + + @else + @component('portal.ninja2020.components.general.card-element-single', ['title' => ctrans('texts.payment_type_SEPA'), 'show_title' => false]) + {{ ctrans('texts.bank_account_not_linked') }} + + {{ ctrans('texts.add_payment_method') }} + @endcomponent + @endif + + @include('portal.ninja2020.gateways.includes.pay_now') +@endsection + +@push('footer') + +@endpush diff --git a/tests/Browser/ClientPortal/Gateways/GoCardless/SEPATest.php b/tests/Browser/ClientPortal/Gateways/GoCardless/SEPATest.php new file mode 100644 index 000000000000..a1de5eced97a --- /dev/null +++ b/tests/Browser/ClientPortal/Gateways/GoCardless/SEPATest.php @@ -0,0 +1,42 @@ +driver->manage()->deleteAllCookies(); + } + + $this->disableCompanyGateways(); + + CompanyGateway::where('gateway_key', 'b9886f9257f0c6ee7c302f1c74475f6c')->restore(); + + $this->browse(function (Browser $browser) { + $browser + ->visit(new Login()) + ->auth(); + }); + } + + public function testPayingWithNoPreauthorizedIsntPossible() + { + $this->browse(function (Browser $browser) { + $browser + ->visitRoute('client.invoices.index') + ->click('@pay-now') + ->press('Pay Now') + ->clickLink('SEPA Direct Debit') + ->assertSee('To pay with a bank account, first you have to add it as payment method.'); + }); + } +}