Merge pull request #4025 from turbo124/v2

Gateway fees implementation
This commit is contained in:
David Bomba 2020-08-31 17:58:43 +10:00 committed by GitHub
commit bb4aac1aeb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 863 additions and 196 deletions

View File

@ -238,8 +238,13 @@ class CompanySettings extends BaseSettings
public $client_portal_terms = ''; public $client_portal_terms = '';
public $client_portal_privacy_policy = ''; public $client_portal_privacy_policy = '';
public $client_portal_enable_uploads = false; public $client_portal_enable_uploads = false;
public $client_portal_allow_under_payment = false;
public $client_portal_allow_over_payment = false;
public static $casts = [ public static $casts = [
'client_portal_allow_under_payment' => 'bool',
'client_portal_allow_over_payment' => 'bool',
'auto_bill' => 'string', 'auto_bill' => 'string',
'lock_invoices' => 'string', 'lock_invoices' => 'string',
'client_portal_terms' => 'string', 'client_portal_terms' => 'string',

View File

@ -96,7 +96,8 @@ class InvoiceController extends Controller
} }
$invoices->map(function ($invoice) { $invoices->map(function ($invoice) {
$invoice->balance = Number::formatMoney($invoice->balance, $invoice->client); $invoice->balance = Number::formatValue($invoice->balance, $invoice->client->currency());
$invoice->partial = Number::formatValue($invoice->partial, $invoice->client->currency());
return $invoice; return $invoice;
}); });
@ -113,6 +114,8 @@ class InvoiceController extends Controller
'total' => $total, 'total' => $total,
]; ];
//REFACTOR entry point for online payments starts here
return $this->render('invoices.payment', $data); return $this->render('invoices.payment', $data);
} }

View File

