1
0
mirror of https://github.com/invoiceninja/invoiceninja.git synced 2024-11-05 18:52:44 +01:00

Payment refunds, Projects, Expenses and Vendors. (#3228)

* OpenAPI Documentation for Vendors and Expenses

* Rules for refunds

* Rules for Payment Refunds

* Fixes for quote invitation link

* Project
This commit is contained in:
David Bomba 2020-01-20 21:10:33 +11:00 committed by GitHub
parent 0e9d098049
commit a70b024d94
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 484 additions and 16 deletions

View File

@ -22,6 +22,7 @@ use App\Models\PaymentType;
use App\Models\User;
use App\Repositories\InvoiceRepository;
use App\Utils\Traits\MakesHash;
use Carbon\Carbon;
use Faker\Factory;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
@ -232,6 +233,17 @@ class CreateTestData extends Command
$this->createClient($company, $user);
}
foreach($company->clients as $client) {
$this->createInvoice($client);
$this->createQuote($client);
$this->createExpense($client);
$this->createVendor($client);
}
}
private function createClient($company, $user)
@ -254,16 +266,8 @@ class CreateTestData extends Command
'company_id' => $company->id
]);
$y = $this->count * rand(1, 5);
$this->info("Creating {$y} invoices & quotes");
for ($x=0; $x<$y; $x++) {
$this->createInvoice($client);
$this->createQuote($client);
$this->createExpense($client);
$this->createVendor($client);
}
}
private function createExpense($client)
@ -308,7 +312,8 @@ class CreateTestData extends Command
$invoice = InvoiceFactory::create($client->company->id, $client->user->id);//stub the company and user_id
$invoice->client_id = $client->id;
$invoice->date = $faker->date();
// $invoice->date = $faker->date();
$invoice->date = Carbon::now()->subDays(rand(0,90));
$invoice->line_items = $this->buildLineItems();
$invoice->uses_inclusive_taxes = false;

View File

@ -0,0 +1,43 @@
<?php
/**
* @OA\Schema(
* schema="Expense",
* type="object",
* @OA\Property(property="id", type="string", example="Opnel5aKBz", description="_________"),
* @OA\Property(property="user_id", type="string", example="", description="__________"),
* @OA\Property(property="assigned_user_id", type="string", example="", description="__________"),
* @OA\Property(property="company_id", type="string", example="", description="________"),
* @OA\Property(property="client_id", type="string", example="", description="________"),
* @OA\Property(property="invoice_id", type="string", example="", description="________"),
* @OA\Property(property="bank_id", type="string", example="", description="________"),
* @OA\Property(property="invoice_currency_id", type="string", example="", description="________"),
* @OA\Property(property="expense_currency_id", type="string", example="", description="________"),
* @OA\Property(property="invoice_category_id", type="string", example="", description="________"),
* @OA\Property(property="payment_type_id", type="string", example="", description="________"),
* @OA\Property(property="recurring_expense_id", type="string", example="", description="________"),
* @OA\Property(property="private_notes", type="string", example="", description="________"),
* @OA\Property(property="public_notes", type="string", example="", description="________"),
* @OA\Property(property="transaction_reference", type="string", example="", description="________"),
* @OA\Property(property="transcation_id", type="string", example="", description="________"),
* @OA\Property(property="custom_value1", type="string", example="", description="________"),
* @OA\Property(property="custom_value2", type="string", example="", description="________"),
* @OA\Property(property="custom_value3", type="string", example="", description="________"),
* @OA\Property(property="custom_value4", type="string", example="", description="________"),
* @OA\Property(property="tax_name1", type="string", example="", description="________"),
* @OA\Property(property="tax_name2", type="string", example="", description="________"),
* @OA\Property(property="tax_rate1", type="number", format="float", example="10.00", description="_________"),
* @OA\Property(property="tax_rate2", type="number", format="float", example="10.00", description="_________"),
* @OA\Property(property="tax_name3", type="string", example="", description="________"),
* @OA\Property(property="tax_rate3", type="number", format="float", example="10.00", description="_________"),
* @OA\Property(property="amount", type="number", format="float", example="10.00", description="_________"),
* @OA\Property(property="foreign_amount", type="number", format="float", example="10.00", description="_________"),
* @OA\Property(property="exchange_rate", type="number", format="float", example="0.80", description="_________"),
* @OA\Property(property="expense_date", type="string", example="", description="________"),
* @OA\Property(property="payment_date", type="string", example="", description="________"),
* @OA\Property(property="should_be_invoiced", type="boolean", example=true, description="_________"),
* @OA\Property(property="is_deleted", type="boolean", example=true, description="_________"),
* @OA\Property(property="invoice_documents", type="boolean", example=true, description=""),
* @OA\Property(property="updated_at", type="number", format="integer", example="1434342123", description="Timestamp"),
* @OA\Property(property="archived_at", type="number", format="integer", example="1434342123", description="Timestamp"),
* )
*/

