Bank Transfers

This commit is contained in:
David Bomba 2023-02-23 16:52:45 +11:00
parent 6496b99379
commit 885ee633d7
6 changed files with 458 additions and 73 deletions

View File

@ -104,6 +104,7 @@ class Gateway extends StaticModel
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', 'customer.source.updated','payment_intent.processing']],
GatewayType::DIRECT_DEBIT => ['refund' => false, 'token_billing' => false, 'webhooks' => ['payment_intent.processing','payment_intent.succeeded','payment_intent.partially_funded']],
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']],
@ -141,6 +142,7 @@ class Gateway extends StaticModel
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', 'customer.source.updated','payment_intent.processing']],
GatewayType::DIRECT_DEBIT => ['refund' => false, 'token_billing' => false, 'webhooks' => ['payment_intent.processing','payment_intent.succeeded','payment_intent.partially_funded']],
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']],

View File

@ -0,0 +1,211 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2023. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\PaymentDrivers\Stripe;
use App\Models\Payment;
use App\Models\SystemLog;
use Stripe\PaymentIntent;
use App\Models\GatewayType;
use App\Models\PaymentType;
use App\Jobs\Util\SystemLogger;
use App\Utils\Traits\MakesHash;
use App\Exceptions\PaymentFailed;
use App\PaymentDrivers\StripePaymentDriver;
use App\Http\Requests\ClientPortal\Payments\PaymentResponseRequest;
class BankTransfer
{
use MakesHash;
/** @var StripePaymentDriver */
public $stripe;
public function __construct(StripePaymentDriver $stripe)
{
$this->stripe = $stripe;
}
public function paymentView(array $data)
{
$this->stripe->init();
$intent = \Stripe\PaymentIntent::create([
'amount' => $this->stripe->convertToStripeAmount($data['total']['amount_with_fee'], $this->stripe->client->currency()->precision, $this->stripe->client->currency()),
'currency' => $this->stripe->client->currency()->code,
'customer' => $this->stripe->findOrCreateCustomer()->id,
'description' => $this->stripe->getDescription(false),
'payment_method_types' => ['customer_balance'],
'payment_method_data' => [
'type' => 'customer_balance',
],
'payment_method_options' => [
'customer_balance' => [
'funding_type' => 'bank_transfer',
'bank_transfer' => $this->resolveBankType()
],
],
'metadata' => [
'payment_hash' => $this->stripe->payment_hash->hash,
'gateway_type_id' => GatewayType::DIRECT_DEBIT,
],
], $this->stripe->stripe_connect_auth);
$this->stripe->payment_hash->data = array_merge((array) $this->stripe->payment_hash->data, ['stripe_amount' => $this->stripe->convertToStripeAmount($data['total']['amount_with_fee'], $this->stripe->client->currency()->precision, $this->stripe->client->currency())]);
$this->stripe->payment_hash->save();
$data = [];
$data['return_url'] = $this->buildReturnUrl();
$data['gateway'] = $this->stripe;
$data['client_secret'] = $intent ? $intent->client_secret : false;
return render('gateways.stripe.bank_transfer.pay', $data);
}
/**
* Resolve the bank type based on the currency
*
* @return void
*/
private function resolveBankType()
{
return match($this->stripe->client->currency()->code){
'GBP' => ['type' => 'gb_bank_transfer'],
'EUR' => ['type' => 'eu_bank_transfer', 'eu_bank_transfer' => ['country' => $this->stripe->client->country->iso_3166_2]],
'JPY' => ['type' => 'jp_bank_transfer'],
'MXN' => ['type' =>'mx_bank_transfer'],
};
}
/**
* Return URL
*
* @return string
*/
private function buildReturnUrl(): string
{
return route('client.payments.response.get', [
'company_gateway_id' => $this->stripe->company_gateway->id,
'payment_hash' => $this->stripe->payment_hash->hash,
'payment_method_id' => GatewayType::DIRECT_DEBIT,
]);
}
public function paymentResponse(PaymentResponseRequest $request)
{
$this->stripe->init();
$this->stripe->setPaymentHash($request->getPaymentHash());
$this->stripe->client = $this->stripe->payment_hash->fee_invoice->client;
if($request->payment_intent){
$pi = \Stripe\PaymentIntent::retrieve(
$request->payment_intent,
$this->stripe->stripe_connect_auth
);
nlog($pi);
if (in_array($pi->status, ['succeeded', 'processing'])) {
return $this->processSuccesfulRedirect($pi);
}
/* Create a pending payment */
if($pi->status == 'requires_action') {
$data = [
'payment_method' => $pi->payment_method,
'payment_type' => PaymentType::DIRECT_DEBIT,
'amount' => $this->stripe->convertFromStripeAmount($this->stripe->payment_hash->data->stripe_amount, $this->stripe->client->currency()->precision, $this->stripe->client->currency()),
'transaction_reference' => $pi->id,
'gateway_type_id' => GatewayType::DIRECT_DEBIT,
];
$payment = $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($pi->next_action->display_bank_transfer_instructions->hosted_instructions_url);
}
return $this->processUnsuccesfulRedirect();
}
}
public function processSuccesfulRedirect($payment_intent)
{
$this->stripe->init();
$data = [
'payment_method' => $payment_intent->payment_method,
'payment_type' => PaymentType::DIRECT_DEBIT,
'amount' => $this->stripe->convertFromStripeAmount($this->stripe->payment_hash->data->stripe_amount, $this->stripe->client->currency()->precision, $this->stripe->client->currency()),
'transaction_reference' => $payment_intent->id,
'gateway_type_id' => GatewayType::DIRECT_DEBIT,
];
$payment = $this->stripe->createPayment($data, $payment_intent->status == 'processing' ? Payment::STATUS_PENDING : Payment::STATUS_COMPLETED);
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.show', ['payment' => $this->stripe->encodePrimaryKey($payment->id)]);
}
public function processUnsuccesfulRedirect()
{
$server_response = $this->stripe->payment_hash->data;
$this->stripe->sendFailureMail($server_response->redirect_status);
$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('Failed to process the payment.', 500);
}
}

