1
0
mirror of https://github.com/invoiceninja/invoiceninja.git synced 2024-11-10 05:02:36 +01:00

Allow fine grained payments (#3110)

* Allow payment amounts to be partial per invoice paid

* edge case tests for payments

* Allow per invoice payment amounts and allow direct payments which simply credit a clients credit_balance

* Fixes
This commit is contained in:
David Bomba 2019-12-01 22:23:24 +11:00 committed by GitHub
parent a7f939c6b9
commit 25514b43cf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 329 additions and 45 deletions

View File

@ -43,6 +43,7 @@
* @OA\Property(property="is_deleted", type="boolean", example=true, description="________"),
* @OA\Property(property="balance", type="number", format="float", example="10.00", description="________"),
* @OA\Property(property="paid_to_date", type="number", format="float", example="10.00", description="________"),
* @OA\Property(property="credit_balance", type="number", format="float", example="10.00", description="An amount which is available to the client for future use."),
* @OA\Property(property="last_login", type="number", format="integer", example="134341234234", description="Timestamp"),
* @OA\Property(property="created_at", type="number", format="integer", example="134341234234", description="Timestamp"),
* @OA\Property(property="updated_at", type="number", format="integer", example="134341234234", description="Timestamp"),

View File

@ -33,15 +33,38 @@ class StorePaymentRequest extends Request
}
protected function prepareForValidation()
{
$input = $this->all();
if(isset($input['client_id']))
$input['client_id'] = $this->decodePrimaryKey($input['client_id']);
if(isset($input['invoices'])){
foreach($input['invoices'] as $key => $value)
{
$input['invoices'][$key]['id'] = $this->decodePrimaryKey($value['id']);
}
}
if(is_array($input['invoices']) === false)
$input['invoices'] = null;
$this->replace($input);
}
public function rules()
{
$this->sanitize();
$rules = [
'amount' => 'numeric|required',
'payment_date' => 'required',
'client_id' => 'required',
'invoices' => 'required',
'invoices' => new ValidPayableInvoicesRule(),
];
@ -50,25 +73,4 @@ class StorePaymentRequest extends Request
}
public function sanitize()
{
$input = $this->all();
if(isset($input['client_id']))
$input['client_id'] = $this->decodePrimaryKey($input['client_id']);
if(isset($input['invoices']))
$input['invoices'] = $this->transformKeys(explode(",", $input['invoices']));
if(is_array($input['invoices']) === false)
$input['invoices'] = null;
$this->replace($input);
return $this->all();
}
}

View File

@ -28,18 +28,23 @@ class ValidPayableInvoicesRule implements Rule
* @param mixed $value
* @return bool
*/
private $error_msg;
public function passes($attribute, $value)
{
/*If no invoices has been sent, then we apply the payment to the client account*/
$invoices = [];
$invoices = Invoice::whereIn('id', $this->transformKeys(explode(",",$value)))->get();
if(!$invoices || $invoices->count() == 0)
return false;
if(is_array($value))
$invoices = Invoice::whereIn('id', array_column($value,'id'))->company()->get();
foreach ($invoices as $invoice) {
if(! $invoice->isPayable())
return false;
if(! $invoice->isPayable()) {
$this->error_msg = "One or more of these invoices have been paid";
return false;
}
}
return true;
@ -50,7 +55,7 @@ class ValidPayableInvoicesRule implements Rule
*/
public function message()
{
return "One or more of these invoices have been paid";
return $this->error_msg;
}
}

View File

@ -0,0 +1,62 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com)
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2019. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://opensource.org/licenses/AAL
*/
namespace App\Jobs\Invoice;
use App\Events\Payment\PaymentWasCreated;
use App\Factory\PaymentFactory;
use App\Jobs\Client\UpdateClientBalance;
use App\Jobs\Client\UpdateClientPaidToDate;
use App\Jobs\Company\UpdateCompanyLedgerWithPayment;
use App\Jobs\Invoice\ApplyPaymentToInvoice;
use App\Models\Invoice;
use App\Models\Payment;
use App\Repositories\InvoiceRepository;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class ApplyClientPayment implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $payment;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct(Payment $payment)
{
$this->payment = $payment;
}
/**
* Execute the job.
*
*
* @return void
*/
public function handle()
{
$client = $this->payment->client;
$client->credit_balance += $this->payment->amount;
$client->save();
}
}

View File

