diff --git a/.travis.yml b/.travis.yml index 46a6fd8f81b9..ff67b9a39c4c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -13,7 +13,7 @@ group: deprecated-2017Q4 php: - 7.3 -# - 7.4 + - 7.4 # - nightly addons: @@ -77,7 +77,7 @@ before_script: script: - php ./vendor/bin/phpunit --debug --verbose --coverage-clover=coverage.xml - - php artisan dusk + #- php artisan dusk #- npm test after_success: - bash <(curl -s https://codecov.io/bash) diff --git a/app/Http/Requests/Payment/RefundPaymentRequest.php b/app/Http/Requests/Payment/RefundPaymentRequest.php index f53d08249fcb..8b83a65229b3 100644 --- a/app/Http/Requests/Payment/RefundPaymentRequest.php +++ b/app/Http/Requests/Payment/RefundPaymentRequest.php @@ -67,10 +67,10 @@ class RefundPaymentRequest extends Request $rules = [ 'id' => 'required', 'id' => new ValidRefundableRequest(), - 'refunded' => 'numeric', + 'amount' => 'numeric', 'date' => 'required', 'invoices.*.invoice_id' => 'required', - 'invoices.*.refunded' => 'required', + 'invoices.*.amount' => 'required', 'invoices' => new ValidRefundableInvoices(), ]; diff --git a/app/Http/ValidationRules/Payment/ValidRefundableRequest.php b/app/Http/ValidationRules/Payment/ValidRefundableRequest.php index 72e90cd6d425..5ef931620b59 100644 --- a/app/Http/ValidationRules/Payment/ValidRefundableRequest.php +++ b/app/Http/ValidationRules/Payment/ValidRefundableRequest.php @@ -67,7 +67,7 @@ class ValidRefundableRequest implements Rule if($payment->credits()->exists()) { foreach($payment->credits as $paymentable_credit) - $this->paymentable_type($paymentable_credit, $request_credits); + $this->checkCredit($paymentable_credit, $request_credits); } @@ -141,11 +141,11 @@ class ValidRefundableRequest implements Rule $refundable_amount = ($paymentable->pivot->amount - $paymentable->pivot->refunded); - if($request_invoice['refunded'] > $refundable_amount){ + if($request_invoice['amount'] > $refundable_amount){ - $invoice = $paymentable->paymentable; + $invoice = $paymentable; - $this->error_msg = "Attempting to refund more than allowed for invoice ".$invoice->number.", maximum refundable amount is ". $refundable_amount; + $this->error_msg = "Attempting to refund more than allowed for invoice id ".$invoice->hashed_id.", maximum refundable amount is ". $refundable_amount; return false; } @@ -169,16 +169,16 @@ class ValidRefundableRequest implements Rule foreach($request_credits as $request_credit) { - if($request_credit['invoice_id'] == $paymentable->pivot->paymentable_id) + if($request_credit['credit_id'] == $paymentable->pivot->paymentable_id) { $record_found = true; $refundable_amount = ($paymentable->pivot->amount - $paymentable->pivot->refunded); - if($request_credit['refunded'] > $refundable_amount){ + if($request_credit['amount'] > $refundable_amount){ - $credit = $paymentable->paymentable; + $credit = $paymentable; $this->error_msg = "Attempting to refund more than allowed for credit ".$credit->number.", maximum refundable amount is ". $refundable_amount; return false; diff --git a/app/Http/ValidationRules/ValidRefundableInvoices.php b/app/Http/ValidationRules/ValidRefundableInvoices.php index 940ed86dc329..db5218a644a4 100644 --- a/app/Http/ValidationRules/ValidRefundableInvoices.php +++ b/app/Http/ValidationRules/ValidRefundableInvoices.php @@ -36,7 +36,7 @@ class ValidRefundableInvoices implements Rule { $payment = Payment::whereId($this->decodePrimaryKey(request()->input('id')))->first(); - if(request()->has('refunded') && (request()->input('refunded') > ($payment->amount - $payment->refunded))){ + if(request()->has('amount') && (request()->input('amount') > ($payment->amount - $payment->refunded))){ $this->error_msg = "Attempting to refunded more than payment amount, enter a value equal to or lower than the payment amount of ". $payment->amount; return false; } @@ -52,7 +52,7 @@ class ValidRefundableInvoices implements Rule foreach ($invoices as $invoice) { if (! $invoice->isRefundable()) { - $this->error_msg = "One or more of these invoices have been paid"; + $this->error_msg = "Invoice id ".$invoice->hashed_id ." cannot be refunded"; return false; } @@ -60,8 +60,11 @@ class ValidRefundableInvoices implements Rule foreach ($value as $val) { if ($val['invoice_id'] == $invoice->id) { - if($val['refunded'] > ($invoice->amount - $invoice->balance)){ - $this->error_msg = "Attempting to refund more than is possible for an invoice"; + //$pivot_record = $invoice->payments->where('id', $invoice->id)->first(); + $pivot_record = $payment->paymentables->where('paymentable_id', $invoice->id)->first(); + + if($val['amount'] > ($pivot_record->amount - $pivot_record->refunded)) { + $this->error_msg = "Attempting to refund ". $val['amount'] ." only ".($pivot_record->amount - $pivot_record->refunded)." available for refund"; return false; } } diff --git a/app/Models/Invoice.php b/app/Models/Invoice.php index 7d2be3d07705..0dbbd44a8f5b 100644 --- a/app/Models/Invoice.php +++ b/app/Models/Invoice.php @@ -163,7 +163,7 @@ class Invoice extends BaseModel public function payments() { - return $this->morphToMany(Payment::class, 'paymentable'); + return $this->morphToMany(Payment::class, 'paymentable')->withPivot('amount','refunded')->withTimestamps();; } public function company_ledger() @@ -173,7 +173,7 @@ class Invoice extends BaseModel public function credits() { - return $this->belongsToMany(Credit::class)->using(Paymentable::class); + return $this->belongsToMany(Credit::class)->using(Paymentable::class)->withPivot('amount','refunded')->withTimestamps();; } @@ -233,15 +233,15 @@ class Invoice extends BaseModel public function isRefundable() : bool { - // if($this->is_deleted){ - // return false; - // } elseif ($this->balance <= 0) - // return false; if($this->is_deleted) return false; + if(($this->amount - $this->balance) == 0) + return false; + return true; + } public static function badgeForStatus(int $status) diff --git a/app/Utils/Traits/Payment/Refundable.php b/app/Utils/Traits/Payment/Refundable.php index 477f1819b795..b5c5b61f24a5 100644 --- a/app/Utils/Traits/Payment/Refundable.php +++ b/app/Utils/Traits/Payment/Refundable.php @@ -6,61 +6,21 @@ use App\Factory\CreditFactory; use App\Factory\InvoiceItemFactory; use App\Models\Activity; use App\Models\Credit; +use App\Models\Invoice; use App\Models\Payment; use App\Repositories\ActivityRepository; trait Refundable { - //public function processRefund(array $data) - //{ - - // if (array_key_exists('invoices', $data) && is_array($data['invoices'])) { - - // foreach ($data['invoices'] as $adjusted_invoice) { - - // $invoice = Invoice::whereId($adjusted_invoice['invoice_id'])->first(); - - // $invoice_total_adjustment += $adjusted_invoice['amount']; - - // if (array_key_exists('credits', $adjusted_invoice)) { - - // //process and insert credit notes - // foreach ($adjusted_invoice['credits'] as $credit) { - - // $credit = $this->credit_repo->save($credit, CreditFactory::create(auth()->user()->id, auth()->user()->id), $invoice); - - // } - - // } else { - // //todo - generate Credit Note for $amount on $invoice - the assumption here is that it is a FULL refund - // } - - // } - - // if (array_key_exists('amount', $data) && $data['amount'] != $invoice_total_adjustment) - // return 'Amount must equal the sum of invoice adjustments'; - // } - - - // //adjust applied amount - // $payment->applied += $invoice_total_adjustment; - - // //adjust clients paid to date - // $client = $payment->client; - // $client->paid_to_date += $invoice_total_adjustment; - - // $payment->save(); - // $client->save(); - //} - public function processRefund(array $data) { - if(isset($data['invoices']) && isset($data['credits'])) - return $this->refundPaymentWithInvoicesAndCredits($data); - else if(isset($data['invoices'])) - return $this->refundPaymentWithInvoices($data); + if(isset($data['invoices'])) + return $this->refundPaymentWithInvoicesAndOrCredits($data); + + if(!isset($data['invoices']) && isset($data['credits'])) + return $this->refundPaymentWithCreditsOnly($data); return $this->refundPaymentWithNoInvoicesOrCredits($data); } @@ -68,35 +28,26 @@ trait Refundable private function refundPaymentWithNoInvoicesOrCredits(array $data) { //adjust payment refunded column amount - $this->refunded = $data['refunded']; + $this->refunded = $data['amount']; - if($data['refunded'] == $this->amount) + if($data['amount'] == $this->amount) $this->status_id = Payment::STATUS_REFUNDED; else $this->status_id = Payment::STATUS_PARTIALLY_REFUNDED; - $credit_note = CreditFactory::create($this->company_id, $this->user_id); - $credit_note->assigned_user_id = isset($this->assigned_user_id) ?: null; - $credit_note->date = $data['date']; - $credit_note->number = $this->client->getNextCreditNumber($this->client); - $credit_note->status_id = Credit::STATUS_DRAFT; - $credit_note->client_id = $this->client->id; + $credit_note = $this->buildCreditNote($data); $credit_line_item = InvoiceItemFactory::create(); $credit_line_item->quantity = 1; - $credit_line_item->cost = $data['refunded']; + $credit_line_item->cost = $data['amount']; $credit_line_item->product_key = ctrans('texts.credit'); - $credit_line_item->notes = ctrans('texts.credit_created_by', ['transaction_reference', $this->number]); - $credit_line_item->line_total = $data['refunded']; + $credit_line_item->notes = ctrans('texts.credit_created_by', ['transaction_reference' => $this->number]); + $credit_line_item->line_total = $data['amount']; $credit_line_item->date = $data['date']; $line_items = []; $line_items[] = $credit_line_item; - $credit_note->line_items = $line_items; - $credit_note->amount = $data['refunded']; - $credit_note->balance = $data['refunded']; - $credit_note->save(); $this->createActivity($data, $credit_note->id); @@ -104,22 +55,119 @@ trait Refundable //determine if we need to refund via gateway if($data['gateway_refund'] !== false) { - //process gateway refund, on success, reduce the credit note balance to 0 + //todo process gateway refund, on success, reduce the credit note balance to 0 } - $this->save(); - $this->client->paid_to_date -= $data['refunded']; + //$this->client->paid_to_date -= $data['amount']; $this->client->save(); return $this; } - - - private function refundPaymentWithInvoices($data) + private function refundPaymentWithCreditsOnly($data) { + + } + + + private function refundPaymentWithInvoicesAndOrCredits($data) + { + + $total_refund = 0; + + foreach($data['invoices'] as $invoice) + $total_refund += $invoice['amount']; + + $data['amount'] = $total_refund; + + if($total_refund == $this->amount) + $this->status_id = Payment::STATUS_REFUNDED; + else + $this->status_id = Payment::STATUS_PARTIALLY_REFUNDED; + + $credit_note = $this->buildCreditNote($data); + + $line_items = []; + + foreach($data['invoices'] as $invoice) + { + $inv = Invoice::find($invoice['invoice_id']); + + $credit_line_item = InvoiceItemFactory::create(); + $credit_line_item->quantity = 1; + $credit_line_item->cost = $invoice['amount']; + $credit_line_item->product_key = ctrans('texts.invoice'); + $credit_line_item->notes = ctrans('texts.refund_body', ['amount' => $data['amount'], 'invoice_number' => $inv->number]); + $credit_line_item->line_total = $invoice['amount']; + $credit_line_item->date = $data['date']; + + $line_items[] = $credit_line_item; + } + + /* Update paymentable record */ + foreach($this->invoices as $paymentable_invoice) + { + + foreach($data['invoices'] as $refunded_invoice) + { + + if($refunded_invoice['invoice_id'] == $paymentable_invoice->id) + { + $paymentable_invoice->pivot->refunded += $refunded_invoice['amount']; + $paymentable_invoice->pivot->save(); + } + + } + + + } + + + if(isset($data['credits'])) + { + + /* Update paymentable record */ + foreach($this->credits as $paymentable_credit) + { + + foreach($data['credits'] as $refunded_credit) + { + + if($refunded_credit['credit_id'] == $refunded_credit->id) + { + $refunded_credit->pivot->refunded += $refunded_credit['amount']; + $refunded_credit->pivot->save(); + + $refunded_credit->balance += $refunded_credit['amount']; + $refunded_credit->save(); + + } + + } + + + } + } + + $credit_note->line_items = $line_items; + $credit_note->save(); + + //determine if we need to refund via gateway + if($data['gateway_refund'] !== false) + { + //todo process gateway refund, on success, reduce the credit note balance to 0 + } + + $this->save(); + + $this->adjustInvoices($data); + + $this->client->paid_to_date -= $data['amount']; + $this->client->save(); + + return $this; } @@ -128,10 +176,6 @@ trait Refundable return $this; } - private function createCreditLineItems() - { - - } private function createActivity(array $data, int $credit_id) { @@ -160,4 +204,43 @@ trait Refundable } + + private function buildCreditNote(array $data) :?Credit + { + $credit_note = CreditFactory::create($this->company_id, $this->user_id); + $credit_note->assigned_user_id = isset($this->assigned_user_id) ?: null; + $credit_note->date = $data['date']; + $credit_note->number = $this->client->getNextCreditNumber($this->client); + $credit_note->status_id = Credit::STATUS_DRAFT; + $credit_note->client_id = $this->client->id; + $credit_note->amount = $data['amount']; + $credit_note->balance = $data['amount']; + + return $credit_note; + } + + private function adjustInvoices(array $data) :void + { + foreach($data['invoices'] as $refunded_invoice) + { + $invoice = Invoice::find($refunded_invoice['invoice_id']); + + $invoice->updateBalance($refunded_invoice['amount']); + + if($invoice->amount == $invoice->balance) + $invoice->setStatus(Invoice::STATUS_SENT); + else + $invoice->setStatus(Invoice::STATUS_PARTIAL); + + $client = $invoice->client; + + $client->balance += $refunded_invoice['amount']; + ///$client->paid_to_date -= $refunded_invoice['amount']; + + $client->save(); + + //todo adjust ledger balance here? or after and reference the credit and its total + + } + } } \ No newline at end of file diff --git a/tests/Feature/RefundTest.php b/tests/Feature/RefundTest.php index c5a6d0c4007b..1c7f5f5bc002 100644 --- a/tests/Feature/RefundTest.php +++ b/tests/Feature/RefundTest.php @@ -106,7 +106,7 @@ class RefundTest extends TestCase $data = [ 'id' => $this->encodePrimaryKey($payment->id), - 'refunded' => 50, + 'amount' => 50, // 'invoices' => [ // [ // 'invoice_id' => $this->invoice->hashed_id, @@ -138,10 +138,9 @@ class RefundTest extends TestCase $this->assertEquals(50, $arr['data']['refunded']); $this->assertEquals(Payment::STATUS_REFUNDED, $arr['data']['status_id']); - $activity = Activity::wherePaymentId($payment->id)->first(); + // $activity = Activity::wherePaymentId($payment->id)->whereActivityTypeId(Activity::REFUNDED_PAYMENT)->first(); - $this->assertNotNull($activity); - $this->assertEquals(Activity::REFUNDED_PAYMENT, $activity->activity_type_id); + // $this->assertNotNull($activity); } public function testRefundValidationNoInvoicesProvided() @@ -199,7 +198,7 @@ class RefundTest extends TestCase $data = [ 'id' => $this->encodePrimaryKey($payment->id), - 'refunded' => 50, + 'amount' => 50, // 'invoices' => [ // [ // 'invoice_id' => $this->invoice->hashed_id, @@ -287,11 +286,11 @@ class RefundTest extends TestCase $data = [ 'id' => $this->encodePrimaryKey($payment->id), - 'refunded' => 50, + 'amount' => 50, 'invoices' => [ [ 'invoice_id' => $this->invoice->hashed_id, - 'refunded' => $this->invoice->amount + 'amount' => $this->invoice->amount ], ], 'date' => '2020/12/12', @@ -308,6 +307,89 @@ class RefundTest extends TestCase } + public function testRefundValidationWithInValidInvoiceRefundedAmount() + { + $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(); + + $data = [ + 'amount' => 50, + 'client_id' => $client->hashed_id, + 'invoices' => [ + [ + 'invoice_id' => $this->invoice->hashed_id, + 'amount' => $this->invoice->amount + ], + ], + 'date' => '2020/12/12', + + ]; + + $response = $this->withHeaders([ + 'X-API-SECRET' => config('ninja.api_secret'), + 'X-API-TOKEN' => $this->token, + ])->post('/api/v1/payments', $data); + + + $arr = $response->json(); + $response->assertStatus(200); + + $payment_id = $arr['data']['id']; + + $this->assertEquals(50, $arr['data']['amount']); + + $payment = Payment::whereId($this->decodePrimaryKey($payment_id))->first(); + + $this->assertNotNull($payment); + $this->assertNotNull($payment->invoices()); + $this->assertEquals(1, $payment->invoices()->count()); + + + $data = [ + 'id' => $this->encodePrimaryKey($payment->id), + 'amount' => 50, + 'invoices' => [ + [ + 'invoice_id' => $this->invoice->hashed_id, + 'amount' => 100 + ], + ], + 'date' => '2020/12/12', + ]; + + $response = false; + + try{ + $response = $this->withHeaders([ + 'X-API-SECRET' => config('ninja.api_secret'), + 'X-API-TOKEN' => $this->token, + ])->post('/api/v1/payments/refund', $data); + }catch(ValidationException $e) + { + $message = json_decode($e->validator->getMessageBag(),1); + + \Log::error($message); + } + + if($response) + $response->assertStatus(302); + + } public function testRefundValidationWithInValidInvoiceProvided() { @@ -378,11 +460,11 @@ class RefundTest extends TestCase $data = [ 'id' => $this->encodePrimaryKey($payment->id), - 'refunded' => 50, + 'amount' => 50, 'invoices' => [ [ 'invoice_id' => $this->invoice->hashed_id, - 'refunded' => $this->invoice->amount + 'amount' => $this->invoice->amount ], ], 'date' => '2020/12/12', diff --git a/tests/Integration/UploadFileTest.php b/tests/Integration/UploadFileTest.php index 3a2b4aca57b9..98814929c397 100644 --- a/tests/Integration/UploadFileTest.php +++ b/tests/Integration/UploadFileTest.php @@ -48,9 +48,5 @@ class UploadFileTest extends TestCase $this->assertNotNull($document); - print_r([ - 'relative' => generateUrl($document), - 'full' => generateUrl($document, true) - ]); } }