View File

@ -0,0 +1,95 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2023. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\PaymentDrivers\Stripe\Jobs;
use App\Libraries\MultiDB;
use App\Models\Company;
use App\Models\CompanyGateway;
use App\Models\Payment;
use App\Models\PaymentHash;
use App\PaymentDrivers\Stripe\Utilities;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class PaymentIntentPartiallyFundedWebhook implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels, Utilities;
public $tries = 1;
public $deleteWhenMissingModels = true;
public function __construct(public array $stripe_request, public string $company_key, public int $company_gateway_id)
{
}
public function handle()
{
MultiDB::findAndSetDbByCompanyKey($this->company_key);
$company = Company::where('company_key', $this->company_key)->first();
foreach ($this->stripe_request as $transaction) {
$payment_intent = false;
if (array_key_exists('payment_intent', $transaction)) {
$payment_intent = $transaction['payment_intent'];
} else {
$payment_intent = $transaction['id'];
}
if(!$payment_intent){
nlog("payment intent not found");
nlog($transaction);
return;
}
$payment = Payment::query()
->where('company_id', $company->id)
->where('transaction_reference', $payment_intent)
->first();
if(!$payment){
nlog("paymentintent found but no payment");
}
$company_gateway = CompanyGateway::find($this->company_gateway_id);
$stripe_driver = $company_gateway->driver()->init();
$hash = isset($transaction['metadata']['payment_hash']) ? $transaction['metadata']['payment_hash'] : false;
if (!$hash) {
nlog("no hash found");
return;
}
$payment_hash = PaymentHash::where('hash', $hash)->first();
if (!$payment_hash) {
nlog("no payment hash found");
return;
}
$stripe_driver->client = $payment_hash->fee_invoice->client;
$pi = \Stripe\PaymentIntent::retrieve($payment_intent, $stripe_driver->stripe_connect_auth);
$amount = $stripe_driver->convertFromStripeAmount($pi->amount, $stripe_driver->client->currency()->precision, $stripe_driver->client->currency()->precision);
$amount_received = $stripe_driver->convertFromStripeAmount($pi->amount_received, $stripe_driver->client->currency()->precision, $stripe_driver->client->currency()->precision);
//at this point we just send notification emails to the client and advise of over/under payments.
}
}
}

View File