@ -0,0 +1,114 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com)
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2019. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://opensource.org/licenses/AAL
*/
namespace App\Jobs\Invoice;
use App\Events\Payment\PaymentWasCreated;
use App\Factory\PaymentFactory;
use App\Jobs\Client\UpdateClientBalance;
use App\Jobs\Client\UpdateClientPaidToDate;
use App\Jobs\Company\UpdateCompanyLedgerWithPayment;
use App\Jobs\Invoice\ApplyPaymentToInvoice;
use App\Models\Invoice;
use App\Models\Payment;
use App\Repositories\InvoiceRepository;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class ApplyInvoicePayment implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $invoice;
public $payment;
public $amount;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct(Invoice $invoice, Payment $payment, float $amount)
{
$this->invoice = $invoice;
$this->payment = $payment;
$this->amount = $amount;
}
/**
* Execute the job.
*
*
* @return void
*/
public function handle()
{
UpdateCompanyLedgerWithPayment::dispatchNow($this->payment, ($this->amount*-1));
UpdateClientBalance::dispatchNow($this->payment->client, $this->amount*-1);
UpdateClientPaidToDate::dispatchNow($this->payment->client, $this->amount);
/* Update Pivot Record amount */
$this->payment->invoices->each(function ($inv){
if($inv->id == $this->invoice->id){
$inv->pivot->amount = $this->amount;
$inv->pivot->save();
}
});
if($this->invoice->hasPartial())
{
//is partial and amount is exactly the partial amount
if($this->invoice->partial == $this->amount)
{
$this->invoice->clearPartial();
$this->invoice->setDueDate();
$this->invoice->setStatus(Invoice::STATUS_PARTIAL);
$this->invoice->updateBalance($this->amount*-1);
}
elseif($this->invoice->partial > 0 && $this->invoice->partial > $this->amount) //partial amount exists, but the amount is less than the partial amount
{
\Log::error("partial > amount");
$this->invoice->partial -= $this->amount;
$this->invoice->updateBalance($this->amount*-1);
}
elseif($this->invoice->partial > 0 && $this->invoice->partial < $this->amount) //partial exists and the amount paid is GREATER than the partial amount
{
\Log::error("partial < amount");
$this->invoice->clearPartial();
$this->invoice->setDueDate();
$this->invoice->setStatus(Invoice::STATUS_PARTIAL);
$this->invoice->updateBalance($this->amount*-1);
}
}
elseif($this->invoice->amount == $this->invoice->balance) //total invoice paid.
{
\Log::error("balance == amount");
$this->invoice->clearPartial();
$this->invoice->setDueDate();
$this->invoice->setStatus(Invoice::STATUS_PAID);
$this->invoice->updateBalance($this->amount*-1);
}
}
}

View File

@ -370,6 +370,7 @@ class Invoice extends BaseModel
$balance_adjustment = floatval($balance_adjustment);
\Log::error("adjusting balance from ". $this->balance. " to ". ($this->balance + $balance_adjustment));
$this->balance = $this->balance + $balance_adjustment;
if($this->balance == 0) {
@ -385,8 +386,7 @@ class Invoice extends BaseModel
public function setDueDate()
{
$this->due_date = Carbon::now()->addDays(PaymentTerm::find($this->company->settings->payment_terms_id)->num_days);
$this->due_date = Carbon::now()->addDays($this->client->getSetting('payment_terms'));
$this->save();
}

View File

@ -14,6 +14,8 @@ namespace App\Repositories;
use App\Events\Payment\PaymentWasCreated;
use App\Jobs\Company\UpdateCompanyLedgerWithPayment;
use App\Jobs\Invoice\UpdateInvoicePayment;
use App\Jobs\Invoice\ApplyInvoicePayment;
use App\Jobs\Invoice\ApplyClientPayment;
use App\Models\Invoice;
use App\Models\Payment;
use Illuminate\Http\Request;
@ -39,17 +41,31 @@ class PaymentRepository extends BaseRepository
if($request->input('invoices'))
{
$invoices = Invoice::whereIn('id', $request->input('invoices'))->get();
$invoices = Invoice::whereIn('id', array_column($request->input('invoices'),'id'))->company()->get();
$payment->invoices()->saveMany($invoices);
foreach($request->input('invoices') as $paid_invoice)
{
$invoice = Invoice::whereId($paid_invoice['id'])->company()->first();
if($invoice)
ApplyInvoicePayment::dispatchNow($invoice, $payment, $paid_invoice['amount']);
}
}
else {
//paid is made, but not to any invoice, therefore we are applying the payment to the clients credit
ApplyClientPayment::dispatchNow($payment);
}
event(new PaymentWasCreated($payment));
UpdateInvoicePayment::dispatchNow($payment);
//UpdateInvoicePayment::dispatchNow($payment);
return $payment;
return $payment->fresh();
}

View File

