mirror of
				https://github.com/invoiceninja/invoiceninja.git
				synced 2025-11-04 00:47:31 -05:00 
			
		
		
		
	Merge pull request #6867 from beganovich/gocardless-sepa
GoCardless: SEPA
This commit is contained in:
		
						commit
						4d5f60eec8
					
				@ -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();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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;
 | 
			
		||||
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -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:
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										250
									
								
								app/PaymentDrivers/GoCardless/SEPA.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										250
									
								
								app/PaymentDrivers/GoCardless/SEPA.php
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,250 @@
 | 
			
		||||
<?php
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Invoice Ninja (https://invoiceninja.com).
 | 
			
		||||
 *
 | 
			
		||||
 * @link https://github.com/invoiceninja/invoiceninja source repository
 | 
			
		||||
 *
 | 
			
		||||
 * @copyright Copyright (c) 2021. Invoice Ninja LLC (https://invoiceninja.com)
 | 
			
		||||
 *
 | 
			
		||||
 * @license https://opensource.org/licenses/AAL
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
namespace App\PaymentDrivers\GoCardless;
 | 
			
		||||
 | 
			
		||||
use App\Exceptions\PaymentFailed;
 | 
			
		||||
use App\Http\Requests\ClientPortal\Payments\PaymentResponseRequest;
 | 
			
		||||
use App\Http\Requests\Request;
 | 
			
		||||
use App\Jobs\Util\SystemLogger;
 | 
			
		||||
use App\Models\ClientGatewayToken;
 | 
			
		||||
use App\Models\GatewayType;
 | 
			
		||||
use App\Models\Payment;
 | 
			
		||||
use App\Models\PaymentType;
 | 
			
		||||
use App\Models\SystemLog;
 | 
			
		||||
use App\PaymentDrivers\Common\MethodInterface;
 | 
			
		||||
use App\PaymentDrivers\GoCardlessPaymentDriver;
 | 
			
		||||
use App\Utils\Traits\MakesHash;
 | 
			
		||||
use Exception;
 | 
			
		||||
use Illuminate\Http\RedirectResponse;
 | 
			
		||||
use Illuminate\Routing\Redirector;
 | 
			
		||||
use Illuminate\View\View;
 | 
			
		||||
 | 
			
		||||
class SEPA implements MethodInterface
 | 
			
		||||
{
 | 
			
		||||
    use MakesHash;
 | 
			
		||||
 | 
			
		||||
    protected GoCardlessPaymentDriver $go_cardless;
 | 
			
		||||
 | 
			
		||||
    public function __construct(GoCardlessPaymentDriver $go_cardless)
 | 
			
		||||
    {
 | 
			
		||||
        $this->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);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@ -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;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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)
 | 
			
		||||
        <div class="alert alert-failure mb-4" hidden id="errors"></div>
 | 
			
		||||
 | 
			
		||||
        @include('portal.ninja2020.gateways.includes.payment_details')
 | 
			
		||||
 | 
			
		||||
        <form action="{{ route('client.payments.response') }}" method="post" id="server-response">
 | 
			
		||||
            @csrf
 | 
			
		||||
            <input type="hidden" name="company_gateway_id" value="{{ $gateway->getCompanyGatewayId() }}">
 | 
			
		||||
            <input type="hidden" name="payment_method_id" value="{{ $payment_method_id }}">
 | 
			
		||||
            <input type="hidden" name="source" value="">
 | 
			
		||||
            <input type="hidden" name="amount" value="{{ $amount }}">
 | 
			
		||||
            <input type="hidden" name="currency" value="{{ $currency }}">
 | 
			
		||||
            <input type="hidden" name="payment_hash" value="{{ $payment_hash }}">
 | 
			
		||||
        </form>
 | 
			
		||||
 | 
			
		||||
        @component('portal.ninja2020.components.general.card-element', ['title' => ctrans('texts.pay_with')])
 | 
			
		||||
            @if (count($tokens) > 0)
 | 
			
		||||
                @foreach ($tokens as $token)
 | 
			
		||||
                    <label class="mr-4">
 | 
			
		||||
                        <input type="radio" data-token="{{ $token->hashed_id }}" name="payment-type"
 | 
			
		||||
                            class="form-radio cursor-pointer toggle-payment-with-token" />
 | 
			
		||||
                        <span class="ml-1 cursor-pointer">{{ ctrans('texts.payment_type_SEPA') }}
 | 
			
		||||
                            (#{{ $token->hashed_id }})</span>
 | 
			
		||||
                    </label>
 | 
			
		||||
                @endforeach
 | 
			
		||||
            @endisset
 | 
			
		||||
        @endcomponent
 | 
			
		||||
 | 
			
		||||
    @else
 | 
			
		||||
        @component('portal.ninja2020.components.general.card-element-single', ['title' => ctrans('texts.payment_type_SEPA'), 'show_title' => false])
 | 
			
		||||
            <span>{{ ctrans('texts.bank_account_not_linked') }}</span>
 | 
			
		||||
            
 | 
			
		||||
            <a class="button button-link text-primary"
 | 
			
		||||
                href="{{ route('client.payment_methods.index') }}">{{ ctrans('texts.add_payment_method') }}</a>
 | 
			
		||||
        @endcomponent
 | 
			
		||||
    @endif
 | 
			
		||||
 | 
			
		||||
    @include('portal.ninja2020.gateways.includes.pay_now')
 | 
			
		||||
@endsection
 | 
			
		||||
 | 
			
		||||
@push('footer')
 | 
			
		||||
    <script>
 | 
			
		||||
        Array
 | 
			
		||||
            .from(document.getElementsByClassName('toggle-payment-with-token'))
 | 
			
		||||
            .forEach((element) => element.addEventListener('click', (element) => {
 | 
			
		||||
                document.querySelector('input[name=source]').value = element.target.dataset.token;
 | 
			
		||||
            }));
 | 
			
		||||
 | 
			
		||||
        document.getElementById('pay-now').addEventListener('click', function() {
 | 
			
		||||
            document.getElementById('server-response').submit();
 | 
			
		||||
        });
 | 
			
		||||
    </script>
 | 
			
		||||
@endpush
 | 
			
		||||
							
								
								
									
										42
									
								
								tests/Browser/ClientPortal/Gateways/GoCardless/SEPATest.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								tests/Browser/ClientPortal/Gateways/GoCardless/SEPATest.php
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,42 @@
 | 
			
		||||
<?php
 | 
			
		||||
 | 
			
		||||
namespace Tests\Browser\ClientPortal\Gateways\GoCardless;
 | 
			
		||||
 | 
			
		||||
use App\Models\CompanyGateway;
 | 
			
		||||
use Laravel\Dusk\Browser;
 | 
			
		||||
use Tests\Browser\Pages\ClientPortal\Login;
 | 
			
		||||
use Tests\DuskTestCase;
 | 
			
		||||
 | 
			
		||||
class SEPATest extends DuskTestCase
 | 
			
		||||
{
 | 
			
		||||
    protected function setUp(): void
 | 
			
		||||
    {
 | 
			
		||||
        parent::setUp();
 | 
			
		||||
 | 
			
		||||
        foreach (static::$browsers as $browser) {
 | 
			
		||||
            $browser->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.');
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user