@ -14,16 +14,19 @@ namespace App\Http\Controllers\ClientPortal;
use App\Filters\PaymentFilters; use App\Filters\PaymentFilters;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\ClientPortal\Payments\PaymentResponseRequest;
use App\Jobs\Invoice\InjectSignature; use App\Jobs\Invoice\InjectSignature;
use App\Models\CompanyGateway; use App\Models\CompanyGateway;
use App\Models\Invoice; use App\Models\Invoice;
use App\Models\Payment; use App\Models\Payment;
use App\Models\PaymentHash;
use App\Utils\Number; use App\Utils\Number;
use App\Utils\Traits\MakesDates; use App\Utils\Traits\MakesDates;
use App\Utils\Traits\MakesHash; use App\Utils\Traits\MakesHash;
use Cache; use Cache;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Yajra\DataTables\Facades\DataTables; use Yajra\DataTables\Facades\DataTables;
/** /**
@ -72,28 +75,56 @@ class PaymentController extends Controller
*/ */
public function process() public function process()
{ {
$invoices = Invoice::whereIn('id', $this->transformKeys(request()->invoices)) //REFACTOR - Here the request will contain an array of invoices and the amount to be charged for the invoice
->where('company_id', auth('contact')->user()->company->id) //REFACTOR - At this point, we will also need to modify the invoice to include a line item for a gateway fee if applicable
->get(); // This is tagged with a type_id of 3 which is for a pending gateway fee.
//REFACTOR - In order to preserve state we should save the array of invoices and amounts and store it in db/cache and use a HASH
// to rehydrate these values in the payment response.
// dd(request()->all());
$amount = $invoices->sum('balance'); $gateway = CompanyGateway::find(request()->input('company_gateway_id'));
/*find invoices*/
$payable_invoices = request()->payable_invoices;
$invoices = Invoice::whereIn('id', $this->transformKeys(array_column($payable_invoices, 'invoice_id')))->get();
/*filter only payable invoices*/
$invoices = $invoices->filter(function ($invoice) { $invoices = $invoices->filter(function ($invoice) {
return $invoice->isPayable(); return $invoice->isPayable();
}); });
/*return early if no invoices*/
if ($invoices->count() == 0) { if ($invoices->count() == 0) {
return redirect() return redirect()
->route('client.invoices.index') ->route('client.invoices.index')
->with(['warning' => 'No payable invoices selected.']); ->with(['warning' => 'No payable invoices selected.']);
} }
$invoices->map(function ($invoice) { /*iterate through invoices and add gateway fees and other payment metadata*/
$invoice->balance = Number::formatMoney($invoice->balance, $invoice->client);
$invoice->due_date = $this->formatDate($invoice->due_date, $invoice->client->date_format()); foreach($payable_invoices as $key => $payable_invoice)
{
$payable_invoices[$key]['amount'] = Number::parseFloat($payable_invoice['amount']);
$payable_invoice['amount'] = $payable_invoices[$key]['amount'];
$invoice = $invoices->first(function ($inv) use($payable_invoice) {
return $payable_invoice['invoice_id'] == $inv->hashed_id;
});
return $invoice; $payable_invoices[$key]['due_date'] = $this->formatDate($invoice->due_date, $invoice->client->date_format());
}); $payable_invoices[$key]['invoice_number'] = $invoice->number;
if(isset($invoice->po_number))
$additional_info = $invoice->po_number;
elseif(isset($invoice->public_notes))
$additional_info = $invoice->public_notes;
else
$additional_info = $invoice->date;
$payable_invoices[$key]['additional_info'] = $additional_info;
}
if ((bool) request()->signature) { if ((bool) request()->signature) {
$invoices->each(function ($invoice) { $invoices->each(function ($invoice) {
@ -101,20 +132,46 @@ class PaymentController extends Controller
}); });
} }
$payment_methods = auth()->user()->client->getPaymentMethods($amount); $payment_methods = auth()->user()->client->getPaymentMethods(array_sum(array_column($payable_invoices, 'amount_with_fee')));
$gateway = CompanyGateway::find(request()->input('company_gateway_id'));
$payment_method_id = request()->input('payment_method_id'); $payment_method_id = request()->input('payment_method_id');
// Place to calculate gateway fee. $invoice_totals = array_sum(array_column($payable_invoices,'amount'));
$first_invoice = $invoices->first();
$fee_totals = round($gateway->calcGatewayFee($invoice_totals, true), $first_invoice->client->currency()->precision);
if(!$first_invoice->uses_inclusive_taxes) {
$fee_tax = 0;
$fee_tax += round(($first_invoice->tax_rate1/100)*$fee_totals, $first_invoice->client->currency()->precision);
$fee_tax += round(($first_invoice->tax_rate2/100)*$fee_totals, $first_invoice->client->currency()->precision);
$fee_tax += round(($first_invoice->tax_rate3/100)*$fee_totals, $first_invoice->client->currency()->precision);
$fee_totals += $fee_tax;
}
$first_invoice->service()->addGatewayFee($gateway, $invoice_totals)->save();
$payment_hash = new PaymentHash;
$payment_hash->hash = Str::random(128);
$payment_hash->data = $payable_invoices;
$payment_hash->fee_total = $fee_totals;
$payment_hash->fee_invoice_id = $first_invoice->id;
$payment_hash->save();
$totals = [
'invoice_totals' => $invoice_totals,
'fee_total' => $fee_totals,
'amount_with_fee' => $invoice_totals + $fee_totals,
];
$data = [ $data = [
'invoices' => $invoices, 'payment_hash' => $payment_hash->hash,
'amount' => $amount, 'total' => $totals,
'fee' => $gateway->calcGatewayFee($amount), 'invoices' => $payable_invoices,
'amount_with_fee' => $amount + $gateway->calcGatewayFee($amount),
'token' => auth()->user()->client->gateway_token($gateway->id, $payment_method_id), 'token' => auth()->user()->client->gateway_token($gateway->id, $payment_method_id),
'payment_method_id' => $payment_method_id, 'payment_method_id' => $payment_method_id,
'hashed_ids' => request()->invoices, 'amount_with_fee' => $invoice_totals + $fee_totals,
]; ];
return $gateway return $gateway
@ -123,10 +180,26 @@ class PaymentController extends Controller
->processPaymentView($data); ->processPaymentView($data);
} }
public function response(Request $request) public function response(PaymentResponseRequest $request)
{ {
$gateway = CompanyGateway::find($request->input('company_gateway_id')); /*Payment Gateway*/
$gateway = CompanyGateway::find($request->input('company_gateway_id'))->firstOrFail();
//REFACTOR - Entry point for the gateway response - we don't need to do anything at this point.
//
// - Inside each gateway driver, we should use have a generic code path (in BaseDriver.php)for successful/failed payment
//
// Success workflow
//
// - Rehydrate the hash and iterate through the invoices and update the balances
// - Update the type_id of the gateway fee to type_id 4
// - Link invoices to payment
//
// Failure workflow
//
// - Rehydrate hash, iterate through invoices and remove type_id 3's
// - Recalcuate invoice totals
return $gateway return $gateway
->driver(auth()->user()->client) ->driver(auth()->user()->client)
->setPaymentMethod($request->input('payment_method_id')) ->setPaymentMethod($request->input('payment_method_id'))

View File

@ -31,7 +31,6 @@ class MigrationController extends BaseController
parent::__construct(); parent::__construct();
} }
/** /**
* *
* Purge Company * Purge Company

View File

@ -0,0 +1,39 @@
<?php
namespace App\Http\Requests\ClientPortal\Payments;
use App\Models\PaymentHash;
use Illuminate\Foundation\Http\FormRequest;
class PaymentResponseRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize()
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules()
{
return [
'company_gateway_id' => 'required',
'payment_hash' => 'required',
];
}
public function getPaymentHash()
{
$input = $this->all();
return PaymentHash::whereRaw("BINARY `hash`= ?", [$input['payment_hash']])->first();
}
}

View File

@ -91,6 +91,7 @@ class EmailInvoice extends BaseMailerJob implements ShouldQueue
catch (\Swift_TransportException $e) { catch (\Swift_TransportException $e) {
event(new InvoiceWasEmailedAndFailed($this->invoice_invitation->invoice, $this->company, $e->getMessage(), Ninja::eventVars())); event(new InvoiceWasEmailedAndFailed($this->invoice_invitation->invoice, $this->company, $e->getMessage(), Ninja::eventVars()));
} }
if (count(Mail::failures()) > 0) { if (count(Mail::failures()) > 0) {

View File

@ -227,6 +227,14 @@ class Import implements ShouldQueue
unset($data['account_id']); unset($data['account_id']);
} }
if(isset($data['referral_code'])) {
$account = $this->company->account;
$account->referral_code = $data['referral_code'];
$account->save();
unset($data['referral_code']);
}
$company_repository = new CompanyRepository(); $company_repository = new CompanyRepository();
$company_repository->save($data, $this->company); $company_repository->save($data, $this->company);

View File

@ -393,7 +393,7 @@ class Company extends BaseModel
public function system_logs() public function system_logs()
{ {
return $this->hasMany(SystemLog::class); return $this->hasMany(SystemLog::class)->orderBy('id', 'DESC')->take(50);
} }
public function tokens_hashed() public function tokens_hashed()

View File

@ -201,6 +201,23 @@ class CompanyGateway extends BaseModel
return floatval($this->fee_amount) || floatval($this->fee_percent); return floatval($this->fee_amount) || floatval($this->fee_percent);
} }
/**
* Returns the current test mode of the gateway
*
* @return boolean whether the gateway is in testmode or not.
*/
public function isTestMode() :bool
{
$config = $this->getConfig();
if($this->gateway->provider == 'Stripe' && strpos($config->publishableKey, 'test'))
return true;
if($config && property_exists($config, 'testMode') && $config->testMode)
return true;
return false;
}
/** /**
* Get Publishable Key * Get Publishable Key
* Only works for STRIPE and PAYMILL * Only works for STRIPE and PAYMILL
@ -211,6 +228,20 @@ class CompanyGateway extends BaseModel
return $this->getConfigField('publishableKey'); return $this->getConfigField('publishableKey');
} }
public function getFeesAndLimits()
{
if (is_null($this->fees_and_limits))
return false;
$fees_and_limits = new \stdClass;
foreach($this->fees_and_limits as $key => $value) {
$fees_and_limits = $this->fees_and_limits->{$key};
}
return $fees_and_limits;
}
/** /**
* Returns the formatted fee amount for the gateway * Returns the formatted fee amount for the gateway
* *
@ -236,17 +267,13 @@ class CompanyGateway extends BaseModel
return $label; return $label;
} }
public function calcGatewayFee($amount) public function calcGatewayFee($amount, $include_taxes = false)
{ {
if (is_null($this->fees_and_limits)) {
$fees_and_limits = $this->getFeesAndLimits();
if(!$fees_and_limits)
return 0; return 0;
}
$fees_and_limits = new \stdClass;
foreach($this->fees_and_limits as $key => $value) {
$fees_and_limits = $this->fees_and_limits->{$key};
}
$fee = 0; $fee = 0;
@ -259,30 +286,68 @@ class CompanyGateway extends BaseModel
$fee += $amount * $fees_and_limits->fee_percent / 100; $fee += $amount * $fees_and_limits->fee_percent / 100;
info("fee after adding fee percent = {$fee}"); info("fee after adding fee percent = {$fee}");
} }
$pre_tax_fee = $fee;
if ($fees_and_limits->fee_tax_rate1) {
$fee += $pre_tax_fee * $fees_and_limits->fee_tax_rate1 / 100;
info("fee after adding fee tax 1 = {$fee}");
}
if ($fees_and_limits->fee_tax_rate2) {
$fee += $pre_tax_fee * $fees_and_limits->fee_tax_rate2 / 100;
info("fee after adding fee tax 2 = {$fee}");
}
if ($fees_and_limits->fee_tax_rate3) {
$fee += $pre_tax_fee * $fees_and_limits->fee_tax_rate3 / 100;
info("fee after adding fee tax 3 = {$fee}");
}
/* Cap fee if we have to here. */
if($fees_and_limits->fee_cap > 0 && ($fee > $fees_and_limits->fee_cap)) if($fees_and_limits->fee_cap > 0 && ($fee > $fees_and_limits->fee_cap))
$fee = $fees_and_limits->fee_cap; $fee = $fees_and_limits->fee_cap;
$pre_tax_fee = $fee;
/**/
if($include_taxes)
{
if ($fees_and_limits->fee_tax_rate1) {
$fee += $pre_tax_fee * $fees_and_limits->fee_tax_rate1 / 100;
info("fee after adding fee tax 1 = {$fee}");
}
if ($fees_and_limits->fee_tax_rate2) {
$fee += $pre_tax_fee * $fees_and_limits->fee_tax_rate2 / 100;
info("fee after adding fee tax 2 = {$fee}");
}
if ($fees_and_limits->fee_tax_rate3) {
$fee += $pre_tax_fee * $fees_and_limits->fee_tax_rate3 / 100;
info("fee after adding fee tax 3 = {$fee}");
}
}
return $fee; return $fee;
} }
/**
* we need to average out the gateway fees across all the invoices
* so lets iterate.
*
* we MAY need to adjust the final fee to ensure our rounding makes sense!
*/
public function calcGatewayFeeObject($amount, $invoice_count)
{
$total_gateway_fee = $this->calcGatewayFee($amount);
$fee_object = new \stdClass;
$fees_and_limits = $this->getFeesAndLimits();
if(!$fees_and_limits)
return $fee_object;
$fee_component_amount = $fees_and_limits->fee_amount ?: 0;
$fee_component_percent = $fees_and_limits->fee_percent ? ($amount * $fees_and_limits->fee_percent / 100) : 0;
$combined_fee_component = $fee_component_amount + $fee_component_percent;
$fee_component_tax_name1 = $fees_and_limits->fee_tax_name1 ?: '';
$fee_component_tax_rate1 = $fees_and_limits->fee_tax_rate1 ? ($combined_fee_component * $fees_and_limits->fee_tax_rate1 / 100) : 0;
$fee_component_tax_name2 = $fees_and_limits->fee_tax_name2 ?: '';
$fee_component_tax_rate2 = $fees_and_limits->fee_tax_rate2 ? ($combined_fee_component * $fees_and_limits->fee_tax_rate2 / 100) : 0;
$fee_component_tax_name3 = $fees_and_limits->fee_tax_name3 ?: '';
$fee_component_tax_rate3 = $fees_and_limits->fee_tax_rate3 ? ($combined_fee_component * $fees_and_limits->fee_tax_rate3 / 100) : 0;
}
public function resolveRouteBinding($value) public function resolveRouteBinding($value)
{ {
return $this return $this

View File

@ -0,0 +1,29 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com)
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2020. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://opensource.org/licenses/AAL
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class PaymentHash extends Model
{
protected $guarded = ['id'];
protected $casts = [
'data' => 'object'
];
public function invoices()
{
return $this->data;
}
}

View File

@ -14,6 +14,8 @@ class CompanyGatewayObserver
*/ */
public function created(CompanyGateway $company_gateway) public function created(CompanyGateway $company_gateway)
{ {
/* Set company gateway if not exists*/
if(!$company_gateway->label){ if(!$company_gateway->label){
$company_gateway->label = $company_gateway->gateway->name; $company_gateway->label = $company_gateway->gateway->name;
$company_gateway->save(); $company_gateway->save();

View File

@ -14,6 +14,7 @@ namespace App\PaymentDrivers;
use App\Events\Invoice\InvoiceWasPaid; use App\Events\Invoice\InvoiceWasPaid;
use App\Factory\PaymentFactory; use App\Factory\PaymentFactory;
use App\Http\Requests\ClientPortal\Payments\PaymentResponseRequest;
use App\Models\Client; use App\Models\Client;
use App\Models\ClientGatewayToken; use App\Models\ClientGatewayToken;
use App\Models\CompanyGateway; use App\Models\CompanyGateway;
@ -158,4 +159,40 @@ class BaseDriver extends AbstractPaymentDriver
* @return Response The payment response * @return Response The payment response
*/ */
public function tokenBilling(ClientGatewayToken $cgt, float $amount, ?Invoice $invoice = null) {} public function tokenBilling(ClientGatewayToken $cgt, float $amount, ?Invoice $invoice = null) {}
/**
* When a successful payment is made, we need to append the gateway fee
* to an invoice
*
* @param PaymentResponseRequest $request The incoming payment request
* @return void Success/Failure
*/
public function confirmGatewayFee(PaymentResponseRequest $request) :void
{
/*Payment meta data*/
$payment_hash = $request->getPaymentHash();
/*Payment invoices*/
$payment_invoices = $payment_hash->invoices();
// /*Fee charged at gateway*/
$fee_total = $payment_hash->fee_total;
// Sum of invoice amounts
// $invoice_totals = array_sum(array_column($payment_invoices,'amount'));
/*Hydrate invoices*/
$invoices = Invoice::whereIn('id', $this->transformKeys(array_column($payment_invoices, 'invoice_id')))->get();
$invoices->each(function($invoice) use($fee_total){
if(collect($invoice->line_items)->contains('type_id', '3')){
$invoice->service()->toggleFeesPaid()->save();
$invoice->client->service()->updateBalance($fee_total)->save();
$invoice->ledger()->updateInvoiceBalance($fee_total, $notes = 'Gateway fee adjustment');
}
});
}
} }

View File

@ -13,6 +13,7 @@
namespace App\PaymentDrivers; namespace App\PaymentDrivers;
use App\Factory\PaymentFactory; use App\Factory\PaymentFactory;
use App\Http\Requests\ClientPortal\Payments\PaymentResponseRequest;
use App\Models\Client; use App\Models\Client;
use App\Models\ClientContact; use App\Models\ClientContact;
use App\Models\CompanyGateway; use App\Models\CompanyGateway;
@ -286,4 +287,41 @@ class BasePaymentDriver
return $payment; return $payment;
} }
/**
* When a successful payment is made, we need to append the gateway fee
* to an invoice
*
* @param PaymentResponseRequest $request The incoming payment request
* @return void Success/Failure
*/
public function confirmGatewayFee(PaymentResponseRequest $request) :void
{
/*Payment meta data*/
$payment_hash = $request->getPaymentHash();
/*Payment invoices*/
$payment_invoices = $payment_hash->invoices();
// /*Fee charged at gateway*/
$fee_total = $payment_hash->fee_total;
// Sum of invoice amounts
// $invoice_totals = array_sum(array_column($payment_invoices,'amount'));
/*Hydrate invoices*/
$invoices = Invoice::whereIn('id', $this->transformKeys(array_column($payment_invoices, 'invoice_id')))->get();
$invoices->each(function($invoice) use($fee_total){
if(collect($invoice->line_items)->contains('type_id', '3')){
$invoice->service()->toggleFeesPaid()->save();
$invoice->client->service()->updateBalance($fee_total)->save();
$invoice->ledger()->updateInvoiceBalance($fee_total, $notes = 'Gateway fee adjustment');
}
});
}
} }