@ -86,6 +86,7 @@ class ClientTransformer extends EntityTransformer
'balance' => (float) $client->balance,
'group_settings_id' => isset($client->group_settings_id) ? (string)$this->encodePrimaryKey($client->group_settings_id) : '',
'paid_to_date' => (float) $client->paid_to_date,
'credit_balance' => (float) $client->credit_balance,
'last_login' => (int)$client->last_login,
// 'currency_id' => (string)$client->currency_id,
'address1' => $client->address1 ?: '',

View File

@ -295,6 +295,7 @@ class CreateUsersTable extends Migration
$table->decimal('balance', 16, 4)->default(0);
$table->decimal('paid_to_date', 16, 4)->default(0);
$table->decimal('credit_balance', 16, 4)->default(0);
$table->timestamp('last_login')->nullable();
$table->unsignedInteger('industry_id')->nullable();
$table->unsignedInteger('size_id')->nullable();

View File

@ -129,7 +129,12 @@ class PaymentTest extends TestCase
$data = [
'amount' => $this->invoice->amount,
'invoices' => $this->invoice->hashed_id,
'invoices' => [
[
'id' => $this->invoice->hashed_id,
'amount' => $this->invoice->amount
],
],
'payment_date' => '2020/12/11',
];
@ -174,11 +179,17 @@ class PaymentTest extends TestCase
$data = [
'amount' => $this->invoice->amount,
'client_id' => $client->hashed_id,
'invoices' => $this->invoice->hashed_id,
'invoices' => [
[
'id' => $this->invoice->hashed_id,
'amount' => $this->invoice->amount
],
],
'payment_date' => '2020/12/12',
];
$response = null;
try {
$response = $this->withHeaders([
@ -194,12 +205,20 @@ class PaymentTest extends TestCase
$this->assertNotNull($message);
}
$arr = $response->json();
// \Log::error($arr);
$response->assertStatus(200);
if($response){
$arr = $response->json();
$response->assertStatus(200);
$payment_id = $arr['data']['id'];
$payment = Payment::find($this->decodePrimaryKey($payment_id))->first();
$this->assertNotNull($payment);
$this->assertNotNull($payment->invoices());
$this->assertEquals(1, $payment->invoices()->count());
}
}
public function testStorePaymentWithNoInvoiecs()
@ -212,7 +231,7 @@ class PaymentTest extends TestCase
$this->invoice->status_id = Invoice::STATUS_SENT;
$this->invoice->line_items = $this->buildLineItems();
$this->invoice->uses_inclusive_Taxes = false;
$this->invoice->uses_inclusive_taxes = false;
$this->invoice->save();
@ -241,9 +260,72 @@ class PaymentTest extends TestCase
catch(ValidationException $e) {
$message = json_decode($e->validator->getMessageBag(),1);
$this->assertNotNull($message);
}
if($response)
$response->assertStatus(200);
}
public function testPartialPaymentAmount()
{
$this->invoice = null;
$client = ClientFactory::create($this->company->id, $this->user->id);
$client->save();
$this->invoice = InvoiceFactory::create($this->company->id,$this->user->id);//stub the company and user_id
$this->invoice->client_id = $client->id;
$this->invoice->partial = 2.0;
$this->invoice->line_items = $this->buildLineItems();
$this->invoice->uses_inclusive_taxes = false;
$this->invoice->save();
$this->invoice_calc = new InvoiceSum($this->invoice);
$this->invoice_calc->build();
$this->invoice = $this->invoice_calc->getInvoice();
$this->invoice->save();
$this->invoice->markSent();
$this->invoice->save();
$data = [
'amount' => 2.0,
'client_id' => $client->hashed_id,
'invoices' => [
[
'id' => $this->invoice->hashed_id,
'amount' => 2.0
],
],
'payment_date' => '2019/12/12',
];
$response = $this->withHeaders([
'X-API-SECRET' => config('ninja.api_secret'),
'X-API-TOKEN' => $this->token,
])->post('/api/v1/payments?include=invoices', $data);
$arr = $response->json();
$response->assertStatus(200);
$payment_id = $arr['data']['id'];
$payment = Payment::find($this->decodePrimaryKey($payment_id))->first();
$this->assertNotNull($payment);
$this->assertNotNull($payment->invoices());
$this->assertEquals(1, $payment->invoices()->count());
$pivot_invoice = $payment->invoices()->first();
$this->assertEquals($pivot_invoice->pivot->amount, 2);
$this->assertEquals($pivot_invoice->partial, 0);
$this->assertEquals($pivot_invoice->amount, 10.0000);
$this->assertEquals($pivot_invoice->balance, 8.0000);
}
}