View File

@ -0,0 +1,23 @@
<?php
/**
* @OA\Schema(
* schema="VendorContact",
* type="object",
* @OA\Property(property="id", type="string", example="Opnel5aKBz", description="_________"),
* @OA\Property(property="user_id", type="string", example="", description="__________"),
* @OA\Property(property="company_id", type="string", example="", description="________"),
* @OA\Property(property="vendor_id", type="string", example="", description="________"),
* @OA\Property(property="first_name", type="string", example="", description="________"),
* @OA\Property(property="last_name", type="string", example="", description="________"),
* @OA\Property(property="phone", type="string", example="", description="________"),
* @OA\Property(property="custom_value1", type="string", example="", description="________"),
* @OA\Property(property="custom_value2", type="string", example="", description="________"),
* @OA\Property(property="custom_value3", type="string", example="", description="________"),
* @OA\Property(property="custom_value4", type="string", example="", description="________"),
* @OA\Property(property="email", type="string", example="", description="________"),
* @OA\Property(property="is_primary", type="boolean", example=true, description="________"),
* @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"),
* @OA\Property(property="deleted_at", type="number", format="integer", example="134341234234", description="Timestamp"),
* )
*/

View File

@ -0,0 +1,44 @@
<?php
/**
* @OA\Schema(
* schema="Vendor",
* type="object",
* @OA\Property(property="id", type="string", example="Opnel5aKBz", description="_________"),
* @OA\Property(property="user_id", type="string", example="", description="__________"),
* @OA\Property(property="assigned_user_id", type="string", example="", description="__________"),
* @OA\Property(property="company_id", type="string", example="", description="________"),
* @OA\Property(property="client_id", type="string", example="", description="________"),
* @OA\Property(
* property="contacts",
* type="array",
* @OA\Items(
*
* ref="#/components/schemas/VendorContact",
* ),
* ),
* @OA\Property(property="name", type="string", example="", description="________"),
* @OA\Property(property="website", type="string", example="", description="________"),
* @OA\Property(property="private_notes", type="string", example="", description="________"),
* @OA\Property(property="industry_id", type="string", example="", description="________"),
* @OA\Property(property="size_id", type="string", example="", description="________"),
* @OA\Property(property="address1", type="string", example="", description="________"),
* @OA\Property(property="address2", type="string", example="", description="________"),
* @OA\Property(property="city", type="string", example="", description="________"),
* @OA\Property(property="state", type="string", example="", description="________"),
* @OA\Property(property="postal_code", type="string", example="", description="________"),
* @OA\Property(property="work_phone", type="string", example="555-3434-3434", description="The client phone number"),
* @OA\Property(property="country_id", type="string", example="", description="________"),
* @OA\Property(property="currency_id", type="string", example="4", description="________"),
* @OA\Property(property="custom_value1", type="string", example="", description="________"),
* @OA\Property(property="custom_value2", type="string", example="", description="________"),
* @OA\Property(property="custom_value3", type="string", example="", description="________"),
* @OA\Property(property="custom_value4", type="string", example="", description="________"),
* @OA\Property(property="vat_number", type="string", example="", description="________"),
* @OA\Property(property="id_number", type="string", example="", description="________"),
* @OA\Property(property="is_deleted", type="boolean", example=true, description="________"),
* @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"),
* @OA\Property(property="settings",ref="#/components/schemas/CompanySettings"),
* )
*/

View File