View File

@ -19,6 +19,7 @@ use App\Models\ClientGatewayToken;
use App\Models\GatewayType; use App\Models\GatewayType;
use App\Models\Invoice; use App\Models\Invoice;
use App\Models\Payment; use App\Models\Payment;
use App\Models\PaymentHash;
use App\Models\PaymentType; use App\Models\PaymentType;
use App\Models\SystemLog; use App\Models\SystemLog;
use App\PaymentDrivers\StripePaymentDriver; use App\PaymentDrivers\StripePaymentDriver;
@ -92,7 +93,7 @@ class CreditCard
'amount' => $this->stripe->convertToStripeAmount($data['amount_with_fee'], $this->stripe->client->currency()->precision), 'amount' => $this->stripe->convertToStripeAmount($data['amount_with_fee'], $this->stripe->client->currency()->precision),
'currency' => $this->stripe->client->getCurrencyCode(), 'currency' => $this->stripe->client->getCurrencyCode(),
'customer' => $this->stripe->findOrCreateCustomer(), 'customer' => $this->stripe->findOrCreateCustomer(),
'description' => $data['invoices']->pluck('id'), //todo more meaningful description here: 'description' => collect($data['invoices'])->pluck('id'), //todo more meaningful description here:
]; ];
if ($data['token']) { if ($data['token']) {
@ -113,6 +114,8 @@ class CreditCard
{ {
$server_response = json_decode($request->input('gateway_response')); $server_response = json_decode($request->input('gateway_response'));
$payment_hash = PaymentHash::whereRaw("BINARY `hash`= ?", [$request->input('payment_hash')])->firstOrFail();
$state = [ $state = [
'payment_method' => $server_response->payment_method, 'payment_method' => $server_response->payment_method,
'payment_status' => $server_response->status, 'payment_status' => $server_response->status,
@ -120,9 +123,10 @@ class CreditCard
'gateway_type_id' => $request->payment_method_id, 'gateway_type_id' => $request->payment_method_id,
'hashed_ids' => $request->hashed_ids, 'hashed_ids' => $request->hashed_ids,
'server_response' => $server_response, 'server_response' => $server_response,
'payment_hash' => $payment_hash,
]; ];
$invoices = Invoice::whereIn('id', $this->stripe->transformKeys($state['hashed_ids'])) $invoices = Invoice::whereIn('id', $this->stripe->transformKeys(array_column($payment_hash->invoices(), 'invoice_id')))
->whereClientId($this->stripe->client->id) ->whereClientId($this->stripe->client->id)
->get(); ->get();
@ -138,6 +142,10 @@ class CreditCard
$state['customer'] = $state['payment_intent']->customer; $state['customer'] = $state['payment_intent']->customer;
if ($state['payment_status'] == 'succeeded') { if ($state['payment_status'] == 'succeeded') {
/* Add gateway fees if needed! */
$this->stripe->confirmGatewayFee($request);
return $this->processSuccessfulPayment($state); return $this->processSuccessfulPayment($state);
} }
@ -183,7 +191,7 @@ class CreditCard
$this->stripe->attachInvoices($payment, $state['hashed_ids']); $this->stripe->attachInvoices($payment, $state['hashed_ids']);
$payment->service()->updateInvoicePayment(); $payment->service()->updateInvoicePayment($state['payment_hash']);
event(new PaymentWasCreated($payment, $payment->company, Ninja::eventVars())); event(new PaymentWasCreated($payment, $payment->company, Ninja::eventVars()));

View File

@ -0,0 +1,132 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com)
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2020. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://opensource.org/licenses/AAL
*/
namespace App\Services\Invoice;
use App\DataMapper\InvoiceItem;
use App\Events\Payment\PaymentWasCreated;
use App\Factory\PaymentFactory;
use App\Models\Client;
use App\Models\CompanyGateway;
use App\Models\Invoice;
use App\Models\Payment;
use App\Services\AbstractService;
use App\Services\Client\ClientService;
use App\Services\Payment\PaymentService;
use App\Utils\Traits\GeneratesCounter;
class AddGatewayFee extends AbstractService
{
private $company_gateway;
private $invoice;
private $amount;
public function __construct(CompanyGateway $company_gateway, Invoice $invoice, float $amount)
{
$this->company_gateway = $company_gateway;
$this->invoice = $invoice;
$this->amount = $amount;
}
public function run()
{
$gateway_fee = round($this->company_gateway->calcGatewayFee($this->amount), $this->invoice->client->currency()->precision);
$this->cleanPendingGatewayFees();
if($gateway_fee > 0)
return $this->processGatewayFee($gateway_fee);
return $this->processGatewayDiscount($gateway_fee);
}
private function cleanPendingGatewayFees()
{
$invoice_items = $this->invoice->line_items;
$invoice_items = collect($invoice_items)->filter(function ($item){
return $item->type_id != '3';
});
$this->invoice->line_items = $invoice_items;
return $this;
}
private function processGatewayFee($gateway_fee)
{
$invoice_item = new InvoiceItem;
$invoice_item->type_id = '3';
$invoice_item->product_key = ctrans('texts.surcharge');
$invoice_item->notes = ctrans('texts.online_payment_surcharge');
$invoice_item->quantity = 1;
$invoice_item->cost = $gateway_fee;
if($fees_and_limits = $this->company_gateway->getFeesAndLimits())
{
$invoice_item->tax_rate1 = $fees_and_limits->fee_tax_rate1;
$invoice_item->tax_rate2 = $fees_and_limits->fee_tax_rate2;
$invoice_item->tax_rate3 = $fees_and_limits->fee_tax_rate3;
}
$invoice_items = $this->invoice->line_items;
$invoice_items[] = $invoice_item;
$this->invoice->line_items = $invoice_items;
/**Refresh Invoice values*/
$this->invoice = $this->invoice->calc()->getInvoice();
/*Update client balance*/ // don't increment until we have process the payment!
//$this->invoice->client->service()->updateBalance($gateway_fee)->save();
//$this->invoice->ledger()->updateInvoiceBalance($gateway_fee, $notes = 'Gateway fee adjustment');
return $this->invoice;
}
private function processGatewayDiscount($gateway_fee)
{
$invoice_item = new InvoiceItem;
$invoice_item->type_id = '3';
$invoice_item->product_key = ctrans('texts.discount');
$invoice_item->notes = ctrans('texts.online_payment_discount');
$invoice_item->quantity = 1;
$invoice_item->cost = $gateway_fee;
if($fees_and_limits = $this->company_gateway->getFeesAndLimits())
{
$invoice_item->tax_rate1 = $fees_and_limits->fee_tax_rate1;
$invoice_item->tax_rate2 = $fees_and_limits->fee_tax_rate2;
$invoice_item->tax_rate3 = $fees_and_limits->fee_tax_rate3;
}
$invoice_items = $this->invoice->line_items;
$invoice_items[] = $invoice_item;
$this->invoice->line_items = $invoice_items;
$this->invoice = $this->invoice->calc()->getInvoice();
// $this->invoice->client->service()->updateBalance($gateway_fee)->save();
// $this->invoice->ledger()->updateInvoiceBalance($gateway_fee, $notes = 'Discount fee adjustment');
return $this->invoice;
}
}

View File

@ -62,17 +62,16 @@ class AutoBillInvoice extends AbstractService
$amount = $this->invoice->balance + $fee; $amount = $this->invoice->balance + $fee;
} }
/* Make sure we remove any stale fees*/
$this->purgeStaleGatewayFees();
if($fee > 0)
$this->addFeeToInvoice($fee);
$payment = $gateway_token->gateway->driver($this->client)->tokenBilling($gateway_token, $amount, $this->invoice); $payment = $gateway_token->gateway->driver($this->client)->tokenBilling($gateway_token, $amount, $this->invoice);
if($payment){ if($payment){
$this->invoice = $this->invoice->service()->toggleFeesPaid()->save(); if($this->invoice->partial > 0)
$amount = $this->invoice->partial;
else
$amount = $this->invoice->balance;
$this->invoice = $this->invoice->service()->addGatewayFee($gateway_token->gateway, $amount)->save();
} }
else else
@ -144,34 +143,34 @@ class AutoBillInvoice extends AbstractService
* *
* @return $this * @return $this
*/ */
private function purgeStaleGatewayFees() // private function purgeStaleGatewayFees()
{ // {
$starting_amount = $this->invoice->amount; // $starting_amount = $this->invoice->amount;
$line_items = $this->invoice->line_items; // $line_items = $this->invoice->line_items;
$new_items = []; // $new_items = [];
foreach($line_items as $item) // foreach($line_items as $item)
{ // {
if($item->type_id != 3) // if($item->type_id != 3)
$new_items[] = $item; // $new_items[] = $item;
} // }
$this->invoice->line_items = $new_items; // $this->invoice->line_items = $new_items;
$this->invoice->save(); // $this->invoice->save();
$this->invoice = $this->invoice->calc()->getInvoice(); // $this->invoice = $this->invoice->calc()->getInvoice();
if($starting_amount != $this->invoice->amount && $this->invoice->status_id != Invoice::STATUS_DRAFT){ // if($starting_amount != $this->invoice->amount && $this->invoice->status_id != Invoice::STATUS_DRAFT){
$this->invoice->client->service()->updateBalance($this->invoice->amount - $starting_amount)->save(); // $this->invoice->client->service()->updateBalance($this->invoice->amount - $starting_amount)->save();
$this->invoice->ledger()->updateInvoiceBalance($this->invoice->amount - $starting_amount, 'Invoice balance updated after stale gateway fee removed')->save(); // $this->invoice->ledger()->updateInvoiceBalance($this->invoice->amount - $starting_amount, 'Invoice balance updated after stale gateway fee removed')->save();
} // }
return $this; // return $this;
} // }
/** /**
* Checks whether a given gateway token is able * Checks whether a given gateway token is able

View File

@ -11,6 +11,7 @@
namespace App\Services\Invoice; namespace App\Services\Invoice;
use App\Models\CompanyGateway;
use App\Models\Invoice; use App\Models\Invoice;
use App\Models\Payment; use App\Models\Payment;
use App\Services\Client\ClientService; use App\Services\Client\ClientService;
@ -76,11 +77,20 @@ class InvoiceService
return $this; return $this;
} }
public function addGatewayFee(CompanyGateway $company_gateway, float $amount)
{
$this->invoice = (new AddGatewayFee($company_gateway, $this->invoice, $amount))->run();
return $this;
}
/** /**
* Update an invoice balance * Update an invoice balance
*
* @param float $balance_adjustment The amount to adjust the invoice by * @param float $balance_adjustment The amount to adjust the invoice by
* a negative amount will REDUCE the invoice balance, a positive amount will INCREASE * a negative amount will REDUCE the invoice balance, a positive amount will INCREASE
* the invoice balance * the invoice balance
*
* @return InvoiceService Parent class object * @return InvoiceService Parent class object
*/ */
public function updateBalance($balance_adjustment) public function updateBalance($balance_adjustment)

View File

@ -56,6 +56,10 @@ class TriggeredActions extends AbstractService
$this->sendEmail(); $this->sendEmail();
} }
if($this->request->has('mark_sent') && $this->request->input('mark_sent') == 'true'){
$this->invoice = $this->invoice->service()->markSent()->save();
}
return $this->invoice; return $this->invoice;
} }

