Implementation of Invoice Reversal (#3602)

* Version bump 0.0.2

* code cleanup

* Working on reversing an invoice

* Working on reversing an invoice

* Working on refunding invoice

* Reversing invoices

* Test for invoice reversals

* Invoice Reversal
This commit is contained in:
David Bomba 2020-04-08 20:48:31 +10:00 committed by GitHub
parent 9c2293427e
commit 2fd3229efd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 586 additions and 134 deletions

View File

@ -1 +1 @@
0.0.1
0.0.2

View File

@ -679,6 +679,11 @@ class InvoiceController extends BaseController
case 'cancel':
break;
case 'reverse':
$invoice = $invoice->service()->handleReversal()->save();
if(!$bulk){
$this->itemResponse($invoice);
}
break;
case 'email':

View File

@ -50,15 +50,15 @@ class ActionInvoiceRequest extends Request
if(!array_key_exists('action', $input) {
$this->error_msg = 'Action is a required field';
}
elseif(!$this->invoiceDeletable()){
elseif(!$this->invoiceDeletable($this->invoice)){
unset($input['action']);
$this->error_msg = 'This invoice cannot be deleted';
}
elseif(!$this->invoiceCancellable()) {
elseif(!$this->invoiceCancellable($this->invoice)) {
unset($input['action']);
$this->error_msg = 'This invoice cannot be cancelled';
}
else if(!$this->invoiceReversable()) {
else if(!$this->invoiceReversable($this->invoice)) {
unset($input['action']);
$this->error_msg = 'This invoice cannot be reversed';
}
@ -74,32 +74,7 @@ class ActionInvoiceRequest extends Request
}
private function invoiceDeletable()
{
if($this->invoice->status_id <= 2 && $this->invoice->is_deleted == false && $this->invoice->deleted_at == NULL)
return true;
return false;
}
private function invoiceCancellable()
{
if($this->invoice->status_id == 3 && $this->invoice->is_deleted == false && $this->invoice->deleted_at == NULL)
return true;
return false;
}
private function invoiceReversable()
{
if(($this->invoice->status_id == 3 || $this->invoice->status_id == 4) && $this->invoice->is_deleted == false && $this->invoice->deleted_at == NULL)
return true;
return false;
}
}

View File

@ -12,6 +12,7 @@
namespace App\Http\Requests\Payment;
use App\Http\Requests\Request;
use App\Http\ValidationRules\Credit\ValidCreditsRules;
use App\Http\ValidationRules\PaymentAmountsBalanceRule;
use App\Http\ValidationRules\Payment\ValidInvoicesRules;
use App\Http\ValidationRules\ValidCreditsPresentRule;
@ -50,8 +51,6 @@ class StorePaymentRequest extends Request
$input['invoices'][$key]['invoice_id'] = $this->decodePrimaryKey($value['invoice_id']);
$invoices_total += $value['amount'];
}
//if(!isset($input['amount']) || )
}
if (isset($input['invoices']) && is_array($input['invoices']) === false) {
@ -61,7 +60,7 @@ class StorePaymentRequest extends Request
if (isset($input['credits']) && is_array($input['credits']) !== false) {
foreach ($input['credits'] as $key => $value) {
if (array_key_exists('credit_id', $input['credits'][$key])) {
$input['credits'][$key]['credit_id'] = $this->decodePrimaryKey($value['credit_id']);
$input['credits'][$key]['credit_id'] = $value['credit_id'];
$credits_total += $value['amount'];
}
}
@ -91,6 +90,7 @@ class StorePaymentRequest extends Request
'invoices.*.invoice_id' => new ValidInvoicesRules($this->all()),
'invoices.*.amount' => 'required',
'credits.*.credit_id' => 'required|exists:credits,id',
'credits.*.credit_id' => new ValidCreditsRules($this->all()),
'credits.*.amount' => 'required',
'invoices' => new ValidPayableInvoicesRule(),
'number' => 'nullable',

View File

@ -0,0 +1,92 @@
<?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\Http\ValidationRules\Credit;
use App\Libraries\MultiDB;
use App\Models\Credit;
use App\Models\Invoice;
use App\Models\Payment;
use App\Models\User;
use App\Utils\Traits\MakesHash;
use Illuminate\Contracts\Validation\Rule;
/**
* Class ValidCreditsRules
* @package App\Http\ValidationRules\Credit
*/
class ValidCreditsRules implements Rule
{
use MakesHash;
/**
* @param string $attribute
* @param mixed $value
* @return bool
*/
private $error_msg;
private $input;
public function __construct($input)
{
$this->input = $input;
}
public function passes($attribute, $value)
{
return $this->checkCreditsAreHomogenous();
}
private function checkCreditsAreHomogenous()
{
if (!array_key_exists('client_id', $this->input)) {
$this->error_msg = "Client id is required";
return false;
}
$unique_array = [];
foreach ($this->input['credits'] as $credit) {
$unique_array[] = $credit['credit_id'];
$cred = Credit::find($this->decodePrimaryKey($credit['credit_id']));
if (!$cred) {
$this->error_msg = "Credit not found ";
return false;
}
if ($cred->client_id != $this->input['client_id']) {
$this->error_msg = "Selected invoices are not from a single client";
return false;
}
}
if (!(array_unique($unique_array) == $unique_array)) {
$this->error_msg = "Duplicate credits submitted.";
return false;
}
return true;
}
/**
* @return string
*/
public function message()
{
return $this->error_msg;
}
}

View File

@ -43,17 +43,17 @@ class ValidCreditsPresentRule implements Rule
return 'Insufficient balance on credit.';
}
private function validCreditsPresent() :bool
{
//todo need to ensure the clients credits are here not random ones!
if (request()->input('credits') && is_array(request()->input('credits'))) {
foreach (request()->input('credits') as $credit) {
$cred = Credit::find($this->decodePrimaryKey($credit['credit_id']));
if ($cred->balance == 0) {
if (!$cred || $cred->balance == 0) {
return false;
}
}

View File

@ -71,7 +71,7 @@ class ApplyCreditPayment implements ShouldQueue
$this->credit->setStatus(Credit::STATUS_APPLIED);
$this->credit->updateBalance($this->amount*-1);
} elseif ($this->amount < $credit_balance) { //compare number appropriately
$this->credit->setStatus(Credit::PARTIAL);
$this->credit->setStatus(Credit::STATUS_PARTIAL);
$this->credit->updateBalance($this->amount*-1);
}

View File

@ -332,8 +332,6 @@ class Client extends BaseModel implements HasLocalePreference
{
$company_gateways = $this->getSetting('company_gateway_ids');
info($company_gateways);
info($this->company->id);
if (strlen($company_gateways)>=1) {
$gateways = $this->company->company_gateways->whereIn('id', $payment_gateways);
} else {

View File

@ -16,6 +16,7 @@ use App\Helpers\Invoice\InvoiceSumInclusive;
use App\Jobs\Credit\CreateCreditPdf;
use App\Models\Filterable;
use App\Services\Credit\CreditService;
use App\Services\Ledger\LedgerService;
use App\Utils\Traits\MakesDates;
use App\Utils\Traits\MakesHash;
use App\Utils\Traits\MakesInvoiceValues;
@ -143,6 +144,10 @@ class Credit extends BaseModel
return $this->morphMany(CompanyLedger::class, 'company_ledgerable');
}
public function ledger()
{
return new LedgerService($this);
}
/**
* The invoice/s which the credit has
* been applied to.

View File

@ -27,6 +27,7 @@ use App\Services\Invoice\InvoiceService;
use App\Services\Ledger\LedgerService;
use App\Utils\Number;
use App\Utils\Traits\InvoiceEmailBuilder;
use App\Utils\Traits\Invoice\ActionsInvoice;
use App\Utils\Traits\MakesDates;
use App\Utils\Traits\MakesInvoiceValues;
use App\Utils\Traits\MakesReminders;
@ -48,6 +49,7 @@ class Invoice extends BaseModel
use MakesInvoiceValues;
use InvoiceEmailBuilder;
use MakesReminders;
use ActionsInvoice;
protected $presenter = 'App\Models\Presenters\InvoicePresenter';
@ -123,10 +125,10 @@ class Invoice extends BaseModel
const STATUS_PARTIAL = 3;
const STATUS_PAID = 4;
const STATUS_CANCELLED = 5;
const STATUS_REVERSED = 6;
const STATUS_OVERDUE = -1;
const STATUS_UNPAID = -2;
const STATUS_REVERSED = -3;
const STATUS_OVERDUE = -1; //status < 4 || < 3 && !is_deleted && !trashed() && due_date < now()
const STATUS_UNPAID = -2; //status < 4 || < 3 && !is_deleted && !trashed()
public function getDateAttribute($value)
{
@ -193,14 +195,13 @@ class Invoice extends BaseModel
return $this->morphMany(CompanyLedger::class, 'company_ledgerable');
}
public function credits()
{
return $this->belongsToMany(Credit::class)->using(Paymentable::class)->withPivot(
'amount',
'refunded'
)->withTimestamps();
;
}
// public function credits()
// {
// return $this->belongsToMany(Credit::class)->using(Paymentable::class)->withPivot(
// 'amount',
// 'refunded'
// )->withTimestamps();
// }
/**
* Service entry points

View File

@ -35,4 +35,9 @@ class Paymentable extends Pivot
{
return $this->morphTo();
}
public function payment()
{
return $this->belongsTo(Payment::class);
}
}

View File

@ -68,7 +68,17 @@ class InvoiceRepository extends BaseRepository
return InvoiceInvitation::whereRaw("BINARY `key`= ?", [$key])->first();
}
public function delete($invoice)
/**
* Method is not protected, assumes that
* other protections have been implemented prior
* to hitting this method.
*
* ie. invoice can be deleted from a business logic perspective.
*
* @param Invoice $invoice
* @return Invoice $invoice
*/
public function delete($invoice)
{
if ($invoice->is_deleted) {
return;
@ -82,6 +92,8 @@ class InvoiceRepository extends BaseRepository
if (class_exists($className)) {
event(new InvoiceWasDeleted($invoice));
}
return $invoice;
}
public function reverse()

View File

@ -19,6 +19,7 @@ use App\Models\Credit;
use App\Models\Invoice;
use App\Models\Payment;
use App\Repositories\CreditRepository;
use App\Utils\Traits\MakesHash;
use Illuminate\Http\Request;
/**
@ -26,6 +27,8 @@ use Illuminate\Http\Request;
*/
class PaymentRepository extends BaseRepository
{
use MakesHash;
protected $credit_repo;
public function __construct(CreditRepository $credit_repo)
@ -105,12 +108,12 @@ class PaymentRepository extends BaseRepository
if (array_key_exists('credits', $data) && is_array($data['credits'])) {
$credit_totals = array_sum(array_column($data['credits'], 'amount'));
$credits = Credit::whereIn('id', array_column($data['credits'], 'credit_id'))->get();
$credits = Credit::whereIn('id', $this->transformKeys(array_column($data['credits'], 'credit_id')))->get();
$payment->credits()->saveMany($credits);
foreach ($data['credits'] as $paid_credit) {
$credit = Credit::whereId($paid_credit['credit_id'])->first();
$credit = Credit::find($this->decodePrimaryKey($paid_credit['credit_id']));
if ($credit) {
ApplyCreditPayment::dispatchNow($credit, $payment, $paid_credit['amount'], $credit->company);

View File

@ -1,54 +0,0 @@
<?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\Events\Payment\PaymentWasCreated;
use App\Factory\PaymentFactory;
use App\Models\Client;
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 HandleDeletion extends AbstractService
{
use GeneratesCounter;
private $invoice;
public function __construct(Invoice $invoice)
{
$this->invoice = $invoice;
}
public function run()
{
$balance_remaining = $this->invoice->balance;
$total_paid = $this->invoice->amount - $this->invoice->balance;
//change invoice status
//set invoice balance to 0
//decrease client balance by $total_paid
//remove paymentables from payment
//decreate client paid_to_date by $total_paid
//generate credit for the $total paid
}
}

View File

@ -0,0 +1,110 @@
<?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\Events\Payment\PaymentWasCreated;
use App\Factory\CreditFactory;
use App\Factory\InvoiceItemFactory;
use App\Factory\PaymentFactory;
use App\Helpers\Invoice\InvoiceSum;
use App\Models\Client;
use App\Models\Invoice;
use App\Models\Payment;
use App\Models\Paymentable;
use App\Services\AbstractService;
use App\Services\Client\ClientService;
use App\Services\Payment\PaymentService;
use App\Utils\Traits\GeneratesCounter;
class HandleReversal extends AbstractService
{
use GeneratesCounter;
private $invoice;
public function __construct(Invoice $invoice)
{
$this->invoice = $invoice;
}
public function run()
{
/* Check again!! */
if(!$this->invoice->invoiceReversable($this->invoice))
return $this->invoice;
$balance_remaining = $this->invoice->balance;
$total_paid = $this->invoice->amount - $this->invoice->balance;
/*Adjust payment applied and the paymentables to the correct amount */
$paymentables = Paymentable::wherePaymentableType(Invoice::class)
->wherePaymentableId($this->invoice->id)
->get();
$paymentables->each(function ($paymentable) use($total_paid){
$reversable_amount = $paymentable->amount - $paymentable->refunded;
$total_paid -= $reversable_amount;
$paymentable->amount = $paymentable->refunded;
$paymentable->save();
});
/* Generate a credit for the $total_paid amount */
$credit = CreditFactory::create($this->invoice->company_id, $this->invoice->user_id);
$credit->client_id = $this->invoice->client_id;
$item = InvoiceItemFactory::create();
$item->quantity = 1;
$item->cost = (float)$total_paid;
$item->notes = "Credit for reversal of ".$this->invoice->number;
$line_items[] = $item;
$credit->line_items = $line_items;
$credit->save();
$credit_calc = new InvoiceSum($credit);
$credit_calc->build();
$credit = $credit_calc->getCredit();
$credit->service()->markSent()->save();
/* Set invoice balance to 0 */
$this->invoice->ledger()->updateInvoiceBalance($balance_remaining, $item->notes)->save();
$this->invoice->balance= 0;
/* Set invoice status to reversed... somehow*/
$this->invoice->service()->setStatus(Invoice::STATUS_REVERSED)->save();
/* Reduce client.paid_to_date by $total_paid amount */
/* Reduce the client balance by $balance_remaining */
$this->invoice->client->service()
->updateBalance($balance_remaining*-1)
->updatePaidToDate($total_paid*-1)
->save();
return $this->invoice;
//create a ledger row for this with the resulting Credit ( also include an explanation in the notes section )
}
}
// The client paid to date amount is reduced by the calculated amount of (invoice balance - invoice amount).

View File

@ -18,7 +18,7 @@ use App\Services\Invoice\ApplyNumber;
use App\Services\Invoice\ApplyPayment;
use App\Services\Invoice\CreateInvitations;
use App\Services\Invoice\GetInvoicePdf;
use App\Services\Invoice\HandleDeletion;
use App\Services\Invoice\HandleReversal;
use App\Services\Invoice\MarkInvoicePaid;
use App\Services\Invoice\MarkSent;
use App\Services\Invoice\UpdateBalance;
@ -116,9 +116,9 @@ class InvoiceService
return $send_email->run();
}
public function handleDeletion()
public function handleReversal()
{
$this->invoice = (new HandleDeletion($this->invoice))->run();
$this->invoice = (new HandleReversal($this->invoice))->run();
return $this;
}

View File

@ -47,8 +47,6 @@ class MarkSent extends AbstractService
$this->invoice->ledger()->updateInvoiceBalance($this->invoice->balance);
//UpdateCompanyLedgerWithInvoice::dispatchNow($this->invoice, $this->invoice->balance, $this->invoice->company);
return $this->invoice;
}
}

View File

@ -23,7 +23,7 @@ class LedgerService
$this->entity = $entity;
}
public function updateInvoiceBalance($adjustment)
public function updateInvoiceBalance($adjustment, $notes = '')
{
$balance = 0;
@ -36,6 +36,7 @@ class LedgerService
$company_ledger = CompanyLedgerFactory::create($this->entity->company_id, $this->entity->user_id);
$company_ledger->client_id = $this->entity->client_id;
$company_ledger->adjustment = $adjustment;
$company_ledger->notes = $notes;
$company_ledger->balance = $balance + $adjustment;
$company_ledger->save();

View File

@ -11,31 +11,33 @@
namespace App\Utils\Traits\Invoice;
use App\Models\Invoice;
trait ActionsInvoice
{
public function invoiceDeletable() :bool
public function invoiceDeletable($invoice) :bool
{
if($this->invoice->status_id <= 2 && $this->invoice->is_deleted == false && $this->invoice->deleted_at == NULL)
if($invoice->status_id <= Invoice::STATUS_SENT && $invoice->is_deleted == false && $invoice->deleted_at == NULL)
return true;
return false;
}
public function invoiceCancellable() :bool
public function invoiceCancellable($invoice) :bool
{
if($this->invoice->status_id == 3 && $this->invoice->is_deleted == false && $this->invoice->deleted_at == NULL)
if($invoice->status_id == Invoice::STATUS_PARTIAL && $invoice->is_deleted == false && $invoice->deleted_at == NULL)
return true;
return false;
}
public function invoiceReversable() :bool
public function invoiceReversable($invoice) :bool
{
if(($this->invoice->status_id == 3 || $this->invoice->status_id == 4) && $this->invoice->is_deleted == false && $this->invoice->deleted_at == NULL)
if(($invoice->status_id == Invoice::STATUS_SENT || $invoice->status_id == Invoice::STATUS_PARTIAL || $invoice->status_id == Invoice::STATUS_PAID) && $invoice->is_deleted == false && $invoice->deleted_at == NULL)
return true;
return false;

View File

@ -5,12 +5,14 @@ namespace Tests\Feature;
use App\DataMapper\ClientSettings;
use App\DataMapper\CompanySettings;
use App\Factory\ClientFactory;
use App\Factory\CreditFactory;
use App\Factory\InvoiceFactory;
use App\Factory\PaymentFactory;
use App\Helpers\Invoice\InvoiceSum;
use App\Models\Account;
use App\Models\Activity;
use App\Models\Client;
use App\Models\Credit;
use App\Models\Invoice;
use App\Models\Payment;
use App\Utils\Traits\MakesHash;
@ -18,6 +20,7 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Foundation\Testing\WithoutEvents;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Session;
use Illuminate\Validation\ValidationException;
@ -34,6 +37,7 @@ class PaymentTest extends TestCase
use MakesHash;
use DatabaseTransactions;
use MockAccountData;
use WithoutEvents;
public function setUp() :void
{
@ -413,6 +417,22 @@ class PaymentTest extends TestCase
$client = ClientFactory::create($this->company->id, $this->user->id);
$client->save();
factory(\App\Models\ClientContact::class, 1)->create([
'user_id' => $this->user->id,
'client_id' => $client->id,
'company_id' => $this->company->id,
'is_primary' => 1,
'send_email' => true,
]);
factory(\App\Models\ClientContact::class, 1)->create([
'user_id' => $this->user->id,
'client_id' => $client->id,
'company_id' => $this->company->id,
'send_email' => true
]);
$this->invoice = InvoiceFactory::create($this->company->id, $this->user->id);//stub the company and user_id
$this->invoice->client_id = $client->id;
@ -471,6 +491,23 @@ class PaymentTest extends TestCase
$client = ClientFactory::create($this->company->id, $this->user->id);
$client->save();
$contact = factory(\App\Models\ClientContact::class, 1)->create([
'user_id' => $this->user->id,
'client_id' => $this->client->id,
'company_id' => $this->company->id,
'is_primary' => 1,
'send_email' => true,
]);
factory(\App\Models\ClientContact::class, 1)->create([
'user_id' => $this->user->id,
'client_id' => $this->client->id,
'company_id' => $this->company->id,
'send_email' => true
]);
$client->setRelation('contact', $contact);
$this->invoice = InvoiceFactory::create($this->company->id, $this->user->id);//stub the company and user_id
$this->invoice->client_id = $client->id;
@ -487,6 +524,7 @@ class PaymentTest extends TestCase
$this->invoice->save();
$this->invoice->service()->markSent()->save();
$this->invoice->setRelation('client', $client);
$data = [
'amount' => 1.0,
@ -1130,4 +1168,87 @@ class PaymentTest extends TestCase
$this->assertNull($response);
}
public function testStorePaymentWithCredits()
{
$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->status_id = Invoice::STATUS_SENT;
$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();
$credit = CreditFactory::create($this->company->id, $this->user->id);
$credit->client_id = $client->id;
$credit->status_id = Credit::STATUS_SENT;
$credit->line_items = $this->buildLineItems();
$credit->uses_inclusive_taxes = false;
$credit->save();
$credit_calc = new InvoiceSum($credit);
$credit_calc->build();
$credit = $this->credit_calc->getCredit();
$credit->save(); //$10 credit
$data = [
'amount' => $this->invoice->amount,
'client_id' => $client->hashed_id,
'invoices' => [
[
'invoice_id' => $this->invoice->hashed_id,
'amount' => 5
],
],
'credits' => [
[
'credit_id' => $credit->id,
'amount' => 5
],
],
'date' => '2020/12/12',
];
$response = null;
try {
$response = $this->withHeaders([
'X-API-SECRET' => config('ninja.api_secret'),
'X-API-TOKEN' => $this->token,
])->post('/api/v1/payments?include=invoices', $data);
} catch (ValidationException $e) {
$message = json_decode($e->validator->getMessageBag(), 1);
$this->assertNotNull($message);
}
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());
}
}
}

View File

@ -505,9 +505,9 @@ class RefundTest extends TestCase
$this->invoice->line_items = $this->buildLineItems();
$this->invoice->uses_inclusive_taxes = false;
$this->invoice_client_id = $client->id;
$this->invoice->save();
$this->invoice_calc = new InvoiceSum($this->invoice);
$this->invoice_calc->build();
@ -515,7 +515,8 @@ class RefundTest extends TestCase
$this->invoice->save();
$this->credit = CreditFactory::create($this->company->id, $this->user->id);
$this->credit->client_id = $this->client->id;
$this->credit->client_id = $client->id;
$this->credit->status_id=2;
$this->credit->line_items = $this->buildLineItems();
$this->credit->amount = 10;
@ -552,9 +553,11 @@ class RefundTest extends TestCase
])->post('/api/v1/payments', $data);
} catch (ValidationException $e) {
$message = json_decode($e->validator->getMessageBag(), 1);
\Log::error("this should not hit");
\Log::error($message);
}
$arr = $response->json();
$response->assertStatus(200);

View File

@ -0,0 +1,175 @@
<?php
namespace Tests\Feature;
use App\Factory\CreditFactory;
use App\Factory\InvoiceItemFactory;
use App\Helpers\Invoice\InvoiceSum;
use App\Listeners\Credit\CreateCreditInvitation;
use App\Models\Client;
use App\Models\Credit;
use App\Models\Invoice;
use App\Models\Payment;
use App\Models\Paymentable;
use App\Utils\Traits\MakesHash;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Routing\Middleware\ThrottleRequests;
use Illuminate\Support\Carbon;
use Illuminate\Validation\ValidationException;
use Tests\MockAccountData;
use Tests\TestCase;
/**
* @test
* @covers App\Services\Invoice\HandleReversal
*/
class ReverseInvoiceTest extends TestCase
{
use MakesHash;
use DatabaseTransactions;
use MockAccountData;
public function setUp() :void
{
parent::setUp();
$this->withoutMiddleware(
ThrottleRequests::class
);
$this->faker = \Faker\Factory::create();
Model::reguard();
$this->makeTestData();
$this->withoutExceptionHandling();
}
public function testReverseInvoice()
{
$amount = $this->invoice->amount;
$balance = $this->invoice->balance;
$this->invoice->service()->markPaid()->save();
$first_payment = $this->invoice->payments->first();
$this->assertEquals((float)$first_payment->amount, (float)$this->invoice->amount);
$this->assertEquals((float)$first_payment->applied, (float)$this->invoice->amount);
$this->assertTrue($this->invoice->invoiceReversable($this->invoice));
$balance_remaining = $this->invoice->balance;
$total_paid = $this->invoice->amount - $this->invoice->balance;
/*Adjust payment applied and the paymentables to the correct amount */
$paymentables = Paymentable::wherePaymentableType(Invoice::class)
->wherePaymentableId($this->invoice->id)
->get();
$paymentables->each(function ($paymentable) use($total_paid){
$reversable_amount = $paymentable->amount - $paymentable->refunded;
$total_paid -= $reversable_amount;
$paymentable->amount = $paymentable->refunded;
$paymentable->save();
});
/* Generate a credit for the $total_paid amount */
$credit = CreditFactory::create($this->invoice->company_id, $this->invoice->user_id);
$credit->client_id = $this->invoice->client_id;
$item = InvoiceItemFactory::create();
$item->quantity = 1;
$item->cost = (float)$total_paid;
$item->notes = "Credit for reversal of ".$this->invoice->number;
$line_items[] = $item;
$credit->line_items = $line_items;
$credit->save();
$credit_calc = new InvoiceSum($credit);
$credit_calc->build();
$credit = $credit_calc->getCredit();
$credit->service()
->setStatus(Credit::STATUS_SENT)
->markSent()->save();
/* Set invoice balance to 0 */
$this->invoice->ledger()->updateInvoiceBalance($balance_remaining*-1, $item->notes)->save();
/* Set invoice status to reversed... somehow*/
$this->invoice->service()->setStatus(Invoice::STATUS_REVERSED)->save();
/* Reduce client.paid_to_date by $total_paid amount */
$this->client->paid_to_date -= $total_paid;
/* Reduce the client balance by $balance_remaining */
$this->client->balance -= $balance_remaining;
$this->client->save();
//create a ledger row for this with the resulting Credit ( also include an explanation in the notes section )
}
public function testReversalViaAPI()
{
$this->assertEquals($this->client->balance, $this->invoice->balance);
$client_paid_to_date = $this->client->paid_to_date;
$client_balance = $this->client->balance;
$invoice_balance = $this->invoice->balance;
$this->assertEquals(Invoice::STATUS_SENT, $this->invoice->status_id);
$this->invoice = $this->invoice->service()->markPaid()->save();
$this->assertEquals($this->client->balance, ($this->invoice->balance*-1));
$this->assertEquals($this->client->paid_to_date, ($client_paid_to_date+$invoice_balance));
$this->assertEquals(0, $this->invoice->balance);
$this->assertEquals(Invoice::STATUS_PAID, $this->invoice->status_id);
$this->invoice = $this->invoice->service()->handleReversal()->save();
$this->assertEquals(Invoice::STATUS_REVERSED, $this->invoice->status_id);
$this->assertEquals(0, $this->invoice->balance);
$this->assertEquals($this->client->paid_to_date, ($client_paid_to_date));
}
public function testReversalNoPayment()
{
$this->assertEquals($this->client->balance, $this->invoice->balance);
$client_paid_to_date = $this->client->paid_to_date;
$client_balance = $this->client->balance;
$invoice_balance = $this->invoice->balance;
$this->assertEquals(Invoice::STATUS_SENT, $this->invoice->status_id);
$this->invoice = $this->invoice->service()->handleReversal()->save();
$this->assertEquals(Invoice::STATUS_REVERSED, $this->invoice->status_id);
$this->assertEquals(0, $this->invoice->balance);
$this->assertEquals($this->client->paid_to_date, ($client_paid_to_date));
$this->assertEquals($this->client->balance, ($client_balance-$invoice_balance));
}
}

View File

@ -27,18 +27,18 @@ class InvoiceActionsTest extends TestCase
public function testInvoiceIsDeletable()
{
$this->assertTrue($this->invoiceDeletable());
$this->assertFalse($this->invoiceReversable());
$this->assertFalse($this->invoiceCancellable());
$this->assertTrue($this->invoiceDeletable($this->invoice));
$this->assertFalse($this->invoiceReversable($this->invoice));
$this->assertFalse($this->invoiceCancellable($this->invoice));
}
public function testInvoiceIsReversable()
{
$this->invoice->service()->markPaid()->save();
$this->assertFalse($this->invoiceDeletable());
$this->assertTrue($this->invoiceReversable());
$this->assertFalse($this->invoiceCancellable());
$this->assertFalse($this->invoiceDeletable($this->invoice));
$this->assertTrue($this->invoiceReversable($this->invoice));
$this->assertFalse($this->invoiceCancellable($this->invoice));
}
public function testInvoiceIsCancellable()
@ -53,9 +53,9 @@ class InvoiceActionsTest extends TestCase
$this->invoice->service()->applyPayment($payment, 5)->save();
$this->assertFalse($this->invoiceDeletable());
$this->assertTrue($this->invoiceReversable());
$this->assertTrue($this->invoiceCancellable());
$this->assertFalse($this->invoiceDeletable($this->invoice));
$this->assertTrue($this->invoiceReversable($this->invoice));
$this->assertTrue($this->invoiceCancellable($this->invoice));
}
public function testInvoiceUnactionable()
@ -63,9 +63,9 @@ class InvoiceActionsTest extends TestCase
$this->invoice->delete();
$this->assertFalse($this->invoiceDeletable());
$this->assertFalse($this->invoiceReversable());
$this->assertFalse($this->invoiceCancellable());
$this->assertFalse($this->invoiceDeletable($this->invoice));
$this->assertFalse($this->invoiceReversable($this->invoice));
$this->assertFalse($this->invoiceCancellable($this->invoice));
}
}