@ -17,6 +17,7 @@ use App\Http\Requests\Payment\ActionPaymentRequest;
use App\Http\Requests\Payment\CreatePaymentRequest;
use App\Http\Requests\Payment\DestroyPaymentRequest;
use App\Http\Requests\Payment\EditPaymentRequest;
use App\Http\Requests\Payment\RefundPaymentRequest;
use App\Http\Requests\Payment\ShowPaymentRequest;
use App\Http\Requests\Payment\StorePaymentRequest;
use App\Http\Requests\Payment\UpdatePaymentRequest;
@ -624,4 +625,60 @@ class PaymentController extends BaseController
break;
}
}
/**
* Store a newly created refund.
*
* @param \App\Http\Requests\Payment\RefundPaymentRequest $request The request
*
* @return \Illuminate\Http\Response
*
*
*
* @OA\Post(
* path="/api/v1/payments/refund",
* operationId="storeRefund",
* tags={"payments"},
* summary="Adds a Refund",
* description="Adds an Refund to the system",
* @OA\Parameter(ref="#/components/parameters/X-Api-Secret"),
* @OA\Parameter(ref="#/components/parameters/X-Api-Token"),
* @OA\Parameter(ref="#/components/parameters/X-Requested-With"),
* @OA\Parameter(ref="#/components/parameters/include"),
* @OA\RequestBody(
* description="The refund request",
* required=true,
* @OA\JsonContent(ref="#/components/schemas/Payment"),
* ),
* @OA\Response(
* response=200,
* description="Returns the saved Payment object",
* @OA\Header(header="X-API-Version", ref="#/components/headers/X-API-Version"),
* @OA\Header(header="X-RateLimit-Remaining", ref="#/components/headers/X-RateLimit-Remaining"),
* @OA\Header(header="X-RateLimit-Limit", ref="#/components/headers/X-RateLimit-Limit"),
* @OA\JsonContent(ref="#/components/schemas/Payment"),
* ),
* @OA\Response(
* response=422,
* description="Validation error",
* @OA\JsonContent(ref="#/components/schemas/ValidationError"),
*
* ),
* @OA\Response(
* response="default",
* description="Unexpected Error",
* @OA\JsonContent(ref="#/components/schemas/Error"),
* ),
* )
*
*/
public function refund(RefundPaymentRequest $request)
{
\Log::error("Payment id = ".$request->input('id'));
$payment = Payment::whereId($request->input('id'))->first();
return $this->itemResponse($payment);
}
}

View File

@ -0,0 +1,59 @@
<?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\Requests\Payment;
use App\Http\Requests\Request;
use App\Http\ValidationRules\ValidRefundableInvoices;
use App\Models\Payment;
use App\Utils\Traits\MakesHash;
class RefundPaymentRequest extends Request
{
use MakesHash;
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize() : bool
{
return auth()->user()->isAdmin();
}
protected function prepareForValidation()
{
$input = $this->all();
if(!isset($input['gateway_refund']))
$input['gateway_refund'] = false;
if(isset($input['id']))
$input['id'] = $this->decodePrimaryKey($input['id']);
$this->replace($input);
}
public function rules()
{
$rules = [
'id' => 'required',
'refunded' => 'numeric',
'date' => 'required',
'invoices.*.invoice_id' => 'required',
'invoices.*.refunded' => 'required',
'invoices' => new ValidRefundableInvoices(),
];
return $rules;
}
}

View File

@ -0,0 +1,83 @@
<?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;
use App\Models\Invoice;
use App\Models\Payment;
use App\Utils\Traits\MakesHash;
use Illuminate\Contracts\Validation\Rule;
/**
* Class ValidRefundableInvoices
* @package App\Http\ValidationRules
*/
class ValidRefundableInvoices implements Rule
{
use MakesHash;
/**
* @param string $attribute
* @param mixed $value
* @return bool
*/
private $error_msg;
public function passes($attribute, $value)
{
$payment = Payment::whereId($this->decodePrimaryKey(request()->input('id')))->first();
if($request->has('refunded') && ($request->input('refunded') > $payment->amount)){
$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;
}
/*If no invoices has been sent, then we apply the payment to the client account*/
$invoices = [];
if (is_array($value)) {
$invoices = Invoice::whereIn('id', array_column($value, 'invoice_id'))->company()->get();
}
else
return true;
foreach ($invoices as $invoice) {
if (! $invoice->isRefundable()) {
$this->error_msg = "One or more of these invoices have been paid";
return false;
}
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";
return false;
}
}
}
return true;
}
/**
* @return string
*/
public function message()
{
return $this->error_msg;
}
}

View File