View File

@ -37,8 +37,6 @@ class UpdateBalance extends AbstractService
if ($this->invoice->balance == 0) { if ($this->invoice->balance == 0) {
$this->invoice->status_id = Invoice::STATUS_PAID; $this->invoice->status_id = Invoice::STATUS_PAID;
// $this->save();
// event(new InvoiceWasPaid($this, $this->company));
} }
return $this->invoice; return $this->invoice;

View File

@ -84,9 +84,9 @@ class PaymentService
return (new DeletePayment($this->payment))->run(); return (new DeletePayment($this->payment))->run();
} }
public function updateInvoicePayment() :?Payment public function updateInvoicePayment($payment_hash = null) :?Payment
{ {
return ((new UpdateInvoicePayment($this->payment)))->run(); return ((new UpdateInvoicePayment($this->payment, $payment_hash)))->run();
} }
public function applyNumber() public function applyNumber()

View File

@ -17,120 +17,89 @@ use App\Jobs\Payment\EmailPayment;
use App\Jobs\Util\SystemLogger; use App\Jobs\Util\SystemLogger;
use App\Models\Invoice; use App\Models\Invoice;
use App\Models\SystemLog; use App\Models\SystemLog;
use App\Utils\Traits\MakesHash;
class UpdateInvoicePayment class UpdateInvoicePayment
{ {
use MakesHash;
/** /**
* @deprecated This is bad logic, assumes too much. * @deprecated This is bad logic, assumes too much.
*/ */
public $payment; public $payment;
public function __construct($payment) public $payment_hash;
public function __construct($payment, $payment_hash)
{ {
$this->payment = $payment; $this->payment = $payment;
$this->payment_hash = $payment_hash;
} }
public function run() public function run()
{ {
$invoices = $this->payment->invoices()->get(); // $invoices = $this->payment->invoices()->get();
// $invoices_total = $invoices->sum('balance');
$invoices_total = $invoices->sum('balance'); $paid_invoices = $this->payment_hash->invoices();
$invoices = Invoice::whereIn('id', $this->transformKeys(array_column($paid_invoices, 'invoice_id')))->get();
/* Simplest scenario - All invoices are paid in full*/ collect($paid_invoices)->each(function ($paid_invoice) use($invoices) {
if (strval($invoices_total) === strval($this->payment->amount)) {
$invoices->each(function ($invoice) {
$this->payment
->ledger()
->updatePaymentBalance($invoice->balance*-1);
$this->payment->client
->service()
->updateBalance($invoice->balance*-1)
->updatePaidToDate($invoice->balance)
->save();
$invoice->pivot->amount = $invoice->balance;
$invoice->pivot->save();
$invoice->service() $invoice = $invoices->first(function ($inv) use($paid_invoice) {
->clearPartial() return $paid_invoice->invoice_id == $inv->hashed_id;
->updateBalance($invoice->balance*-1)
->save();
}); });
}
/*Combination of partials and full invoices are being paid*/
else {
$total = 0;
/* Calculate the grand total of the invoices*/ if($invoice->id == $this->payment_hash->fee_invoice_id)
foreach ($invoices as $invoice) { $paid_amount = $paid_invoice->amount + $this->payment_hash->fee_total;
if ($invoice->hasPartial()) { else
$total += $invoice->partial; $paid_amount = $paid_invoice->amount;
} else {
$total += $invoice->balance;
}
}
/*Test if there is a batch of partial invoices that have been paid */ $this->payment
if ($this->payment->amount == $total) { ->ledger()
$invoices->each(function ($invoice) { ->updatePaymentBalance($paid_amount*-1);
if ($invoice->hasPartial()) {
$this->payment
->ledger()
->updatePaymentBalance($invoice->partial*-1);
$this->payment->client->service() $this->payment
->updateBalance($invoice->partial*-1) ->client
->updatePaidToDate($invoice->partial) ->service()
->save(); ->updateBalance($paid_amount*-1)
->updatePaidToDate($paid_amount)
->save();
$invoice->pivot->amount = $invoice->partial; /*i think to interact with this correct - we need to do this form $payment->invoice()->pivot*/
$invoice->pivot->save(); // $invoice->pivot->amount = $paid_amount;
// $invoice->pivot->save();
$invoice->service()->updateBalance($invoice->partial*-1) $invoice->service() //caution what if we amount paid was less than partial - we wipe it!
->clearPartial() ->clearPartial()
->setDueDate() ->updateBalance($paid_amount*-1)
->setStatus(Invoice::STATUS_PARTIAL) ->save();
->save();
} else {
$this->payment
->ledger()
->updatePaymentBalance($invoice->balance*-1);
$this->payment->client->service() });
->updateBalance($invoice->balance*-1)
->updatePaidToDate($invoice->balance)
->save();
$invoice->pivot->amount = $invoice->balance; // } else {
$invoice->pivot->save(); // SystemLogger::dispatch(
// [
$invoice->service()->clearPartial()->updateBalance($invoice->balance*-1)->save(); // 'payment' => $this->payment,
} // 'invoices' => $invoices,
}); // 'invoices_total' => $invoices_total,
} else { // 'payment_amount' => $this->payment->amount,
SystemLogger::dispatch( // 'partial_check_amount' => $total,
[ // ],
'payment' => $this->payment, // SystemLog::CATEGORY_GATEWAY_RESPONSE,
'invoices' => $invoices, // SystemLog::EVENT_PAYMENT_RECONCILIATION_FAILURE,
'invoices_total' => $invoices_total, // SystemLog::TYPE_LEDGER,
'payment_amount' => $this->payment->amount, // $this->payment->client
'partial_check_amount' => $total, // );
],
SystemLog::CATEGORY_GATEWAY_RESPONSE,
SystemLog::EVENT_PAYMENT_RECONCILIATION_FAILURE,
SystemLog::TYPE_LEDGER,
$this->payment->client
);
throw new \Exception("payment amount {$this->payment->amount} does not match invoice totals {$invoices_total} reversing payment"); // throw new \Exception("payment amount {$this->payment->amount} does not match invoice totals {$invoices_total} reversing payment");
$this->payment->invoice()->delete(); // $this->payment->invoice()->delete();
$this->payment->is_deleted=true; // $this->payment->is_deleted=true;
$this->payment->save(); // $this->payment->save();
$this->payment->delete(); // $this->payment->delete();
} // }
}
return $this->payment; return $this->payment;
} }

View File

@ -64,6 +64,7 @@ class CompanyGatewayTransformer extends EntityTransformer
'custom_value4' => $company_gateway->custom_value4 ?: '', 'custom_value4' => $company_gateway->custom_value4 ?: '',
'label' => (string)$company_gateway->label ?: '', 'label' => (string)$company_gateway->label ?: '',
'token_billing' => (string)$company_gateway->token_billing, 'token_billing' => (string)$company_gateway->token_billing,
'test_mode' => (bool)$company_gateway->isTestMode(),
]; ];
} }

