Merge pull request #8053 from LarsK1/patch-4

Stripe: add Klarna as payment method
This commit is contained in:
David Bomba 2022-12-07 19:50:31 +11:00 committed by GitHub
commit 449d88dd21
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 320 additions and 5 deletions

View File

@ -134,13 +134,14 @@ class Gateway extends StaticModel
GatewayType::BANK_TRANSFER => ['refund' => true, 'token_billing' => true, 'webhooks' => [' ']],
];
break;
case 56:
case 56: //Stripe
return [
GatewayType::CREDIT_CARD => ['refund' => true, 'token_billing' => true, 'webhooks' => ['payment_intent.succeeded']],
GatewayType::BANK_TRANSFER => ['refund' => true, 'token_billing' => true, 'webhooks' => ['source.chargeable', 'charge.succeeded']],
GatewayType::ALIPAY => ['refund' => false, 'token_billing' => false],
GatewayType::APPLE_PAY => ['refund' => false, 'token_billing' => false],
GatewayType::SOFORT => ['refund' => true, 'token_billing' => true, 'webhooks' => ['source.chargeable', 'charge.succeeded', 'payment_intent.succeeded']], //Stripe
GatewayType::SOFORT => ['refund' => true, 'token_billing' => true, 'webhooks' => ['source.chargeable', 'charge.succeeded', 'payment_intent.succeeded']],
GatewayType::KLARNA => ['refund' => true, 'token_billing' => true, 'webhooks' => ['source.chargeable', 'charge.succeeded', 'payment_intent.succeeded']],
GatewayType::SEPA => ['refund' => true, 'token_billing' => true, 'webhooks' => ['source.chargeable', 'charge.succeeded', 'payment_intent.succeeded']],
GatewayType::PRZELEWY24 => ['refund' => true, 'token_billing' => true, 'webhooks' => ['source.chargeable', 'charge.succeeded', 'payment_intent.succeeded']],
GatewayType::GIROPAY => ['refund' => true, 'token_billing' => true, 'webhooks' => ['source.chargeable', 'charge.succeeded', 'payment_intent.succeeded']],

View File

@ -61,6 +61,8 @@ class GatewayType extends StaticModel
const FPX = 22;
const KLARNA = 23;
public function gateway()
{
return $this->belongsTo(Gateway::class);
@ -116,6 +118,8 @@ class GatewayType extends StaticModel
return ctrans('texts.payment_type_instant_bank_pay');
case self::FPX:
return ctrans('texts.fpx');
case self::KLARNA:
return ctrans('texts.klarna');
default:
return ' ';
break;

View File

@ -55,6 +55,7 @@ class PaymentType extends StaticModel
const ACSS = 44;
const INSTANT_BANK_PAY = 45;
const FPX = 46;
const KLARNA = 47;
public static function parseCardType($cardName)
{

View File

@ -0,0 +1,154 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2022. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\PaymentDrivers\Stripe;
use App\Exceptions\PaymentFailed;
use App\Jobs\Util\SystemLogger;
use App\Models\GatewayType;
use App\Models\Payment;
use App\Models\PaymentType;
use App\Models\SystemLog;
use App\PaymentDrivers\StripePaymentDriver;
class Klarna
{
/** @var StripePaymentDriver */
public StripePaymentDriver $stripe;
public function __construct(StripePaymentDriver $stripe)
{
$this->stripe = $stripe;
}
public function authorizeView($data)
{
return render('gateways.stripe.klarna.authorize', $data);
}
public function paymentView(array $data)
{
$this->stripe->init();
$data['gateway'] = $this->stripe;
$data['return_url'] = $this->buildReturnUrl();
$data['stripe_amount'] = $this->stripe->convertToStripeAmount($data['total']['amount_with_fee'], $this->stripe->client->currency()->precision, $this->stripe->client->currency());
$data['client'] = $this->stripe->client;
$data['customer'] = $this->stripe->findOrCreateCustomer()->id;
$data['country'] = $this->stripe->client->country->iso_3166_2;
$amount = $data['total']['amount_with_fee'];
$invoice_numbers = collect($data['invoices'])->pluck('invoice_number');
if ($invoice_numbers > 0) {
$description = ctrans('texts.payment_provider_paymenttext', ['invoicenumber' => $invoice_numbers->implode(', '), 'amount' => Number::formatMoney($amount, $this->stripe->client), 'client' => $this->stripe->client->present()->name()]);
} else {
$description = ctrans('texts.payment_prvoder_paymenttext_without_invoice', ['amount' => Number::formatMoney($amount, $this->stripe->client), 'client' => $this->stripe->client->present()->name()]);
}
$intent = \Stripe\PaymentIntent::create([
'amount' => $data['stripe_amount'],
'currency' => $this->stripe->client->getCurrencyCode(),
'payment_method_types' => ['klarna'],
'customer' => $this->stripe->findOrCreateCustomer(),
'description' => $description,
'metadata' => [
'payment_hash' => $this->stripe->payment_hash->hash,
'gateway_type_id' => GatewayType::KLARNA,
],
], array_merge($this->stripe->stripe_connect_auth, ['idempotency_key' => uniqid("st",true)]));
$data['pi_client_secret'] = $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();
return render('gateways.stripe.klarna.pay', $data);
}
private function buildReturnUrl(): string
{
return route('client.payments.response', [
'company_gateway_id' => $this->stripe->company_gateway->id,
'payment_hash' => $this->stripe->payment_hash->hash,
'payment_method_id' => GatewayType::KLARNA,
]);
}
public function paymentResponse($request)
{
$this->stripe->payment_hash->data = array_merge((array) $this->stripe->payment_hash->data, $request->all());
$this->stripe->payment_hash->save();
if (in_array($request->redirect_status, ['succeeded','pending'])) {
return $this->processSuccessfulPayment($request->payment_intent);
}
return $this->processUnsuccessfulPayment();
}
public function processSuccessfulPayment(string $payment_intent)
{
$this->stripe->init();
//catch duplicate submissions.
if (Payment::where('transaction_reference', $payment_intent)->exists()) {
return redirect()->route('client.payments.index');
}
$data = [
'payment_method' => $payment_intent,
'payment_type' => PaymentType::KLARNA,
'amount' => $this->stripe->convertFromStripeAmount($this->stripe->payment_hash->data->stripe_amount, $this->stripe->client->currency()->precision, $this->stripe->client->currency()),
'transaction_reference' => $payment_intent,
'gateway_type_id' => GatewayType::KLARNA,
];
$this->stripe->createPayment($data, Payment::STATUS_PENDING);
SystemLogger::dispatch(
['response' => $this->stripe->payment_hash->data, 'data' => $data],
SystemLog::CATEGORY_GATEWAY_RESPONSE,
SystemLog::EVENT_GATEWAY_SUCCESS,
SystemLog::TYPE_STRIPE,
$this->stripe->client,
$this->stripe->client->company,
);
return redirect()->route('client.payments.index');
}
public function processUnsuccessfulPayment()
{
$server_response = $this->stripe->payment_hash->data;
$this->stripe->sendFailureMail($server_response);
$message = [
'server_response' => $server_response,
'data' => $this->stripe->payment_hash->data,
];
SystemLogger::dispatch(
$message,
SystemLog::CATEGORY_GATEWAY_RESPONSE,
SystemLog::EVENT_GATEWAY_FAILURE,
SystemLog::TYPE_STRIPE,
$this->stripe->client,
$this->stripe->client->company,
);
throw new PaymentFailed(ctrans('texts.payment_provider_failed_process_payment'), 500);
}
}

View File

@ -37,6 +37,7 @@ use App\PaymentDrivers\Stripe\CreditCard;
use App\PaymentDrivers\Stripe\EPS;
use App\PaymentDrivers\Stripe\FPX;
use App\PaymentDrivers\Stripe\GIROPAY;
use App\PaymentDrivers\Stripe\Klarna;
use App\PaymentDrivers\Stripe\iDeal;
use App\PaymentDrivers\Stripe\ImportCustomers;
use App\PaymentDrivers\Stripe\Jobs\PaymentIntentFailureWebhook;
@ -97,6 +98,7 @@ class StripePaymentDriver extends BaseDriver
GatewayType::BECS => BECS::class,
GatewayType::ACSS => ACSS::class,
GatewayType::FPX => FPX::class,
GatewayType::KLARNA => KLARNA::class,
];
const SYSTEM_LOG_TYPE = SystemLog::TYPE_STRIPE;
@ -236,6 +238,13 @@ class StripePaymentDriver extends BaseDriver
&& in_array($this->client->country->iso_3166_3, ['CAN', 'USA'])) {
$types[] = GatewayType::ACSS;
}
if ($this->client
&& $this->client->currency()
&& in_array($this->client->currency()->code, ['EUR', 'DKK', 'GBP', 'NOK', 'SEK', 'USD', 'AUD', 'NZD', 'CAD', 'PLN', 'CHF'])
&& isset($this->client->country)
&& in_array($this->client->country->iso_3166_3, ['AUT','BEL','DNK','FIN','FRA','DEU','IRL','ITA','NLD','NOR','ESP','SWE','GBR','USA'])) {
$types[] = GatewayType::KLARNA;
}
if (
$this->client
@ -274,6 +283,9 @@ class StripePaymentDriver extends BaseDriver
case GatewayType::GIROPAY:
return 'gateways.stripe.giropay';
break;
case GatewayType::KLARNA:
return 'gateways.stripe.klarna';
break;
case GatewayType::IDEAL:
return 'gateways.stripe.ideal';
case GatewayType::EPS:

View File

@ -0,0 +1,34 @@
<?php
use App\Models\GatewayType;
use App\Models\PaymentType;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('payment_types', function (Blueprint $table) {
$type = new PaymentType();
$type->id = 47;
$type->name = 'Klarna';
$type->gateway_type_id = GatewayType::KLARNA;
$type->save();
});
$type = new GatewayType();
$type->id = 23;
$type->alias = 'klarna';
$type->name = 'Klarna';
$type->save();
}
};

