1
0
mirror of https://github.com/invoiceninja/invoiceninja.git synced 2024-11-10 13:12:50 +01:00

Merge pull request #6827 from beganovich/v5-726

Stripe: SEPA improvements
This commit is contained in:
David Bomba 2021-10-14 05:41:07 +11:00 committed by GitHub
commit f038073b4a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 360 additions and 109 deletions

View File

@ -11,15 +11,15 @@
namespace App\PaymentDrivers\Stripe;
use App\Exceptions\PaymentFailed;
use App\Http\Requests\ClientPortal\Payments\PaymentResponseRequest;
use App\PaymentDrivers\StripePaymentDriver;
use App\Jobs\Mail\PaymentFailureMailer;
use App\Jobs\Util\SystemLogger;
use App\Models\GatewayType;
use App\Models\Payment;
use App\Models\PaymentType;
use App\Models\SystemLog;
use App\Exceptions\PaymentFailed;
use App\PaymentDrivers\StripePaymentDriver;
class SEPA
{
@ -29,6 +29,8 @@ class SEPA
public function __construct(StripePaymentDriver $stripe)
{
$this->stripe = $stripe;
$this->stripe->init();
}
public function authorizeView($data)
@ -36,7 +38,8 @@ class SEPA
return render('gateways.stripe.sepa.authorize', $data);
}
public function paymentView(array $data) {
public function paymentView(array $data)
{
$data['gateway'] = $this->stripe;
$data['payment_method_id'] = GatewayType::SEPA;
$data['stripe_amount'] = $this->stripe->convertToStripeAmount($data['total']['amount_with_fee'], $this->stripe->client->currency()->precision, $this->stripe->client->currency());
@ -52,11 +55,19 @@ class SEPA
'setup_future_usage' => 'off_session',
'customer' => $this->stripe->findOrCreateCustomer(),
'description' => $this->stripe->decodeUnicodeString(ctrans('texts.invoices') . ': ' . collect($data['invoices'])->pluck('invoice_number')),
]);
$data['pi_client_secret'] = $intent->client_secret;
if (count($data['tokens']) > 0) {
$setup_intent = $this->stripe->stripe->setupIntents->create([
'payment_method_types' => ['sepa_debit'],
'customer' => $this->stripe->findOrCreateCustomer()->id,
]);
$data['si_client_secret'] = $setup_intent->client_secret;
}
$this->stripe->payment_hash->data = array_merge((array) $this->stripe->payment_hash->data, ['stripe_amount' => $data['stripe_amount']]);
$this->stripe->payment_hash->save();
@ -65,28 +76,24 @@ class SEPA
public function paymentResponse(PaymentResponseRequest $request)
{
$gateway_response = json_decode($request->gateway_response);
$this->stripe->payment_hash->data = array_merge((array) $this->stripe->payment_hash->data, $request->all());
$this->stripe->payment_hash->save();
if (property_exists($gateway_response, 'status') && $gateway_response->status == 'processing') {
$this->stripe->init();
$this->storePaymentMethod($gateway_response);
if (property_exists($gateway_response, 'status') && ($gateway_response->status == 'processing' || $gateway_response->status === 'succeeded')) {
if ($request->store_card) {
$this->storePaymentMethod($gateway_response);
}
return $this->processSuccessfulPayment($gateway_response->id);
}
return $this->processUnsuccessfulPayment();
}
public function processSuccessfulPayment(string $payment_intent)
{
$this->stripe->init();
$data = [
'payment_method' => $payment_intent,
'payment_type' => PaymentType::SEPA,
@ -95,7 +102,7 @@ class SEPA
'gateway_type_id' => GatewayType::SEPA,
];
$this->stripe->createPayment($data, Payment::STATUS_PENDING);
$payment = $this->stripe->createPayment($data, Payment::STATUS_PENDING);
SystemLogger::dispatch(
['response' => $this->stripe->payment_hash->data, 'data' => $data],
@ -106,7 +113,7 @@ class SEPA
$this->stripe->client->company,
);
return redirect()->route('client.payments.index');
return redirect()->route('client.payments.show', $payment->hashed_id);
}
public function processUnsuccessfulPayment()
@ -141,7 +148,6 @@ class SEPA
private function storePaymentMethod($intent)
{
try {
$method = $this->stripe->getStripePaymentMethod($intent->payment_method);
$payment_meta = new \stdClass;

File diff suppressed because one or more lines are too long

View File

@ -20,7 +20,7 @@
"/js/clients/payments/stripe-ach.js": "/js/clients/payments/stripe-ach.js?id=81c2623fc1e5769b51c7",
"/js/clients/payments/stripe-alipay.js": "/js/clients/payments/stripe-alipay.js?id=665ddf663500767f1a17",
"/js/clients/payments/stripe-credit-card.js": "/js/clients/payments/stripe-credit-card.js?id=a30464874dee84678344",
"/js/clients/payments/stripe-sepa.js": "/js/clients/payments/stripe-sepa.js?id=e7dc964c85085314b12c",
"/js/clients/payments/stripe-sepa.js": "/js/clients/payments/stripe-sepa.js?id=3f2fa0857dc804a85dcb",
"/js/clients/payments/stripe-sofort.js": "/js/clients/payments/stripe-sofort.js?id=231571942310348aa616",
"/js/clients/payments/wepay-credit-card.js": "/js/clients/payments/wepay-credit-card.js?id=f51400e03c5fdb6cdabe",
"/js/clients/quotes/action-selectors.js": "/js/clients/quotes/action-selectors.js?id=1b8f9325aa6e8595e7fa",

View File

@ -18,93 +18,174 @@ class ProcessSEPA {
setupStripe = () => {
this.stripe = Stripe(this.key);
if(this.stripeConnect)
this.stripe.stripeAccount = stripeConnect;
if (this.stripeConnect) this.stripe.stripeAccount = stripeConnect;
const elements = this.stripe.elements();
var style = {
base: {
color: "#32325d",
color: '#32325d',
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
fontSmoothing: "antialiased",
fontSize: "16px",
"::placeholder": {
color: "#aab7c4"
fontSmoothing: 'antialiased',
fontSize: '16px',
'::placeholder': {
color: '#aab7c4',
},
':-webkit-autofill': {
color: '#32325d',
},
":-webkit-autofill": {
color: "#32325d"
}
},
invalid: {
color: "#fa755a",
iconColor: "#fa755a",
":-webkit-autofill": {
color: "#fa755a"
}
}
color: '#fa755a',
iconColor: '#fa755a',
':-webkit-autofill': {
color: '#fa755a',
},
},
};
var options = {
style: style,
supportedCountries: ["SEPA"],
supportedCountries: ['SEPA'],
// If you know the country of the customer, you can optionally pass it to
// the Element as placeholderCountry. The example IBAN that is being used
// as placeholder reflects the IBAN format of that country.
placeholderCountry: document.querySelector('meta[name="country"]').content
placeholderCountry: document.querySelector('meta[name="country"]')
.content,
};
this.iban = elements.create("iban", options);
this.iban.mount("#sepa-iban");
this.iban = elements.create('iban', options);
this.iban.mount('#sepa-iban');
return this;
};
handle = () => {
document.getElementById('pay-now').addEventListener('click', (e) => {
let errors = document.getElementById('errors');
if (document.getElementById('sepa-name').value === "") {
document.getElementById('sepa-name').focus();
errors.textContent = "Name required.";
errors.hidden = false;
return;
}
Array.from(
document.getElementsByClassName('toggle-payment-with-token')
).forEach((element) =>
element.addEventListener('click', (element) => {
document
.getElementById('stripe--payment-container')
.classList.add('hidden');
document.getElementById('save-card--container').style.display =
'none';
document.querySelector('input[name=token]').value =
element.target.dataset.token;
})
);
if (document.getElementById('sepa-email-address').value === "") {
document.getElementById('sepa-email-address').focus();
errors.textContent = "Email required.";
errors.hidden = false;
return ;
}
document
.getElementById('toggle-payment-with-new-bank-account')
.addEventListener('click', (element) => {
document
.getElementById('stripe--payment-container')
.classList.remove('hidden');
document.getElementById('save-card--container').style.display =
'grid';
document.querySelector('input[name=token]').value = '';
});
document.getElementById('pay-now').addEventListener('click', (e) => {
if (
document.querySelector('input[name=token]').value.length !== 0
) {
document.querySelector('#errors').hidden = true;
if (!document.getElementById('sepa-mandate-acceptance').checked) {
errors.textContent = "Accept Terms";
errors.hidden = false;
console.log("Terms");
return ;
}
document.getElementById('pay-now').disabled = true;
document
.querySelector('#pay-now > svg')
.classList.remove('hidden');
document
.querySelector('#pay-now > span')
.classList.add('hidden');
this.stripe
.confirmSepaDebitSetup(
document.querySelector('meta[name=si-client-secret')
.content,
{
payment_method: document.querySelector(
'input[name=token]'
).value,
}
)
.then((result) => {
if (result.error) {
console.error(error);
return;
}
document.querySelector(
'input[name="gateway_response"]'
).value = JSON.stringify(result.setupIntent);
return document
.querySelector('#server-response')
.submit();
})
.catch((error) => {
errors.textContent = error;
errors.hidden = false;
});
return;
}
if (document.getElementById('sepa-name').value === '') {
document.getElementById('sepa-name').focus();
errors.textContent = document.querySelector(
'meta[name=translation-name-required]'
).content;
errors.hidden = false;
return;
}
if (document.getElementById('sepa-email-address').value === '') {
document.getElementById('sepa-email-address').focus();
errors.textContent = document.querySelector(
'meta[name=translation-email-required]'
).content;
errors.hidden = false;
return;
}
if (!document.getElementById('sepa-mandate-acceptance').checked) {
errors.textContent = document.querySelector(
'meta[name=translation-terms-required]'
).content;
errors.hidden = false;
console.log('Terms');
return;
}
document.getElementById('pay-now').disabled = true;
document.querySelector('#pay-now > svg').classList.remove('hidden');
document.querySelector('#pay-now > span').classList.add('hidden');
this.stripe.confirmSepaDebitPayment(
document.querySelector('meta[name=pi-client-secret').content,
{
payment_method: {
sepa_debit: this.iban,
billing_details: {
name: document.getElementById("sepa-name").value,
email: document.getElementById("sepa-email-address").value,
this.stripe
.confirmSepaDebitPayment(
document.querySelector('meta[name=pi-client-secret')
.content,
{
payment_method: {
sepa_debit: this.iban,
billing_details: {
name: document.getElementById('sepa-name')
.value,
email: document.getElementById(
'sepa-email-address'
).value,
},
},
},
}
).then((result) => {
if (result.error) {
return this.handleFailure(result.error.message);
}
}
)
.then((result) => {
if (result.error) {
return this.handleFailure(result.error.message);
}
return this.handleSuccess(result);
});
return this.handleSuccess(result);
});
});
};
@ -113,6 +194,15 @@ class ProcessSEPA {
'input[name="gateway_response"]'
).value = JSON.stringify(result.paymentIntent);
let tokenBillingCheckbox = document.querySelector(
'input[name="token-billing-checkbox"]:checked'
);
if (tokenBillingCheckbox) {
document.querySelector('input[name="store_card"]').value =
tokenBillingCheckbox.value;
}
document.getElementById('server-response').submit();
}
@ -123,15 +213,15 @@ class ProcessSEPA {
errors.textContent = message;
errors.hidden = false;
document.getElementById('pay-now').disabled = false;
document.querySelector('#pay-now > svg').classList.add('hidden');
document.querySelector('#pay-now > span').classList.remove('hidden');
document.getElementById('pay-now').disabled = false;
document.querySelector('#pay-now > svg').classList.add('hidden');
document.querySelector('#pay-now > span').classList.remove('hidden');
}
}
const publishableKey = document.querySelector(
'meta[name="stripe-publishable-key"]'
)?.content ?? '';
const publishableKey =
document.querySelector('meta[name="stripe-publishable-key"]')?.content ??
'';
const stripeConnect =
document.querySelector('meta[name="stripe-account-id"]')?.content ?? '';

View File

@ -4327,6 +4327,7 @@ $LANG = array(
'giropay' => 'GiroPay',
'giropay_law' => 'By entering your Customer information (such as name, sort code and account number) you (the Customer) agree that this information is given voluntarily.',
'eps' => 'EPS',
'you_need_to_accept_the_terms_before_proceeding' => 'You need to accept the terms before proceeding.',
);
return $LANG;

View File

@ -7,9 +7,24 @@
<meta name="country" content="{{ $country }}">
<meta name="customer" content="{{ $customer }}">
<meta name="pi-client-secret" content="{{ $pi_client_secret }}">
<meta name="si-client-secret" content="{{ $si_client_secret ?? '' }}">
<meta name="translation-name-required" content="{{ ctrans('texts.missing_account_holder_name') }}">
<meta name="translation-email-required" content="{{ ctrans('texts.provide_email') }}">
<meta name="translation-terms-required" content="{{ ctrans('texts.you_need_to_accept_the_terms_before_proceeding') }}">
@endsection
@section('gateway_content')
<form action="{{ route('client.payments.response') }}" method="post" id="server-response">
@csrf
<input type="hidden" name="gateway_response">
<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="payment_hash" value="{{ $payment_hash }}">
<input type="hidden" name="store_card">
<input type="hidden" name="token">
</form>
<div class="alert alert-failure mb-4" hidden id="errors"></div>
@include('portal.ninja2020.gateways.includes.payment_details')
@ -18,7 +33,48 @@
{{ ctrans('texts.sepa') }} ({{ ctrans('texts.bank_transfer') }})
@endcomponent
@include('portal.ninja2020.gateways.stripe.sepa.sepa_debit')
@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->token }}" name="payment-type"
class="form-radio cursor-pointer toggle-payment-with-token" />
<span class="ml-1 cursor-pointer">**** {{ optional($token->meta)->last4 }}</span>
</label>
@endforeach
@endisset
<label>
<input type="radio" id="toggle-payment-with-new-bank-account" class="form-radio cursor-pointer" name="payment-type"
checked />
<span class="ml-1 cursor-pointer">{{ __('texts.new_bank_account') }}</span>
</label>
@endcomponent
@component('portal.ninja2020.components.general.card-element-single')
<div id="stripe--payment-container">
<label for="sepa-name">
<input class="input w-full" id="sepa-name" type="text"
placeholder="{{ ctrans('texts.bank_account_holder') }}">
</label>
<label for="sepa-email" class="mt-4">
<input class="input w-full" id="sepa-email-address" type="email"
placeholder="{{ ctrans('texts.email') }}">
</label>
<label>
<div class="border p-3 rounded mt-2">
<div id="sepa-iban"></div>
</div>
</label>
<div id="mandate-acceptance" class="mt-4">
<input type="checkbox" id="sepa-mandate-acceptance" class="input mr-4">
<label for="sepa-mandate-acceptance" class="cursor-pointer">
{{ ctrans('texts.sepa_mandat', ['company' => $contact->company->present()->name()]) }}
</label>
</div>
</div>
@endcomponent
@include('portal.ninja2020.gateways.includes.save_card')
@include('portal.ninja2020.gateways.includes.pay_now')
@endsection

View File

@ -1,29 +0,0 @@
<div id="stripe--payment-container">
@component('portal.ninja2020.components.general.card-element', ['title' => ctrans('texts.name')])
<form action="{{ route('client.payments.response') }}" method="post" id="server-response">
@csrf
<input type="hidden" name="gateway_response">
<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="payment_hash" value="{{ $payment_hash }}">
<input type="hidden" name="store_card">
<label for="sepa-name">
<input class="input w-full" id="sepa-name" type="text" placeholder="{{ ctrans('texts.bank_account_holder') }}">
</label>
<label for="sepa-email" >
<input class="input w-full" id="sepa-email-address" type="email" placeholder="{{ ctrans('texts.email') }}">
</label>
<label>
<div class="border p-4 rounded">
<div id="sepa-iban"></div>
</div>
</label>
<div id="mandate-acceptance">
<input type="checkbox" id="sepa-mandate-acceptance" class="input mr-4">
<label for="sepa-mandate-acceptance">{{ctrans('texts.sepa_mandat', ['company' => auth('contact')->user()->company->present()->name()])}}</label>
</div>
</form>
@endcomponent
</div>

View File

@ -0,0 +1,127 @@
<?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 Tests\Browser\ClientPortal\Gateways\Stripe;
use App\DataMapper\FeesAndLimits;
use App\Models\Client;
use App\Models\CompanyGateway;
use App\Models\GatewayType;
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->browse(function (Browser $browser) {
$browser
->visit(new Login())
->auth();
});
$this->disableCompanyGateways();
// Enable Stripe.
CompanyGateway::where('gateway_key', 'd14dd26a37cecc30fdd65700bfb55b23')->restore();
// Enable SEPA.
$cg = CompanyGateway::where('gateway_key', 'd14dd26a37cecc30fdd65700bfb55b23')->firstOrFail();
$fees_and_limits = $cg->fees_and_limits;
$fees_and_limits->{GatewayType::SEPA} = new FeesAndLimits();
$cg->fees_and_limits = $fees_and_limits;
$cg->save();
// SEPA required DE to be billing country.
$client = Client::first();
$client->country_id = 276;
$settings = $client->settings;
$settings->currency_id = "3";
$client->settings = $settings;
$client->save();
}
public function testPayingWithNewSEPABankAccount(): void
{
$this->browse(function (Browser $browser) {
$browser
->visitRoute('client.invoices.index')
->click('@pay-now')
->click('@pay-now-dropdown')
->clickLink('SEPA Direct Debit')
->type('#sepa-name', 'John Doe')
->type('#sepa-email-address', 'test@invoiceninja.com')
->withinFrame('iframe', function (Browser $browser) {
$browser->type('iban', 'DE89370400440532013000');
})
->check('#sepa-mandate-acceptance', true)
->click('#pay-now')
->waitForText('Details of the payment', 60);
});
}
public function testPayingWithNewSEPABankAccountAndSaveForFuture(): void
{
$this->browse(function (Browser $browser) {
$browser
->visitRoute('client.invoices.index')
->click('@pay-now')
->click('@pay-now-dropdown')
->clickLink('SEPA Direct Debit')
->type('#sepa-name', 'John Doe')
->type('#sepa-email-address', 'test@invoiceninja.com')
->withinFrame('iframe', function (Browser $browser) {
$browser->type('iban', 'DE89370400440532013000');
})
->check('#sepa-mandate-acceptance', true)
->radio('#proxy_is_default', true)
->click('#pay-now')
->waitForText('Details of the payment', 60);
});
}
public function testPayWithSavedBankAccount()
{
$this->browse(function (Browser $browser) {
$browser
->visitRoute('client.invoices.index')
->click('@pay-now')
->click('@pay-now-dropdown')
->clickLink('SEPA Direct Debit')
->click('.toggle-payment-with-token')
->click('#pay-now')
->waitForText('Details of the payment', 60);
});
}
public function testRemoveBankAccount()
{
$this->browse(function (Browser $browser) {
$browser
->visitRoute('client.payment_methods.index')
->clickLink('View')
->press('Remove Payment Method')
->waitForText('Confirmation')
->click('@confirm-payment-removal')
->assertSee('Payment method has been successfully removed.');
});
}
}