View File

@ -33,12 +33,12 @@ class Number
/** /**
* Formats a given value based on the clients currency * Formats a given value based on the clients currency
* *
* @param float $value The number to be formatted * @param float $value The number to be formatted
* @param object $currency The client currency object * @param object $currency The client currency object
* *
* @return float The formatted value * @return string The formatted value
*/ */
public static function formatValue($value, $currency) : float public static function formatValue($value, $currency) :string
{ {
$value = floatval($value); $value = floatval($value);
@ -49,6 +49,30 @@ class Number
return number_format($value, $precision, $decimal, $thousand); return number_format($value, $precision, $decimal, $thousand);
} }
/**
* Formats a given value based on the clients currency
* BACK to a float
*
* @param string $value The formatted number to be converted back to float
* @param object $currency The client currency object
*
* @return float The formatted value
*/
public static function parseFloat($value)
{
// convert "," to "."
$s = str_replace(',', '.', $value);
// remove everything except numbers and dot "."
$s = preg_replace("/[^0-9\.]/", "", $s);
// remove all seperators from first part and keep the end
$s = str_replace('.', '',substr($s, 0, -3)) . substr($s, -3);
// return float
return (float) $s;
}
/** /**
* Formats a given value based on the clients currency AND country * Formats a given value based on the clients currency AND country
* *

View File

@ -71,8 +71,8 @@ class SystemHealth
'env_writable' => self::checkEnvWritable(), 'env_writable' => self::checkEnvWritable(),
//'mail' => self::testMailServer(), //'mail' => self::testMailServer(),
'simple_db_check' => (bool) self::simpleDbCheck(), 'simple_db_check' => (bool) self::simpleDbCheck(),
'npm_status' => self::checkNpm(), //'npm_status' => self::checkNpm(),
'node_status' => self::checkNode(), //'node_status' => self::checkNode(),
]; ];
} }

View File

@ -35,6 +35,26 @@ class AddIsPublicToDocumentsTable extends Migration
$table->softDeletes('deleted_at', 6); $table->softDeletes('deleted_at', 6);
}); });
Schema::create('payment_hashes', function ($table) {
$table->increments('id');
$table->string('hash', 255);
$table->decimal('fee_total', 16, 4);
$table->unsignedInteger('fee_invoice_id')->nullable();
$table->mediumText('data');
$table->timestamps(6);
});
Schema::table('recurring_invoices', function ($table) {
$table->string('auto_bill');
});
// Schema::table('recurring_expenses', function ($table) {
// $table->string('auto_bill');
// });
Schema::table('companies', function ($table) {
$table->enum('default_auto_bill', ['off', 'always','optin','optout'])->default('off');
});
} }

View File

@ -11,6 +11,11 @@
<style> <style>
/* fix for blurry fonts */
flt-glass-pane {
image-rendering: pixelated;
}
/* https://projects.lukehaas.me/css-loaders/ */ /* https://projects.lukehaas.me/css-loaders/ */
.loader, .loader,
.loader:before, .loader:before,