@ -260,39 +260,6 @@ class PaymentIntentWebhook implements ShouldQueue
}
}
// private function updateSepaPayment($payment_hash, $client, $meta)
// {
// $company_gateway = CompanyGateway::find($this->company_gateway_id);
// $payment_method_type = GatewayType::SEPA;
// $driver = $company_gateway->driver($client)->init()->setPaymentMethod($payment_method_type);
// $payment_hash->data = array_merge((array) $payment_hash->data, $this->stripe_request);
// $payment_hash->save();
// $driver->setPaymentHash($payment_hash);
// $data = [
// 'payment_method' => $payment_hash->data->object->payment_method,
// 'payment_type' => PaymentType::parseCardType(strtolower($meta['card_details'])) ?: PaymentType::CREDIT_CARD_OTHER,
// 'amount' => $payment_hash->data->amount_with_fee,
// 'transaction_reference' => $meta['transaction_reference'],
// 'gateway_type_id' => GatewayType::CREDIT_CARD,
// ];
// $payment = $driver->createPayment($data, Payment::STATUS_COMPLETED);
// SystemLogger::dispatch(
// ['response' => $this->stripe_request, 'data' => $data],
// SystemLog::CATEGORY_GATEWAY_RESPONSE,
// SystemLog::EVENT_GATEWAY_SUCCESS,
// SystemLog::TYPE_STRIPE,
// $client,
// $client->company,
// );
// }
private function updateCreditCardPayment($payment_hash, $client, $meta)
{

View File

@ -12,51 +12,53 @@
namespace App\PaymentDrivers;
use App\Exceptions\PaymentFailed;
use App\Exceptions\StripeConnectFailure;
use App\Http\Requests\Payments\PaymentWebhookRequest;
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\PaymentHash;
use App\Models\SystemLog;
use App\PaymentDrivers\Stripe\ACH;
use App\PaymentDrivers\Stripe\ACSS;
use App\PaymentDrivers\Stripe\Alipay;
use App\PaymentDrivers\Stripe\Bancontact;
use App\PaymentDrivers\Stripe\BECS;
use App\PaymentDrivers\Stripe\BrowserPay;
use App\PaymentDrivers\Stripe\Charge;
use App\PaymentDrivers\Stripe\Connect\Verify;
use App\PaymentDrivers\Stripe\CreditCard;
use App\PaymentDrivers\Stripe\EPS;
use App\PaymentDrivers\Stripe\FPX;
use App\PaymentDrivers\Stripe\GIROPAY;
use App\PaymentDrivers\Stripe\iDeal;
use App\PaymentDrivers\Stripe\ImportCustomers;
use App\PaymentDrivers\Stripe\Jobs\PaymentIntentFailureWebhook;
use App\PaymentDrivers\Stripe\Jobs\PaymentIntentProcessingWebhook;
use App\PaymentDrivers\Stripe\Jobs\PaymentIntentWebhook;
use App\PaymentDrivers\Stripe\Klarna;
use App\PaymentDrivers\Stripe\PRZELEWY24;
use App\PaymentDrivers\Stripe\SEPA;
use App\PaymentDrivers\Stripe\SOFORT;
use App\PaymentDrivers\Stripe\UpdatePaymentMethods;
use App\PaymentDrivers\Stripe\Utilities;
use App\Utils\Traits\MakesHash;
use Exception;
use Illuminate\Http\RedirectResponse;
use Laracasts\Presenter\Exceptions\PresenterException;
use Stripe\Stripe;
use Stripe\Account;
use Stripe\Customer;
use Stripe\Exception\ApiErrorException;
use App\Models\Payment;
use Stripe\SetupIntent;
use Stripe\StripeClient;
use App\Models\SystemLog;
use Stripe\PaymentIntent;
use Stripe\PaymentMethod;
use Stripe\SetupIntent;
use Stripe\Stripe;
use Stripe\StripeClient;
use App\Models\GatewayType;
use App\Models\PaymentHash;
use App\Http\Requests\Request;
use App\Jobs\Util\SystemLogger;
use App\Utils\Traits\MakesHash;
use App\Exceptions\PaymentFailed;
use App\Models\ClientGatewayToken;
use App\PaymentDrivers\Stripe\ACH;
use App\PaymentDrivers\Stripe\EPS;
use App\PaymentDrivers\Stripe\FPX;
use App\PaymentDrivers\Stripe\ACSS;
use App\PaymentDrivers\Stripe\BECS;
use App\PaymentDrivers\Stripe\SEPA;
use App\PaymentDrivers\Stripe\iDeal;
use App\PaymentDrivers\Stripe\Alipay;
use App\PaymentDrivers\Stripe\Charge;
use App\PaymentDrivers\Stripe\Klarna;
use App\PaymentDrivers\Stripe\SOFORT;
use Illuminate\Http\RedirectResponse;
use App\PaymentDrivers\Stripe\GIROPAY;
use Stripe\Exception\ApiErrorException;
use App\Exceptions\StripeConnectFailure;
use App\PaymentDrivers\Stripe\Utilities;
use App\PaymentDrivers\Stripe\Bancontact;
use App\PaymentDrivers\Stripe\BrowserPay;
use App\PaymentDrivers\Stripe\CreditCard;
use App\PaymentDrivers\Stripe\PRZELEWY24;
use App\PaymentDrivers\Stripe\BankTransfer;
use App\PaymentDrivers\Stripe\Connect\Verify;
use App\PaymentDrivers\Stripe\ImportCustomers;
use App\PaymentDrivers\Stripe\UpdatePaymentMethods;
use App\Http\Requests\Payments\PaymentWebhookRequest;
use Laracasts\Presenter\Exceptions\PresenterException;
use App\PaymentDrivers\Stripe\Jobs\PaymentIntentWebhook;
use App\PaymentDrivers\Stripe\Jobs\PaymentIntentFailureWebhook;
use App\PaymentDrivers\Stripe\Jobs\PaymentIntentProcessingWebhook;
use App\PaymentDrivers\Stripe\Jobs\PaymentIntentPartiallyFundedWebhook;
class StripePaymentDriver extends BaseDriver
{
@ -95,6 +97,7 @@ class StripePaymentDriver extends BaseDriver
GatewayType::ACSS => ACSS::class,
GatewayType::FPX => FPX::class,
GatewayType::KLARNA => Klarna::class,
GatewayType::DIRECT_DEBIT => BankTransfer::class,
];
const SYSTEM_LOG_TYPE = SystemLog::TYPE_STRIPE;
@ -255,6 +258,14 @@ class StripePaymentDriver extends BaseDriver
$types[] = GatewayType::APPLE_PAY;
}
if (
$this->client
&& isset($this->client->country)
&& in_array($this->client->country->iso_3166_2, ['FR', 'IE', 'NL', 'GB', 'DE', 'ES', 'JP', 'MX'])
) {
$types[] = GatewayType::DIRECT_DEBIT;
}
return $types;
}
@ -657,6 +668,12 @@ class StripePaymentDriver extends BaseDriver
return response()->json([], 200);
}
if ($request->type === 'payment_intent.partially_funded') {
PaymentIntentPartiallyFundedWebhook::dispatch($request->data, $request->company_key, $this->company_gateway->id)->delay(now()->addSeconds(rand(5, 10)));
return response()->json([], 200);
}
if (in_array($request->type, ['payment_intent.payment_failed', 'charge.failed'])) {
PaymentIntentFailureWebhook::dispatch($request->data, $request->company_key, $this->company_gateway->id)->delay(now()->addSeconds(rand(5, 10)));

View File

@ -0,0 +1,93 @@
@extends('portal.ninja2020.layout.payments', ['gateway_title' => 'Bank Transfer', 'card_title' => 'Bank Transfer'])
@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->getPublishableKey() }}">
@endif
<meta name="viewport" content="width=device-width, minimum-scale=1" />
@endsection
@section('gateway_content')
<div class="alert alert-failure mb-4" hidden id="errors"></div>
<form action="{{ route('client.payments.response') }}" method="post" id="payment-form">
@csrf
<div id="payment-element">
<!-- Elements will create form elements here -->
</div>
<div class="bg-white px-4 py-5 flex justify-end">
<button
@isset($form) form="{{ $form }}" @endisset
type="submit"
id="{{ $id ?? 'pay-now' }}"
@isset($data) @foreach($data as $prop => $value) data-{{ $prop }}="{{ $value }}" @endforeach @endisset
class="button button-primary bg-primary {{ $class ?? '' }}"
{{ isset($disabled) && $disabled === true ? 'disabled' : '' }}>
<svg class="animate-spin h-5 w-5 text-white hidden" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span>{{ $slot ?? ctrans('texts.pay_now') }}</span>
</button>
</div>
</form>
@endsection
@push('footer')
<script src="https://js.stripe.com/v3/"></script>
<script>
const options = {
clientSecret: '{{ $client_secret }}',
// Fully customizable with appearance API.
appearance: {/*...*/},
};
const stripe = Stripe(document.querySelector('meta[name="stripe-publishable-key"]').getAttribute('content'));
const stripeConnect = document.querySelector('meta[name="stripe-account-id"]')?.content ?? '';
if(stripeConnect)
stripe.stripeAccount = stripeConnect;
// Set up Stripe.js and Elements to use in checkout form, passing the client secret obtained in step 3
const elements = stripe.elements(options);
// Create and mount the Payment Element
const paymentElement = elements.create('payment');
paymentElement.mount('#payment-element');
const form = document.getElementById('payment-form');
form.addEventListener('submit', async (event) => {
event.preventDefault();
document.getElementById('pay-now').disabled = true;
document.querySelector('#pay-now > svg').classList.add('hidden');
document.querySelector('#pay-now > span').classList.remove('hidden');
const {error} = await stripe.confirmPayment({
//`Elements` instance that was used to create the Payment Element
elements,
confirmParams: {
return_url: '{!! $return_url !!}',
},
});
if (error) {
const messageContainer = document.querySelector('#errors');
messageContainer.textContent = error.message;
} else {
}
});
</script>
@endpush