View File

@ -4202,7 +4202,6 @@ $LANG = array(
'count_minutes' => ':count Minutes',
'password_timeout' => 'Password Timeout',
'shared_invoice_credit_counter' => 'Share Invoice/Credit Counter',
'activity_80' => ':user created subscription :subscription',
'activity_81' => ':user updated subscription :subscription',
'activity_82' => ':user archived subscription :subscription',
@ -4210,7 +4209,6 @@ $LANG = array(
'activity_84' => ':user restored subscription :subscription',
'amount_greater_than_balance_v5' => 'The amount is greater than the invoice balance. You cannot overpay an invoice.',
'click_to_continue' => 'Click to continue',
'notification_invoice_created_body' => 'The following invoice :invoice was created for client :client for :amount.',
'notification_invoice_created_subject' => 'Invoice :invoice was created for :client',
'notification_quote_created_body' => 'The following quote :invoice was created for client :client for :amount.',
@ -4296,6 +4294,7 @@ $LANG = array(
'przelewy24_accept' => 'I declare that I have familiarized myself with the regulations and information obligation of the Przelewy24 service.',
'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.',
'klarna' => 'Klarna',
'eps' => 'EPS',
'becs' => 'BECS Direct Debit',
'becs_mandate' => 'By providing your bank account details, you agree to this <a class="underline" href="https://stripe.com/au-becs-dd-service-agreement/legal">Direct Debit Request and the Direct Debit Request service agreement</a>, and authorise Stripe Payments Australia Pty Ltd ACN 160 180 343 Direct Debit User ID number 507156 (“Stripe”) to debit your account through the Bulk Electronic Clearing System (BECS) on behalf of :company (the “Merchant”) for any amounts separately communicated to you by the Merchant. You certify that you are either an account holder or an authorised signatory on the account listed above.',
@ -4780,7 +4779,7 @@ $LANG = array(
'invoice_task_project_help' => 'Add the project to the invoice line items',
'bulk_action' => 'Bulk Action',
'phone_validation_error' => 'This mobile/cell phone number is not valid, please enter in E.164 format',
'transaction' => 'Transaction',
'transaction' => 'Transaction',
'disable_2fa' => 'Disable 2FA',
'change_number' => 'Change Number',
'resend_code' => 'Resend Code',

View File

@ -0,0 +1,68 @@
/**
* 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://www.elastic.co/licensing/elastic-license
*/
class ProcessKlarna {
constructor(key, stripeConnect) {
this.key = key;
this.errors = document.getElementById('errors');
this.stripeConnect = stripeConnect;
}
setupStripe = () => {
if (this.stripeConnect){
// this.stripe.stripeAccount = this.stripeConnect;
this.stripe = Stripe(this.key, {
stripeAccount: this.stripeConnect,
});
}
else {
this.stripe = Stripe(this.key);
}
return this;
};
handle = () => {
document.getElementById('pay-now').addEventListener('click', (e) => {
let errors = document.getElementById('errors');
document.getElementById('pay-now').disabled = true;
document.querySelector('#pay-now > svg').classList.remove('hidden');
document.querySelector('#pay-now > span').classList.add('hidden');
this.stripe.confirmKlarnaPayment(
document.querySelector('meta[name=pi-client-secret').content,
{
payment_method: {
billing_details: {
name: document.getElementById("giropay-name").value,
},
},
return_url: document.querySelector(
'meta[name="return-url"]'
).content,
}
);
});
};
}
const publishableKey = document.querySelector(
'meta[name="stripe-publishable-key"]'
)?.content ?? '';
const stripeConnect =
document.querySelector('meta[name="stripe-account-id"]')?.content ?? '';
new ProcessKlarna(publishableKey, stripeConnect).setupStripe().handle();

View File

@ -0,0 +1,7 @@
@extends('portal.ninja2020.layout.payments', ['gateway_title' => ctrans('texts.bank_account'), 'card_title' => ctrans('texts.bank_account')])
@section('gateway_content')
@component('portal.ninja2020.components.general.card-element-single', ['title' => ctrans('texts.bank_account'), 'show_title' => false])
{{ __('texts.sofort_authorize_label') }}
@endcomponent
@endsection

View File

@ -0,0 +1,31 @@
@extends('portal.ninja2020.layout.payments', ['gateway_title' => 'Klarna', 'card_title' => 'Klarna'])
@section('gateway_head')
@if($gateway->company_gateway->getConfigField('account_id'))
<meta name="stripe-account-id" content="{{ $gateway->company_gateway->getConfigField('account_id') }}">
<meta name="stripe-publishable-key" content="{{ config('ninja.ninja_stripe_publishable_key') }}">
@else
<meta name="stripe-publishable-key" content="{{ $gateway->company_gateway->getPublishableKey() }}">
@endif
<meta name="return-url" content="{{ $return_url }}">
<meta name="amount" content="{{ $stripe_amount }}">
<meta name="country" content="{{ $country }}">
<meta name="customer" content="{{ $customer }}">
<meta name="pi-client-secret" content="{{ $pi_client_secret }}">
@endsection
@section('gateway_content')
<div class="alert alert-failure mb-4" hidden id="errors"></div>
@include('portal.ninja2020.gateways.includes.payment_details')
@component('portal.ninja2020.components.general.card-element', ['title' => ctrans('texts.payment_type')])
{{ ctrans('texts.klarna') }} ({{ ctrans('texts.bank_transfer') }})
@endcomponent
@include('portal.ninja2020.gateways.includes.pay_now')
@endsection
@push('footer')
<script src="https://js.stripe.com/v3/"></script>
<script src="{{ asset('js/clients/payments/stripe-klarna.js') }}"></script>
@endpush

4
webpack.mix.js vendored
View File

@ -22,6 +22,10 @@ mix.js("resources/js/app.js", "public/js")
"resources/js/clients/payments/stripe-ach.js",
"public/js/clients/payments/stripe-ach.js"
)
.js(
"resources/js/clients/payments/stripe-klarna.js",
"public/js/clients/payments/stripe-klarna.js"
)
.js(
"resources/js/clients/invoices/action-selectors.js",
"public/js/clients/invoices/action-selectors.js"