View File

@ -11,7 +11,7 @@
{!! Former::hidden('gateway_response')->id('gateway_response') !!} {!! Former::hidden('gateway_response')->id('gateway_response') !!}
{!! Former::hidden('store_card')->id('store_card') !!} {!! Former::hidden('store_card')->id('store_card') !!}
{!! Former::hidden('hashed_ids')->value($hashed_ids) !!} {!! Former::hidden('payment_hash')->value($payment_hash) !!}
{!! Former::hidden('company_gateway_id')->value($payment_method_id) !!} {!! Former::hidden('company_gateway_id')->value($payment_method_id) !!}
{!! Former::hidden('payment_method_id')->value($gateway->getCompanyGatewayId()) !!} {!! Former::hidden('payment_method_id')->value($gateway->getCompanyGatewayId()) !!}
{!! Former::close() !!} {!! Former::close() !!}

View File

@ -13,9 +13,7 @@
@section('body') @section('body')
<form action="{{ route('client.payments.response') }}" method="post" id="server_response"> <form action="{{ route('client.payments.response') }}" method="post" id="server_response">
@csrf @csrf
@foreach($invoices as $invoice) <input type="hidden" name="payment_hash" value="{{ $payment_hash }}">
<input type="hidden" name="hashed_ids[]" value="{{ $invoice->hashed_id }}">
@endforeach
<input type="hidden" name="company_gateway_id" value="{{ $gateway->id }}"> <input type="hidden" name="company_gateway_id" value="{{ $gateway->id }}">
<input type="hidden" name="payment_method_id" value="1"> <input type="hidden" name="payment_method_id" value="1">
<input type="hidden" name="gateway_response" id="gateway_response"> <input type="hidden" name="gateway_response" id="gateway_response">
@ -23,7 +21,7 @@
<input type="hidden" name="dataDescriptor" id="dataDescriptor" /> <input type="hidden" name="dataDescriptor" id="dataDescriptor" />
<input type="hidden" name="token" id="token" /> <input type="hidden" name="token" id="token" />
<input type="hidden" name="store_card" id="store_card" /> <input type="hidden" name="store_card" id="store_card" />
<input type="hidden" name="amount_with_fee" id="amount_with_fee" value="{{ $amount_with_fee }}" /> <input type="hidden" name="amount_with_fee" id="amount_with_fee" value="{{ $total['amount_with_fee'] }}" />
</form> </form>
<div class="container mx-auto"> <div class="container mx-auto">
<div class="grid grid-cols-6 gap-4"> <div class="grid grid-cols-6 gap-4">
@ -40,10 +38,22 @@
<dl> <dl>
<div class="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6"> <div class="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt class="text-sm leading-5 font-medium text-gray-500"> <dt class="text-sm leading-5 font-medium text-gray-500">
{{ ctrans('texts.amount') }} {{ ctrans('texts.subtotal') }}
</dt> </dt>
<dd class="mt-1 text-sm leading-5 text-gray-900 sm:mt-0 sm:col-span-2"> <dd class="mt-1 text-sm leading-5 text-gray-900 sm:mt-0 sm:col-span-2">
{{ App\Utils\Number::formatMoney($amount_with_fee, $client) }} {{ App\Utils\Number::formatMoney($total['invoice_totals'], $client) }}
</dd>
<dt class="text-sm leading-5 font-medium text-gray-500">
{{ ctrans('texts.gateway_fees') }}
</dt>
<dd class="mt-1 text-sm leading-5 text-gray-900 sm:mt-0 sm:col-span-2">
{{ App\Utils\Number::formatMoney($total['fee_total'], $client) }}
</dd>
<dt class="text-sm leading-5 font-medium text-gray-500">
{{ ctrans('texts.total') }}
</dt>
<dd class="mt-1 text-sm leading-5 text-gray-900 sm:mt-0 sm:col-span-2">
{{ App\Utils\Number::formatMoney($total['amount_with_fee'], $client) }}
</dd> </dd>
</div> </div>
@ -67,11 +77,23 @@
<div> <div>
<dl> <dl>
<div class="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6"> <div class="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt class="text-sm leading-5 font-medium text-gray-500">
{{ ctrans('texts.totals') }}
</dt>
<dd class="mt-1 text-sm leading-5 text-gray-900 sm:mt-0 sm:col-span-2">
{{ App\Utils\Number::formatMoney($total['invoice_totals'], $client) }}
</dd>
<dt class="text-sm leading-5 font-medium text-gray-500">
{{ ctrans('texts.gateway_fees') }}
</dt>
<dd class="mt-1 text-sm leading-5 text-gray-900 sm:mt-0 sm:col-span-2">
{{ App\Utils\Number::formatMoney($total['fee_total'], $client) }}
</dd>
<dt class="text-sm leading-5 font-medium text-gray-500"> <dt class="text-sm leading-5 font-medium text-gray-500">
{{ ctrans('texts.amount') }} {{ ctrans('texts.amount') }}
</dt> </dt>
<dd class="mt-1 text-sm leading-5 text-gray-900 sm:mt-0 sm:col-span-2"> <dd class="mt-1 text-sm leading-5 text-gray-900 sm:mt-0 sm:col-span-2">
{{ App\Utils\Number::formatMoney($amount_with_fee, $client) }} {{ App\Utils\Number::formatMoney($total['amount_with_fee'], $client) }}
</dd> </dd>
</div> </div>
@foreach($tokens as $token) @foreach($tokens as $token)