@ -231,6 +231,17 @@ class Invoice extends BaseModel
}
}
public function isRefundable() : bool
{
if($this->is_deleted){
return false;
} elseif ($this->balance <= 0)
return false;
return true;
}
public static function badgeForStatus(int $status)
{
switch ($status) {

62
app/Models/Project.php Normal file
View File

@ -0,0 +1,62 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\SoftDeletes;
use Laracasts\Presenter\PresentableTrait;
/**
* Class Project.
*/
class Project extends BaseModel
{
// Expense Categories
use SoftDeletes;
use PresentableTrait;
/**
* @var array
*/
protected $dates = ['deleted_at'];
/**
* @var array
*/
protected $fillable = [
'name',
'task_rate',
'private_notes',
'due_date',
'budgeted_hours',
'custom_value1',
'custom_value2',
];
/**
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function company()
{
return $this->belongsTo(Company::class);
}
/**
* @return mixed
*/
public function client()
{
return $this->belongsTo(Client::class)->withTrashed();
}
// /**
// * @return \Illuminate\Database\Eloquent\Relations\HasMany
// */
// public function tasks()
// {
// return $this->hasMany('App\Models\Task');
// }
}

View File

@ -12,12 +12,14 @@
namespace App\Models;
use App\Models\Quote;
use App\Utils\Traits\Inviteable;
use App\Utils\Traits\MakesDates;
use Illuminate\Database\Eloquent\Model;
class QuoteInvitation extends BaseModel
{
use MakesDates;
use Inviteable;
protected $fillable = [
'id',

View File

@ -74,6 +74,8 @@ Route::group(['middleware' => ['api_db','api_secret_check','token_auth','locale'
Route::resource('payments', 'PaymentController'); // name = (payments. index / create / show / update / destroy / edit
Route::post('payments/refund', 'PaymentController@refund')->name('payments.refund');
Route::post('payments/bulk', 'PaymentController@bulk')->name('payments.bulk');
// Route::resource('users', 'UserController')->middleware('password_protected'); // name = (users. index / create / show / update / destroy / edit

View File

@ -116,7 +116,7 @@ class ClientApiTest extends TestCase
])->post('/api/v1/clients/bulk?action=archive', $data);
$arr = $response->json();
\Log::error($arr);
$this->assertNotNull($arr['data'][0]['archived_at']);
}

View File

@ -330,7 +330,7 @@ class PaymentTest extends TestCase
$payment_id = $arr['data']['id'];
$payment = Payment::find($this->decodePrimaryKey($payment_id))->first();
$payment = Payment::whereId($this->decodePrimaryKey($payment_id))->first();
$this->assertNotNull($payment);
$this->assertNotNull($payment->invoices());
@ -405,7 +405,7 @@ class PaymentTest extends TestCase
$payment_id = $arr['data']['id'];
$payment = Payment::find($this->decodePrimaryKey($payment_id))->first();
$payment = Payment::whereId($this->decodePrimaryKey($payment_id))->first();
$this->assertNotNull($payment);
$this->assertNotNull($payment->invoices());
@ -466,7 +466,7 @@ class PaymentTest extends TestCase
$payment_id = $arr['data']['id'];
$payment = Payment::find($this->decodePrimaryKey($payment_id))->first();
$payment = Payment::whereId($this->decodePrimaryKey($payment_id))->first();
$this->assertNotNull($payment);
$this->assertNotNull($payment->invoices());
@ -786,7 +786,7 @@ class PaymentTest extends TestCase
$payment_id = $arr['data']['id'];
$payment = Payment::find($this->decodePrimaryKey($payment_id))->first();
$payment = Payment::whereId($this->decodePrimaryKey($payment_id))->first();
$this->assertEquals($payment->amount, 15);
$this->assertEquals($payment->applied, 10);
@ -890,7 +890,7 @@ class PaymentTest extends TestCase
$payment_id = $arr['data']['id'];
$payment = Payment::find($this->decodePrimaryKey($payment_id))->first();
$payment = Payment::whereId($this->decodePrimaryKey($payment_id))->first();
$this->assertEquals($payment->amount, 20);
$this->assertEquals($payment->applied, 10);
@ -1005,7 +1005,7 @@ class PaymentTest extends TestCase
$this->assertEquals($this->invoice->amount, $arr['data']['amount']);
$payment = Payment::find($this->decodePrimaryKey($payment_id))->first();
$payment = Payment::whereId($this->decodePrimaryKey($payment_id))->first();
$this->assertNotNull($payment);
$this->assertNotNull($payment->invoices());
@ -1066,4 +1066,81 @@ class PaymentTest extends TestCase
}
public function testBasicRefundValidation()
{
$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),
'refunded' => 50,
// '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/refund', $data);
$arr = $response->json();
$response->assertStatus(200);
}
}