diff --git a/app/Http/Controllers/ClientController.php b/app/Http/Controllers/ClientController.php index 84722eda5c6d..9f19c338aaec 100644 --- a/app/Http/Controllers/ClientController.php +++ b/app/Http/Controllers/ClientController.php @@ -11,37 +11,46 @@ namespace App\Http\Controllers; -use App\Events\Client\ClientWasCreated; -use App\Events\Client\ClientWasUpdated; +use App\Utils\Ninja; +use App\Models\Quote; +use App\Models\Client; +use App\Models\Credit; +use App\Models\Account; +use App\Models\Company; +use App\Models\Invoice; +use App\Models\Document; +use App\Models\SystemLog; +use Postmark\PostmarkClient; +use Illuminate\Http\Response; use App\Factory\ClientFactory; use App\Filters\ClientFilters; +use App\Utils\Traits\MakesHash; +use App\Utils\Traits\Uploadable; +use App\Utils\Traits\BulkOptions; +use App\Jobs\Client\UpdateTaxData; +use App\Utils\Traits\SavesDocuments; +use App\Repositories\ClientRepository; +use App\Events\Client\ClientWasCreated; +use App\Events\Client\ClientWasUpdated; +use App\Transformers\ClientTransformer; +use Illuminate\Support\Facades\Storage; +use App\Services\Template\TemplateAction; +use App\Jobs\PostMark\ProcessPostmarkWebhook; use App\Http\Requests\Client\BulkClientRequest; -use App\Http\Requests\Client\CreateClientRequest; -use App\Http\Requests\Client\DestroyClientRequest; use App\Http\Requests\Client\EditClientRequest; -use App\Http\Requests\Client\PurgeClientRequest; -use App\Http\Requests\Client\ReactivateClientEmailRequest; use App\Http\Requests\Client\ShowClientRequest; +use App\Http\Requests\Client\PurgeClientRequest; use App\Http\Requests\Client\StoreClientRequest; +use App\Http\Requests\Client\CreateClientRequest; use App\Http\Requests\Client\UpdateClientRequest; use App\Http\Requests\Client\UploadClientRequest; -use App\Jobs\Client\UpdateTaxData; -use App\Jobs\PostMark\ProcessPostmarkWebhook; -use App\Models\Account; -use App\Models\Client; -use App\Models\Company; -use App\Models\SystemLog; -use App\Repositories\ClientRepository; -use App\Services\Template\TemplateAction; -use App\Transformers\ClientTransformer; -use App\Utils\Ninja; -use App\Utils\Traits\BulkOptions; -use App\Utils\Traits\MakesHash; -use App\Utils\Traits\SavesDocuments; -use App\Utils\Traits\Uploadable; -use Illuminate\Http\Response; -use Illuminate\Support\Facades\Storage; -use Postmark\PostmarkClient; +use App\Http\Requests\Client\DestroyClientRequest; +use App\Http\Requests\Client\ClientDocumentsRequest; +use App\Http\Requests\Client\ReactivateClientEmailRequest; +use App\Models\Expense; +use App\Models\Payment; +use App\Models\Task; +use App\Transformers\DocumentTransformer; /** * Class ClientController. @@ -402,4 +411,24 @@ class ClientController extends BaseController } } + + public function documents(ClientDocumentsRequest $request, Client $client) + { + + $this->entity_type = Document::class; + + $this->entity_transformer = DocumentTransformer::class; + + $documents = Document::query() + ->company() + ->whereHasMorph('documentable', [Invoice::class, Quote::class, Credit::class, Expense::class, Payment::class, Task::class], function ($query) use($client) { + $query->where('client_id', $client->id); + }) + ->orWhereHasMorph('documentable', [Client::class], function ($query) use ($client){ + $query->where('id', $client->id); + }); + + return $this->listResponse($documents); + + } } diff --git a/app/Http/Controllers/ImportController.php b/app/Http/Controllers/ImportController.php index edd51d5d6fb2..81ae40103380 100644 --- a/app/Http/Controllers/ImportController.php +++ b/app/Http/Controllers/ImportController.php @@ -260,7 +260,7 @@ class ImportController extends Controller } } - return $bestDelimiter; + return $bestDelimiter ?? ','; } } diff --git a/app/Http/Requests/Client/ClientDocumentsRequest.php b/app/Http/Requests/Client/ClientDocumentsRequest.php new file mode 100644 index 000000000000..bbcdf3cf0203 --- /dev/null +++ b/app/Http/Requests/Client/ClientDocumentsRequest.php @@ -0,0 +1,30 @@ +user(); + + return $user->can('view', $this->client); + } +} diff --git a/app/Http/Requests/Client/PurgeClientRequest.php b/app/Http/Requests/Client/PurgeClientRequest.php index 7af1ae59e820..68f4fe57cd8a 100644 --- a/app/Http/Requests/Client/PurgeClientRequest.php +++ b/app/Http/Requests/Client/PurgeClientRequest.php @@ -22,6 +22,9 @@ class PurgeClientRequest extends Request */ public function authorize(): bool { - return auth()->user()->isAdmin(); + /** @var \App\Models\User $user */ + $user = auth()->user(); + + return $user->isAdmin(); } } diff --git a/app/Import/Providers/BaseImport.php b/app/Import/Providers/BaseImport.php index db9d3807baba..56bda23e86af 100644 --- a/app/Import/Providers/BaseImport.php +++ b/app/Import/Providers/BaseImport.php @@ -152,7 +152,8 @@ class BaseImport } } - return $bestDelimiter; + + return $bestDelimiter ?? ','; } public function mapCSVHeaderToKeys($csvData) diff --git a/app/Jobs/Company/CompanyExport.php b/app/Jobs/Company/CompanyExport.php index 406ac09e9984..00b69d235bd7 100644 --- a/app/Jobs/Company/CompanyExport.php +++ b/app/Jobs/Company/CompanyExport.php @@ -111,13 +111,16 @@ $this->export_data = null; $this->export_data['users'] = $this->company->users()->withTrashed()->cursor()->map(function ($user) { $user->account_id = $this->encodePrimaryKey($user->account_id); + return $user; })->all(); + $x = $this->writer->collection('users'); $x->addItems($this->export_data['users']); $this->export_data = null; + $this->export_data['client_contacts'] = $this->company->client_contacts->map(function ($client_contact) { $client_contact = $this->transformArrayOfKeys($client_contact, ['company_id', 'user_id', 'client_id']); @@ -663,20 +666,9 @@ $this->writer->end(); private function zipAndSend() { - // $file_name = date('Y-m-d').'_'.str_replace([" ", "/"], ["_",""], $this->company->present()->name() . '_' . $this->company->company_key .'.zip'); $zip_path = \Illuminate\Support\Str::ascii(str_replace(".json", ".zip", $this->file_name)); - // $path = 'backups'; - // Storage::makeDirectory(storage_path('backups/')); - - // try { - // mkdir(storage_path('backups/')); - // } catch(\Exception $e) { - // nlog("could not create directory"); - // } - - // $zip_path = storage_path('backups/'.\Illuminate\Support\Str::ascii($file_name)); $zip = new \ZipArchive(); if ($zip->open($zip_path, \ZipArchive::CREATE) !== true) { @@ -686,7 +678,6 @@ $this->writer->end(); $zip->addFile($this->file_name); $zip->renameName($this->file_name, 'backup.json'); - // $zip->addFromString("backup.json", json_encode($this->export_data)); $zip->close(); Storage::disk(config('filesystems.default'))->put('backups/'.str_replace(".json", ".zip",$this->file_name), file_get_contents($zip_path)); @@ -695,6 +686,10 @@ $this->writer->end(); unlink($zip_path); } + if(file_exists($this->file_name)){ + unlink($this->file_name); + } + if(Ninja::isSelfHost()) { $storage_path = 'backups/'.str_replace(".json", ".zip",$this->file_name); } else { @@ -709,8 +704,6 @@ $this->writer->end(); $t = app('translator'); $t->replace(Ninja::transformTranslations($this->company->settings)); - // $company_reference = Company::find($this->company->id); - $nmo = new NinjaMailerObject(); $nmo->mailable = new DownloadBackup($url, $this->company->withoutRelations()); $nmo->to_user = $this->user; diff --git a/app/Jobs/Company/CompanyImport.php b/app/Jobs/Company/CompanyImport.php index 4636fb40430e..0fe90c3e229d 100644 --- a/app/Jobs/Company/CompanyImport.php +++ b/app/Jobs/Company/CompanyImport.php @@ -312,7 +312,7 @@ class CompanyImport implements ShouldQueue } unlink($tmp_file); - unlink($this->file_location); + unlink(Storage::path($this->file_location)); } // diff --git a/app/Models/RecurringExpense.php b/app/Models/RecurringExpense.php index ed37adba939b..b83bf4b7a3df 100644 --- a/app/Models/RecurringExpense.php +++ b/app/Models/RecurringExpense.php @@ -77,6 +77,7 @@ use Illuminate\Support\Carbon; * @property-read mixed $hashed_id * @property-read \App\Models\User $user * @property-read \App\Models\Vendor|null $vendor + * @property-read \App\Models\ExpenseCategory|null $category * @method static \Illuminate\Database\Eloquent\Builder|BaseModel company() * @method static \Illuminate\Database\Eloquent\Builder|BaseModel exclude($columns) * @method static \Database\Factories\RecurringExpenseFactory factory($count = null, $state = []) @@ -140,17 +141,6 @@ use Illuminate\Support\Carbon; * @method static \Illuminate\Database\Eloquent\Builder|RecurringExpense withTrashed() * @method static \Illuminate\Database\Eloquent\Builder|RecurringExpense withoutTrashed() * @property-read \Illuminate\Database\Eloquent\Collection $documents - * @property-read \Illuminate\Database\Eloquent\Collection $documents - * @property-read \Illuminate\Database\Eloquent\Collection $documents - * @property-read \Illuminate\Database\Eloquent\Collection $documents - * @property-read \Illuminate\Database\Eloquent\Collection $documents - * @property-read \Illuminate\Database\Eloquent\Collection $documents - * @property-read \Illuminate\Database\Eloquent\Collection $documents - * @property-read \Illuminate\Database\Eloquent\Collection $documents - * @property-read \Illuminate\Database\Eloquent\Collection $documents - * @property-read \Illuminate\Database\Eloquent\Collection $documents - * @property-read \Illuminate\Database\Eloquent\Collection $documents - * @property-read \Illuminate\Database\Eloquent\Collection $documents * @mixin \Eloquent */ class RecurringExpense extends BaseModel @@ -247,6 +237,12 @@ class RecurringExpense extends BaseModel return $this->belongsTo(Client::class); } + public function category(): \Illuminate\Database\Eloquent\Relations\BelongsTo + { + return $this->belongsTo(ExpenseCategory::class)->withTrashed(); + } + + /** * Service entry points. */ diff --git a/app/Services/Subscription/ProRata.php b/app/Services/Subscription/ProRata.php new file mode 100644 index 000000000000..da69dd7e560c --- /dev/null +++ b/app/Services/Subscription/ProRata.php @@ -0,0 +1,308 @@ + | null $unpaid_invoices */ + private $unpaid_invoices = null; + + /** @var bool $refundable */ + private bool $refundable = false; + + /** @var int $pro_rata_duration */ + private int $pro_rata_duration = 0; + + /** @var int $subscription_interval_duration */ + private int $subscription_interval_duration = 0; + + /** @var int $pro_rata_ratio */ + private int $pro_rata_ratio = 1; + + public function __construct(public Subscription $subscription, protected RecurringInvoice $recurring_invoice) + { + } + + public function run() + { + $this->setCalculations(); + } + + private function setCalculations(): self + { + $this->isInTrialPeriod() + ->checkUnpaidInvoices() + ->checkRefundPeriod() + ->checkProRataDuration() + ->calculateSubscriptionIntervalDuration() + ->calculateProRataRatio(); + + return $this; + } + + /** + * Calculates the number of seconds + * of the current interval that has been used. + * + * @return self + */ + private function checkProRataDuration(): self + { + + $primary_invoice = $this->recurring_invoice + ->invoices() + ->where('is_deleted', 0) + ->where('is_proforma', 0) + ->orderBy('id', 'desc') + ->first(); + + $duration = Carbon::parse($primary_invoice->date)->startOfDay()->diffInSeconds(now()); + + $this->setProRataDuration(max(0, $duration)); + + return $this; + } + + private function calculateProRataRatio(): self + { + if($this->pro_rata_duration < $this->subscription_interval_duration) + $this->setProRataRatio($this->pro_rata_duration/$this->subscription_interval_duration); + + return $this; + } + + + private function calculateSubscriptionIntervalDuration(): self + { + if($this->getIsTrial()) + return $this->setSubscriptionIntervalDuration(0); + + $primary_invoice = $this->recurring_invoice + ->invoices() + ->where('is_deleted', 0) + ->where('is_proforma', 0) + ->orderBy('id', 'desc') + ->first(); + + if(!$primary_invoice) + return $this->setSubscriptionIntervalDuration(0); + + $start = Carbon::parse($primary_invoice->date); + $end = Carbon::parse($this->recurring_invoice->next_send_date_client); + + $this->setSubscriptionIntervalDuration($start->diffInSeconds($end)); + + return $this; + } + + /** + * Determines if this subscription + * is eligible for a refund. + * + * @return self + */ + private function checkRefundPeriod(): self + { + if(!$this->subscription->refund_period || $this->subscription->refund_period === 0) + return $this->setRefundable(false); + + $primary_invoice = $this->recurring_invoice + ->invoices() + ->where('is_deleted', 0) + ->where('is_proforma', 0) + ->orderBy('id', 'desc') + ->first(); + + if($primary_invoice && + $primary_invoice->status_id == Invoice::STATUS_PAID && + Carbon::parse($primary_invoice->date)->addSeconds($this->subscription->refund_period)->lte(now()->startOfDay()->addSeconds($primary_invoice->client->timezone_offset())) + ){ + return $this->setRefundable(true); + } + + return $this->setRefundable(false); + + } + + /** + * Gathers any unpaid invoices for this subscription. + * + * @return self + */ + private function checkUnpaidInvoices(): self + { + $this->unpaid_invoices = $this->recurring_invoice + ->invoices() + ->where('is_deleted', 0) + ->where('is_proforma', 0) + ->whereIn('status_id', [Invoice::STATUS_SENT, Invoice::STATUS_PARTIAL]) + ->where('balance', '>', 0) + ->get(); + + return $this; + } + + private function setProRataRatio(int $ratio): self + { + $this->pro_rata_ratio = $ratio; + + return $this; + } + /** + * setSubscriptionIntervalDuration + * + * @param int $seconds + * @return self + */ + private function setSubscriptionIntervalDuration(int $seconds): self + { + $this->subscription_interval_duration = $seconds; + + return $this; + } + + /** + * setProRataDuration + * + * @param int $seconds + * @return self + */ + private function setProRataDuration(int $seconds): self + { + $this->pro_rata_duration = $seconds; + + return $this; + } + + /** + * setRefundable + * + * @param bool $refundable + * @return self + */ + private function setRefundable(bool $refundable): self + { + $this->refundable = $refundable; + + return $this; + } + + /** + * Determines if this users is in their trial period + * + * @return self + */ + private function isInTrialPeriod(): self + { + + if(!$this->subscription->trial_enabled) + return $this->setIsTrial(false); + + $primary_invoice = $this->recurring_invoice + ->invoices() + ->where('is_deleted', 0) + ->where('is_proforma', 0) + ->orderBy('id', 'asc') + ->first(); + + if($primary_invoice && Carbon::parse($primary_invoice->date)->addSeconds($this->subscription->trial_duration)->lte(now()->startOfDay()->addSeconds($primary_invoice->client->timezone_offset()))) + return $this->setIsTrial(true); + + $this->setIsTrial(false); + + return $this; + } + + /** + * Sets the is_trial flag + * + * @param bool $is_trial + * @return self + */ + private function setIsTrial(bool $is_trial): self + { + $this->is_trial = $is_trial; + + return $this; + } + + + /** + * Getter for unpaid invoices + * + * @return \Illuminate\Database\Eloquent\Collection | null + */ + public function getUnpaidInvoices(): ?\Illuminate\Database\Eloquent\Collection + { + return $this->unpaid_invoices; + } + + /** + * Gets the is_trial flag + * + * @return bool + */ + public function getIsTrial(): bool + { + return $this->is_trial; + } + + /** + * Getter for refundable flag + * + * @return bool + */ + public function getRefundable(): bool + { + return $this->refundable; + } + + /** + * The number of seconds used in the current duration + * + * @return int + */ + public function getProRataDuration(): int + { + return $this->pro_rata_duration; + } + + /** + * The total number of seconds in this subscription interval + * + * @return int + */ + public function getSubscriptionIntervalDuration(): int + { + return $this->subscription_interval_duration; + } + + + /** + * Returns the pro rata ratio to be applied to any credit. + * + * @return int + */ + public function getProRataRatio(): int + { + return $this->pro_rata_ratio; + } +} \ No newline at end of file diff --git a/routes/api.php b/routes/api.php index 8903843abe1f..904885c59174 100644 --- a/routes/api.php +++ b/routes/api.php @@ -168,6 +168,7 @@ Route::group(['middleware' => ['throttle:api', 'api_db', 'token_auth', 'locale'] Route::post('clients/{client}/updateTaxData', [ClientController::class, 'updateTaxData'])->name('clients.update_tax_data')->middleware('throttle:3,1'); Route::post('clients/{client}/{mergeable_client}/merge', [ClientController::class, 'merge'])->name('clients.merge')->middleware('password_protected'); Route::post('clients/bulk', [ClientController::class, 'bulk'])->name('clients.bulk'); + Route::post('clients/{client}/documents', [ClientController::class, 'documents'])->name('clients.documents'); Route::post('reactivate_email/{bounce_id}', [ClientController::class, 'reactivateEmail'])->name('clients.reactivate_email'); diff --git a/tests/Feature/ClientApiTest.php b/tests/Feature/ClientApiTest.php index 9cb18a2dc2e3..882fd3cdd459 100644 --- a/tests/Feature/ClientApiTest.php +++ b/tests/Feature/ClientApiTest.php @@ -59,6 +59,148 @@ class ClientApiTest extends TestCase Model::reguard(); } + public function testClientDocumentQuery() + { + + $d = \App\Models\Document::factory()->create([ + 'company_id' => $this->company->id, + 'user_id' => $this->user->id, + ]); + + $this->invoice->documents()->save($d); + + $response = $this->withHeaders([ + 'X-API-TOKEN' => $this->token, + ])->postJson("/api/v1/clients/{$this->client->hashed_id}/documents") + ->assertStatus(200); + + $arr = $response->json(); + + $this->assertCount(1, $arr['data']); + + $d = \App\Models\Document::factory()->create([ + 'company_id' => $this->company->id, + 'user_id' => $this->user->id, + ]); + + $this->client->documents()->save($d); + + $response = $this->withHeaders([ + 'X-API-TOKEN' => $this->token, + ])->postJson("/api/v1/clients/{$this->client->hashed_id}/documents") + ->assertStatus(200); + + $arr = $response->json(); + + $this->assertCount(2, $arr['data']); + + + $d = \App\Models\Document::factory()->create([ + 'company_id' => $this->company->id, + 'user_id' => $this->user->id, + ]); + + $this->client->documents()->save($d); + + $response = $this->withHeaders([ + 'X-API-TOKEN' => $this->token, + ])->postJson("/api/v1/clients/{$this->client->hashed_id}/documents") + ->assertStatus(200); + + $arr = $response->json(); + + $this->assertCount(3, $arr['data']); + + $d = \App\Models\Document::factory()->create([ + 'company_id' => $this->company->id, + 'user_id' => $this->user->id, + ]); + + $this->quote->documents()->save($d); + + $response = $this->withHeaders([ + 'X-API-TOKEN' => $this->token, + ])->postJson("/api/v1/clients/{$this->client->hashed_id}/documents") + ->assertStatus(200); + + $arr = $response->json(); + + $this->assertCount(4, $arr['data']); + + + + $d = \App\Models\Document::factory()->create([ + 'company_id' => $this->company->id, + 'user_id' => $this->user->id, + ]); + + $this->credit->documents()->save($d); + + $response = $this->withHeaders([ + 'X-API-TOKEN' => $this->token, + ])->postJson("/api/v1/clients/{$this->client->hashed_id}/documents") + ->assertStatus(200); + + $arr = $response->json(); + + $this->assertCount(5, $arr['data']); + + + + $d = \App\Models\Document::factory()->create([ + 'company_id' => $this->company->id, + 'user_id' => $this->user->id, + ]); + + + $e = \App\Models\Expense::factory()->create([ + 'company_id' => $this->company->id, + 'user_id' => $this->user->id, + 'client_id' => $this->client->id, + 'amount' => 100 + ]); + + + $e->documents()->save($d); + + $response = $this->withHeaders([ + 'X-API-TOKEN' => $this->token, + ])->postJson("/api/v1/clients/{$this->client->hashed_id}/documents") + ->assertStatus(200); + + $arr = $response->json(); + + $this->assertCount(6, $arr['data']); + + +$d = \App\Models\Document::factory()->create([ + 'company_id' => $this->company->id, + 'user_id' => $this->user->id, +]); + + +$t = \App\Models\Task::factory()->create([ + 'company_id' => $this->company->id, + 'user_id' => $this->user->id, + 'client_id' => $this->client->id, +]); + + +$t->documents()->save($d); + +$response = $this->withHeaders([ + 'X-API-TOKEN' => $this->token, +])->postJson("/api/v1/clients/{$this->client->hashed_id}/documents") +->assertStatus(200); + +$arr = $response->json(); + +$this->assertCount(7, $arr['data']); + + + + + } public function testCrossCompanyBulkActionsFail() {