View File

@ -11,9 +11,8 @@
@csrf @csrf
<input type="hidden" name="gateway_response"> <input type="hidden" name="gateway_response">
<input type="hidden" name="store_card"> <input type="hidden" name="store_card">
@foreach($invoices as $invoice) <input type="hidden" name="payment_hash" value="{{$payment_hash}}">
<input type="hidden" name="hashed_ids[]" value="{{ $invoice->hashed_id }}">
@endforeach
<input type="hidden" name="company_gateway_id" value="{{ $gateway->getCompanyGatewayId() }}"> <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_method_id" value="{{ $payment_method_id }}">
</form> </form>

View File

@ -10,13 +10,10 @@
@section('body') @section('body')
<form action="{{ route('client.payments.process') }}" method="post" id="payment-form"> <form action="{{ route('client.payments.process') }}" method="post" id="payment-form">
@csrf @csrf
@foreach($invoices as $invoice)
<input type="hidden" name="invoices[]" value="{{ $invoice->hashed_id }}">
@endforeach
<input type="hidden" name="company_gateway_id" id="company_gateway_id"> <input type="hidden" name="company_gateway_id" id="company_gateway_id">
<input type="hidden" name="payment_method_id" id="payment_method_id"> <input type="hidden" name="payment_method_id" id="payment_method_id">
<input type="hidden" name="signature"> <input type="hidden" name="signature">
</form>
<div class="container mx-auto"> <div class="container mx-auto">
<div class="grid grid-cols-6 gap-4"> <div class="grid grid-cols-6 gap-4">
<div class="col-span-6 md:col-start-2 md:col-span-4"> <div class="col-span-6 md:col-start-2 md:col-span-4">
@ -63,7 +60,8 @@
</div> </div>
</div> </div>
@foreach($invoices as $invoice) @foreach($invoices as $key => $invoice)
<input type="hidden" name="payable_invoices[{{$key}}][invoice_id]" value="{{ $invoice->hashed_id }}">
<div class="bg-white shadow overflow-hidden sm:rounded-lg mb-4"> <div class="bg-white shadow overflow-hidden sm:rounded-lg mb-4">
<div class="px-4 py-5 border-b border-gray-200 sm:px-6"> <div class="px-4 py-5 border-b border-gray-200 sm:px-6">
<h3 class="text-lg leading-6 font-medium text-gray-900"> <h3 class="text-lg leading-6 font-medium text-gray-900">
@ -104,7 +102,7 @@
@elseif($invoice->public_notes) @elseif($invoice->public_notes)
{{ $invoice->public_notes }} {{ $invoice->public_notes }}
@else @else
{{ $invoice->invoice_date}} {{ $invoice->date}}
@endif @endif
</dd> </dd>
</div> </div>
@ -113,7 +111,8 @@
{{ ctrans('texts.amount') }} {{ ctrans('texts.amount') }}
</dt> </dt>
<dd class="mt-1 text-sm leading-5 text-gray-900 sm:mt-0 sm:col-span-2"> <dd class="mt-1 text-sm leading-5 text-gray-900 sm:mt-0 sm:col-span-2">
{{ App\Utils\Number::formatMoney($invoice->amount, $invoice->client) }} <!-- App\Utils\Number::formatMoney($invoice->amount, $invoice->client) -->
<input type="text" name="payable_invoices[{{$key}}][amount]" value="{{ $invoice->partial > 0 ? $invoice->partial : $invoice->balance }}">
</dd> </dd>
</div> </div>
</dl> </dl>
@ -123,7 +122,7 @@
</div> </div>
</div> </div>
</div> </div>
</form>
@include('portal.ninja2020.invoices.includes.terms') @include('portal.ninja2020.invoices.includes.terms')
@include('portal.ninja2020.invoices.includes.signature') @include('portal.ninja2020.invoices.includes.signature')
@endsection @endsection

View File

@ -314,7 +314,41 @@ class CompanyGatewayApiTest extends TestCase
$company_gateway = CompanyGateway::find($id); $company_gateway = CompanyGateway::find($id);
$this->assertEquals(11, $company_gateway->calcGatewayFee(10)); $this->assertEquals(11, $company_gateway->calcGatewayFee(10, true));
}
public function testFeesAndLimitsFeePercentAndAmountAndTaxCalcuationInclusiveTaxes()
{
//{"1":{"min_limit":1,"max_limit":1000000,"fee_amount":10,"fee_percent":2,"fee_tax_name1":"","fee_tax_name2":"","fee_tax_name3":"","fee_tax_rate1":0,"fee_tax_rate2":0,"fee_tax_rate3":0,"fee_cap":10,"adjust_fee_percent":true}}
$fee = new FeesAndLimits;
$fee->fee_amount = 10;
// $fee->fee_percent = 2;
$fee->fee_tax_name1 = 'GST';
$fee->fee_tax_rate1 = '10.0';
$fee_arr[1] = (array)$fee;
$data = [
'config' => 'random config',
'gateway_key' => '3b6621f970ab18887c4f6dca78d3f8bb',
'fees_and_limits' => $fee_arr,
];
/* POST */
$response = $this->withHeaders([
'X-API-SECRET' => config('ninja.api_secret'),
'X-API-TOKEN' => $this->token
])->post('/api/v1/company_gateways', $data);
$response->assertStatus(200);
$arr = $response->json();
$id = $this->decodePrimaryKey($arr['data']['id']);
$company_gateway = CompanyGateway::find($id);
$this->assertEquals(10, $company_gateway->calcGatewayFee(10));
} }
public function testFeesAndLimitsFeePercentAndAmountAndDoubleTaxCalcuation() public function testFeesAndLimitsFeePercentAndAmountAndDoubleTaxCalcuation()
@ -351,7 +385,7 @@ class CompanyGatewayApiTest extends TestCase
$company_gateway = CompanyGateway::find($id); $company_gateway = CompanyGateway::find($id);
$this->assertEquals(12, $company_gateway->calcGatewayFee(10)); $this->assertEquals(12, $company_gateway->calcGatewayFee(10,true));
} }
@ -389,6 +423,6 @@ class CompanyGatewayApiTest extends TestCase
$company_gateway = CompanyGateway::find($id); $company_gateway = CompanyGateway::find($id);
$this->assertEquals(1, $company_gateway->calcGatewayFee(10)); $this->assertEquals(1.2, $company_gateway->calcGatewayFee(10,true));
} }
} }

View File

@ -49,6 +49,7 @@ class CompanyGatewayTest extends TestCase
$data[1]['fee_tax_rate2'] = ''; $data[1]['fee_tax_rate2'] = '';
$data[1]['fee_tax_name3'] = ''; $data[1]['fee_tax_name3'] = '';
$data[1]['fee_tax_rate3'] = 0; $data[1]['fee_tax_rate3'] = 0;
$data[1]['fee_cap'] = 0;
$cg = new CompanyGateway; $cg = new CompanyGateway;
$cg->company_id = $this->company->id; $cg->company_id = $this->company->id;
@ -107,4 +108,125 @@ class CompanyGatewayTest extends TestCase
return $passes; return $passes;
} }
public function testFeesAreAppendedToInvoice() //after refactor this may be redundant
{
$data = [];
$data[1]['min_limit'] = -1;
$data[1]['max_limit'] = -1;
$data[1]['fee_amount'] = 1.00;
$data[1]['fee_percent'] = 0.000;
$data[1]['fee_tax_name1'] = '';
$data[1]['fee_tax_rate1'] = 0;
$data[1]['fee_tax_name2'] = '';
$data[1]['fee_tax_rate2'] = 0;
$data[1]['fee_tax_name3'] = '';
$data[1]['fee_tax_rate3'] = 0;
$data[1]['fee_cap'] = 0;
$cg = new CompanyGateway;
$cg->company_id = $this->company->id;
$cg->user_id = $this->user->id;
$cg->gateway_key = 'd14dd26a37cecc30fdd65700bfb55b23';
$cg->require_cvv = true;
$cg->show_billing_address = true;
$cg->show_shipping_address = true;
$cg->update_details = true;
$cg->config = encrypt(config('ninja.testvars.stripe'));
$cg->fees_and_limits = $data;
$cg->save();
$balance = $this->invoice->balance;
$this->invoice = $this->invoice->service()->addGatewayFee($cg, $this->invoice->balance)->save();
$this->invoice = $this->invoice->calc()->getInvoice();
$items = $this->invoice->line_items;
$this->assertEquals(($balance+1), $this->invoice->balance);
}
public function testProRataGatewayFees()
{
$data = [];
$data[1]['min_limit'] = -1;
$data[1]['max_limit'] = -1;
$data[1]['fee_amount'] = 1.00;
$data[1]['fee_percent'] = 2;
$data[1]['fee_tax_name1'] = 'GST';
$data[1]['fee_tax_rate1'] = 10;
$data[1]['fee_tax_name2'] = 'GST';
$data[1]['fee_tax_rate2'] = 10;
$data[1]['fee_tax_name3'] = 'GST';
$data[1]['fee_tax_rate3'] = 10;
$data[1]['fee_cap'] = 0;
$cg = new CompanyGateway;
$cg->company_id = $this->company->id;
$cg->user_id = $this->user->id;
$cg->gateway_key = 'd14dd26a37cecc30fdd65700bfb55b23';
$cg->require_cvv = true;
$cg->show_billing_address = true;
$cg->show_shipping_address = true;
$cg->update_details = true;
$cg->config = encrypt(config('ninja.testvars.stripe'));
$cg->fees_and_limits = $data;
$cg->save();
$total = 10.93;
$total_invoice_count = 5;
$total_gateway_fee = round($cg->calcGatewayFee($total,true),2);
$this->assertEquals(1.58, $total_gateway_fee);
/*simple pro rata*/
$fees_and_limits = $cg->getFeesAndLimits();
/*Calculate all subcomponents of the fee*/
// $fee_component_amount = $fees_and_limits->fee_amount ?: 0;
// $fee_component_percent = $fees_and_limits->fee_percent ? ($total * $fees_and_limits->fee_percent / 100) : 0;
// $combined_fee_component = $fee_component_amount + $fee_component_percent;
// $fee_component_tax_name1 = $fees_and_limits->fee_tax_name1 ?: '';
// $fee_component_tax_rate1 = $fees_and_limits->fee_tax_rate1 ? ($combined_fee_component * $fees_and_limits->fee_tax_rate1 / 100) : 0;
// $fee_component_tax_name2 = $fees_and_limits->fee_tax_name2 ?: '';
// $fee_component_tax_rate2 = $fees_and_limits->fee_tax_rate2 ? ($combined_fee_component * $fees_and_limits->fee_tax_rate2 / 100) : 0;
// $fee_component_tax_name3 = $fees_and_limits->fee_tax_name3 ?: '';
// $fee_component_tax_rate3 = $fees_and_limits->fee_tax_rate3 ? ($combined_fee_component * $fees_and_limits->fee_tax_rate3 / 100) : 0;
// $pro_rata_fee = round($total_gateway_fee / $total_invoice_count,2);
// while($pro_rata_fee * $total_invoice_count != $total_gateway_fee) {
// //nudge one pro rata fee until we get the desired amount
// $sub_total_fees = ($pro_rata_fee*($total_invoice_count--));
// //work out if we have to nudge up or down
// if($pro_rata_fee*$total_invoice_count > $total_gateway_fee) {
// //nudge DOWN
// $pro_rata_fee - 0.01; //this will break if the currency doesn't have decimals
// }
// else {
// //nudge UP
// }
// }
// $this->assertEquals(1.56, $pro_rata_fee*$total_invoice_count);
}
} }

View File

@ -2,6 +2,7 @@
namespace Tests\Unit; namespace Tests\Unit;
use App\Models\Currency;
use App\Utils\Number; use App\Utils\Number;
use Tests\TestCase; use Tests\TestCase;
@ -31,4 +32,25 @@ class NumberTest extends TestCase
$this->assertEquals(2.15, $rounded); $this->assertEquals(2.15, $rounded);
} }
public function testParsingFloats()
{
Currency::all()->each(function ($currency){
$amount = 123456789.12;
$formatted_amount = Number::formatValue($amount, $currency);
$float_amount = Number::parseFloat($formatted_amount);
if($currency->precision == 0){
$this->assertEquals(123456789, $float_amount);
}
else
$this->assertEquals($amount, $float_amount);
});
}
} }