From 6814b3c917f7635ee36c0f9b8ba0c794554afc8e Mon Sep 17 00:00:00 2001 From: Faruk Nasir Date: Thu, 24 Aug 2023 13:05:08 +0100 Subject: [PATCH] chore: reporting --- config/instrument.php | 54 ++ .../create_instrument_table.php.stub | 19 + routes/web.php | 8 + src/Actions/CreateReport.php | 47 ++ src/Actions/DeleteReport.php | 24 + src/Actions/UpdateReport.php | 40 + src/Commands/InstallCommand.php | 1 + src/Contracts/CreatesReports.php | 14 + src/Contracts/DeletesReports.php | 14 + src/Contracts/UpdatesReports.php | 14 + src/Events/CreatingReport.php | 7 + src/Events/DeletingReport.php | 7 + src/Events/ReportCreated.php | 7 + src/Events/ReportDeleted.php | 7 + src/Events/ReportEvent.php | 29 + src/Events/ReportUpdated.php | 7 + src/Events/UpdatingReport.php | 7 + src/Http/Controllers/ReportController.php | 73 ++ src/Instrument.php | 81 +++ src/InstrumentServiceProvider.php | 6 + src/InteractsWithReport.php | 59 ++ src/Report.php | 18 + src/Reports/Report.php | 683 ++++++++++++++++++ src/TeamHasInstruments.php | 20 + stubs/app/Models/Report.php | 55 ++ .../Providers/InstrumentServiceProvider.php | 3 + tests/Feature/Actions/CreateReportTest.php | 35 + tests/Feature/Actions/DeleteReportTest.php | 30 + tests/Feature/Actions/UpdateReportTest.php | 36 + tests/Feature/ReportTest.php | 73 ++ tests/Mocks/Report.php | 63 ++ tests/Mocks/ReportFactory.php | 24 + tests/Pest.php | 11 + 33 files changed, 1576 insertions(+) create mode 100644 src/Actions/CreateReport.php create mode 100644 src/Actions/DeleteReport.php create mode 100644 src/Actions/UpdateReport.php create mode 100644 src/Contracts/CreatesReports.php create mode 100644 src/Contracts/DeletesReports.php create mode 100644 src/Contracts/UpdatesReports.php create mode 100644 src/Events/CreatingReport.php create mode 100644 src/Events/DeletingReport.php create mode 100644 src/Events/ReportCreated.php create mode 100644 src/Events/ReportDeleted.php create mode 100644 src/Events/ReportEvent.php create mode 100644 src/Events/ReportUpdated.php create mode 100644 src/Events/UpdatingReport.php create mode 100644 src/Http/Controllers/ReportController.php create mode 100644 src/InteractsWithReport.php create mode 100644 src/Report.php create mode 100644 src/Reports/Report.php create mode 100644 stubs/app/Models/Report.php create mode 100644 tests/Feature/Actions/CreateReportTest.php create mode 100644 tests/Feature/Actions/DeleteReportTest.php create mode 100644 tests/Feature/Actions/UpdateReportTest.php create mode 100644 tests/Feature/ReportTest.php create mode 100644 tests/Mocks/Report.php create mode 100644 tests/Mocks/ReportFactory.php diff --git a/config/instrument.php b/config/instrument.php index 2ec17cc..c9fb58b 100644 --- a/config/instrument.php +++ b/config/instrument.php @@ -39,6 +39,11 @@ 'update' => null, 'destroy' => '/', ], + 'reports' => [ + 'store' => null, + 'update' => null, + 'destroy' => '/', + ], ], 'route_names' => [ @@ -77,5 +82,54 @@ 'update' => 'transactions.update', 'destroy' => 'transactions.destroy', ], + 'reports' => [ + 'store' => 'reports.store', + 'update' => 'reports.update', + 'destroy' => 'reports.destroy', + ], + ], + + /* + * The types + */ + 'report_types' => [ + 'expense' => Expense::class, + 'income' => Income::class, + 'profit_and_loss' => ProfitLoss::class, + 'tax' => Tax::class, + ], + + /** + * Group Options. + */ + 'report_groups' => [ + 'category', + 'customer', + 'item', + ], + + /** + * Period Options. + */ + 'report_periods' => [ + 'monthly', + 'quarterly', + 'yearly', + ], + + /** + * Basis Options. + */ + 'report_accounting_basis' => [ + 'cash', + 'accrual', + ], + + /** + * Chart Options. + */ + 'report_charts' => [ + // 'none', + // 'line', ], ]; diff --git a/database/migrations/create_instrument_table.php.stub b/database/migrations/create_instrument_table.php.stub index 44d13bb..528a53b 100644 --- a/database/migrations/create_instrument_table.php.stub +++ b/database/migrations/create_instrument_table.php.stub @@ -12,6 +12,7 @@ return new class extends Migration Schema::create('contacts', function (Blueprint $table) { $table->id(); $table->unsignedBigInteger('team_id')->nullable(); + $table->unsignedBigInteger('user_id')->nullable(); $table->string('type'); $table->string('name'); $table->json('meta'); @@ -33,6 +34,7 @@ return new class extends Migration Schema::create('taxes', function (Blueprint $table) { $table->id(); $table->unsignedBigInteger('team_id')->nullable(); + $table->unsignedBigInteger('user_id')->nullable(); $table->string('type')->nullable(); $table->string('name'); $table->double('rate', 15, 8, true); @@ -54,6 +56,7 @@ return new class extends Migration Schema::create('currencies', function (Blueprint $table) { $table->id(); $table->unsignedBigInteger('team_id')->nullable(); + $table->unsignedBigInteger('user_id')->nullable(); $table->string('name'); $table->string('code'); $table->double('rate', 15, 8); @@ -81,6 +84,7 @@ return new class extends Migration $table->id(); $table->unsignedBigInteger('team_id')->nullable(); + $table->unsignedBigInteger('user_id')->nullable(); // add fields $table->unsignedBigInteger('parent_id')->nullable(); @@ -107,6 +111,7 @@ return new class extends Migration $table->id(); $table->unsignedBigInteger('team_id')->nullable(); + $table->unsignedBigInteger('user_id')->nullable(); $table->string('name'); $table->string('number'); @@ -139,6 +144,7 @@ return new class extends Migration $table->id(); $table->unsignedBigInteger('team_id')->nullable(); + $table->unsignedBigInteger('user_id')->nullable(); $table->unsignedBigInteger('account_id')->nullable(); $table->unsignedBigInteger('document_id')->nullable(); @@ -152,6 +158,18 @@ return new class extends Migration $table->timestamps(); }); + + Schema::create('reports', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('team_id')->nullable(); + $table->unsignedBigInteger('user_id')->nullable(); + $table->string('type'); + $table->string('name'); + $table->string('description')->nullable(); + $table->json('settings')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); } /** @@ -168,5 +186,6 @@ return new class extends Migration Schema::dropIfExists('documents'); Schema::dropIfExists('accounts'); Schema::dropIfExists('transactions'); + Schema::dropIfExists('reports'); } }; diff --git a/routes/web.php b/routes/web.php index 44dce3f..ad455aa 100644 --- a/routes/web.php +++ b/routes/web.php @@ -61,4 +61,12 @@ 'update' => config('instrument.route_names.transactions.update', 'transactions.update'), 'destroy' => config('instrument.route_names.transactions.destroy', 'transactions.destroy'), ]); + + Route::resource('reports', Controllers\ReportController::class) + ->only(['store', 'update', 'destroy']) + ->names([ + 'store' => config('instrument.route_names.reports.store', 'reports.store'), + 'update' => config('instrument.route_names.reports.update', 'reports.update'), + 'destroy' => config('instrument.route_names.reports.destroy', 'reports.destroy'), + ]); }); \ No newline at end of file diff --git a/src/Actions/CreateReport.php b/src/Actions/CreateReport.php new file mode 100644 index 0000000..126bac3 --- /dev/null +++ b/src/Actions/CreateReport.php @@ -0,0 +1,47 @@ + 'required|string|max:255|in:'.implode(',', collect(config('instrument.report_types'))->keys()->toArray()), + 'name' => 'required|string|max:255', + 'description' => 'required|string|max:255', + 'settings' => 'nullable|array', + 'settings.group' => 'nullable|string|max:255|in:'.implode(',', config('instrument.report_groups')), + 'settings.period' => 'nullable|string|max:255|in:'.implode(',', config('instrument.report_periods')), + 'settings.basis' => 'nullable|string|max:255|in:'.implode(',', config('instrument.report_accounting_basis')), + ])->validateWithBag('createReport'); + + $fields = collect($data)->only([ + 'type', + 'name', + 'description', + 'settings', + ])->toArray(); + + $report = Instrument::$supportsTeams ? + Instrument::findTeamByIdOrFail($teamId)->reports()->create($fields) : + Instrument::newReportModel()->create($fields); + + event(new ReportCreated(user: $user, report: $report, data: $data)); + + return $report; + } +} diff --git a/src/Actions/DeleteReport.php b/src/Actions/DeleteReport.php new file mode 100644 index 0000000..7d49b55 --- /dev/null +++ b/src/Actions/DeleteReport.php @@ -0,0 +1,24 @@ +delete(); + + event(new ReportDeleted(user: $user, report: $report)); + } +} diff --git a/src/Actions/UpdateReport.php b/src/Actions/UpdateReport.php new file mode 100644 index 0000000..b23fdb0 --- /dev/null +++ b/src/Actions/UpdateReport.php @@ -0,0 +1,40 @@ + 'required|string|max:255', + 'description' => 'required|string|max:255', + 'settings' => 'nullable|array', + 'settings.group' => 'nullable|string|max:255|in:'.implode(',', config('instrument.report_groups')), + 'settings.period' => 'nullable|string|max:255|in:'.implode(',', config('instrument.report_periods')), + 'settings.basis' => 'nullable|string|max:255|in:'.implode(',', config('instrument.report_accounting_basis')), + ])->validateWithBag('updateReport'); + + $report->update(collect($data)->only([ + 'name', + 'description', + 'settings', + ])->toArray()); + + event(new ReportUpdated(user: $user, report: $report, data: $data)); + + return $report->refresh(); + } +} diff --git a/src/Commands/InstallCommand.php b/src/Commands/InstallCommand.php index 1baa2c5..0d2abfd 100644 --- a/src/Commands/InstallCommand.php +++ b/src/Commands/InstallCommand.php @@ -30,6 +30,7 @@ public function handle(): int copy(__DIR__.'/../../stubs/app/Models/Currency.php', app_path('Models/Currency.php')); copy(__DIR__.'/../../stubs/app/Models/PaymentMethod.php', app_path('Models/PaymentMethod.php')); copy(__DIR__.'/../../stubs/app/Models/Transaction.php', app_path('Models/Transaction.php')); + copy(__DIR__.'/../../stubs/app/Models/Report.php', app_path('Models/Report.php')); // Service Providers... copy(__DIR__.'/../../stubs/app/Providers/InstrumentServiceProvider.php', app_path('Providers/InstrumentServiceProvider.php')); diff --git a/src/Contracts/CreatesReports.php b/src/Contracts/CreatesReports.php new file mode 100644 index 0000000..cffe15a --- /dev/null +++ b/src/Contracts/CreatesReports.php @@ -0,0 +1,14 @@ +user(), + request()->all(), + request('team_id') + ); + + return request()->wantsJson() ? response()->json(['report' => $report]) : redirect()->to( + request()->get('redirect', Instrument::redirects('reports', 'store', '/')) + ); + } + + /** + * Update the specified resource in storage. + * + * @param mixed $report + * @param \Instrument\Contracts\UpdatesReports $updatesReports + * @return \Illuminate\Http\RedirectResponse + */ + public function update($report, UpdatesReports $updatesReports) + { + $report = Instrument::newReportModel()->findOrFail($report); + + $report = $updatesReports( + request()->user(), + $report, + request()->all() + ); + + return request()->wantsJson() ? response()->json(['report' => $report]) : redirect()->to( + request()->get('redirect', Instrument::redirects('reports', 'update', '/')) + ); + } + + /** + * Remove the specified resource from storage. + * + * @param mixed $report + * @param \Instrument\Contracts\DeletesReports $deletesReports + * @return \Illuminate\Http\RedirectResponse + */ + public function destroy($report, DeletesReports $deletesReports) + { + $report = Instrument::newReportModel()->findOrFail($report); + + $deletesReports( + request()->user(), + $report + ); + + return request()->wantsJson() ? response()->json([]) : redirect()->to( + request()->get('redirect', Instrument::redirects('reports', 'destroy', '/')) + ); + } +} diff --git a/src/Instrument.php b/src/Instrument.php index d3a8dd2..e1a4c69 100755 --- a/src/Instrument.php +++ b/src/Instrument.php @@ -3,6 +3,7 @@ namespace Instrument; use Illuminate\Database\Eloquent\Model; +use NumberFormatter; final class Instrument { @@ -72,6 +73,13 @@ final class Instrument */ public static $transactionModel = 'App\\Models\\Transaction'; + /** + * The report model that should be used by Instrument. + * + * @var string + */ + public static $reportModel = 'App\\Models\\Report'; + /** * Indicates if Instrument should support teams. * @@ -592,6 +600,79 @@ public static function deletePaymentMethodsUsing(string $class) app()->singleton(Contracts\DeletesPaymentMethods::class, $class); } + /** + * Get the name of the report model used by the application. + * + * @return string + */ + public static function reportModel() + { + return static::$reportModel; + } + + /** + * Get a new instance of the report model. + * + * @return mixed + */ + public static function newReportModel() + { + $model = static::reportModel(); + + return new $model(); + } + + /** + * Specify the report model that should be used by Instrument. + * + * @param string $model + * @return static + */ + public static function useReportModel(string $model) + { + static::$reportModel = $model; + + return new static(); + } + + /** + * Register a class / callback that should be used to create reports. + * + * @param string $class + * @return void + */ + public static function createReportsUsing(string $class) + { + app()->singleton(Contracts\CreatesReports::class, $class); + } + + /** + * Register a class / callback that should be used to update reports. + * + * @param string $class + * @return void + */ + public static function updateReportsUsing(string $class) + { + app()->singleton(Contracts\UpdatesReports::class, $class); + } + + /** + * Register a class / callback that should be used to delete reports. + * + * @param string $class + * @return void + */ + public static function deleteReportsUsing(string $class) + { + app()->singleton(Contracts\DeletesReports::class, $class); + } + + public static function money(float $amount, string $currency = 'NGN') + { + return (new NumberFormatter("en_NG", NumberFormatter::CURRENCY))->formatCurrency($amount, $currency); + } + /** * Configure Instrument to not register its routes. * diff --git a/src/InstrumentServiceProvider.php b/src/InstrumentServiceProvider.php index 6811e77..bbbbc7d 100644 --- a/src/InstrumentServiceProvider.php +++ b/src/InstrumentServiceProvider.php @@ -72,5 +72,11 @@ public function packageRegistered() Instrument::updatePaymentMethodsUsing(Actions\UpdatePaymentMethod::class); Instrument::deletePaymentMethodsUsing(Actions\DeletePaymentMethod::class); + + Instrument::createReportsUsing(Actions\CreateReport::class); + + Instrument::updateReportsUsing(Actions\UpdateReport::class); + + Instrument::deleteReportsUsing(Actions\DeleteReport::class); } } diff --git a/src/InteractsWithReport.php b/src/InteractsWithReport.php new file mode 100644 index 0000000..65ebce8 --- /dev/null +++ b/src/InteractsWithReport.php @@ -0,0 +1,59 @@ +year); + + $financialStart = $this->getFinancialStart($year); + + // Check if FS has been customized + if ($now->startOfYear()->format('Y-m-d') === $financialStart->format('Y-m-d')) { + $start = Carbon::parse($year . '-01-01')->startOfDay()->format('Y-m-d H:i:s'); + $end = Carbon::parse($year . '-12-31')->endOfDay()->format('Y-m-d H:i:s'); + } else { + $start = $financialStart + ->startOfDay() + ->format('Y-m-d H:i:s'); + $end = $financialStart + ->addYear(1) + ->subDays(1) + ->endOfDay() + ->format('Y-m-d H:i:s'); + } + + return $query->whereBetween($field, [$start, $end]); + } + + /** + * Get the financial start date. + * + * @param mixed $year + * @return \Carbon\Carbon|false + */ + public function getFinancialStart($year = null) + { + $now = now(); + $start = now()->startOfYear(); + + $setting = explode('-', auth()->user()->currentTeam->reportSettings()->financial_start); + + $day = !empty($setting[0]) ? $setting[0] : $start->day; + $month = !empty($setting[1]) ? $setting[1] : $start->month; + $year = $year ?? request('year', $now->year); + + $financialStart = Carbon::create($year, $month, $day); + + if ((auth()->user()->currentTeam->reportSettings()->financial_year == 'ends') && ($financialStart->dayOfYear != 1)) { + $financialStart->subYear(); + } + + return $financialStart; + } +} \ No newline at end of file diff --git a/src/Report.php b/src/Report.php new file mode 100644 index 0000000..0524d88 --- /dev/null +++ b/src/Report.php @@ -0,0 +1,18 @@ +belongsTo(Instrument::teamModel(), 'team_id'); + } +} diff --git a/src/Reports/Report.php b/src/Reports/Report.php new file mode 100644 index 0000000..2291559 --- /dev/null +++ b/src/Reports/Report.php @@ -0,0 +1,683 @@ +setGroups(); + + $this->model = Instrument::reportModel(); + + if ($loadData) { + $this->load(); + } + } + + /** + * Sets the report's data. + * + * @return void + */ + abstract public function setData(); + + /** + * Hydrates the report. + * + * @return void + */ + public function load() + { + $this->setYear(); + $this->setTables(); + $this->setDates(); + $this->setFilters(); + $this->setRows(); + $this->loadData(); + + $this->loaded = true; + } + + /** + * Loads the report's data. + * + * @return void + */ + public function loadData() + { + $this->setData(); + } + + /** + * Gets the default name of the report. + * + * @return string + */ + public function getName(): string + { + return $this->name; + } + + /** + * Get's grand total. + * + * @return string + */ + public function getGrandTotal(): string + { + if (!$this->loaded) { + $this->load(); + } + + if (!empty($this->footerTotals)) { + $sum = 0; + + foreach ($this->footerTotals as $total) { + $sum += is_array($total) ? array_sum($total) : $total; + } + + $total = $this->hasMoney ? Instrument::money($sum) : $sum; + } else { + $total = __("N/A"); + } + + return $total; + } + + /** + * Sets the report's year. + * + * @return void + */ + public function setYear() + { + $this->year = request('year', now()->year); + } + + /** + * Sets the report's tables. + * + * @return void + */ + public function setTables() + { + $this->tables = [ + 'default' => 'default', + ]; + } + + /** + * Get's a reports setting by key. + * + * @param string $name + * @param mixed $default + * @return mixed + */ + public function getSetting(string $name, $default = '') + { + return $this->model->settings[$name] ?? $default; + } + + /** + * Get the report's financial year. + * + * @param mixed $year + * @return \Carbon\CarbonPeriod + */ + public function getFinancialYear($year = null) + { + $start = $this->getFinancialStart($year); + + return CarbonPeriod::create($start, $start->copy()->addYear()->subDay()->endOfDay()); + } + + /** + * Get the report's financial quarter. + * + * @param mixed $year + * @return \Carbon\CarbonPeriod + */ + public function getFinancialQuarters($year = null) + { + $quarters = []; + $start = $this->getFinancialStart($year); + + for ($i = 0; $i < 4; $i++) { + $quarters[] = CarbonPeriod::create($start->copy()->addQuarters($i), $start->copy()->addQuarters($i + 1)->subDay()->endOfDay()); + } + + return $quarters; + } + + /** + * Sets the report's dates. + * + * @return void + */ + public function setDates() + { + if (!$period = $this->getSetting('period')) { + return; + } + + $function = 'sub' . ucfirst(str_replace('ly', '', $period)); + + $start = $this->getFinancialStart($this->year)->copy()->$function(); + + for ($j = 1; $j <= 12; $j++) { + switch ($period) { + case 'yearly': + $start->addYear(); + + $j += 11; + + break; + case 'quarterly': + $start->addQuarter(); + + $j += 2; + + break; + default: + $start->addMonth(); + + break; + } + + $date = $this->getFormattedDate($start); + + $this->dates[] = $date; + + foreach ($this->tables as $table) { + $this->footerTotals[$table][$date] = 0; + } + } + } + + /** + * Sets filters. + * + * @return void + */ + abstract public function setFilters(); + + /** + * Sets groups. + * + * @return void + */ + abstract public function setGroups(); + + /** + * Sets rows. + * + * @return void + */ + abstract public function setRows(); + + /** + * Sets totals. + * + * @param mixed $items + * @param string $dateField + * @param bool $checkType + * @param string $table + * @param bool $withTax + * @return void + */ + public function setTotals($items, string $dateField, bool $checkType = false, string $table = 'default', bool $withTax = true) + { + $groupField = $this->getSetting('group') . '_id'; + + foreach ($items as $item) { + // Make groups extensible + $item = $this->applyGroups($item); + + $date = $this->getFormattedDate(Carbon::parse($item->$dateField)); + + if (!isset($item->$groupField)) { + continue; + } + + $group = $item->$groupField; + + if ( + !isset($this->rowValues[$table][$group]) + || !isset($this->rowValues[$table][$group][$date]) + || !isset($this->footerTotals[$table][$date]) + ) { + continue; + } + + $amount = $item->getAmountConvertedToDefault(false, $withTax); + + $type = ($item->type === 'invoice' || $item->type === 'income') ? 'income' : 'expense'; + + if (($checkType == false) || ($type == 'income')) { + $this->rowValues[$table][$group][$date] += $amount; + + $this->footerTotals[$table][$date] += $amount; + } else { + $this->rowValues[$table][$group][$date] -= $amount; + + $this->footerTotals[$table][$date] -= $amount; + } + } + } + + /** + * Sets the report's arithmetic totals. + * + * @param array $items + * @param string $dateField + * @param string $operator + * @param string $table + * @param string $amountField + * @return void + */ + public function setArithmeticTotals(array $items, string $dateField, string $operator = 'add', string $table = 'default', string $amountField = 'amount') + { + $groupField = $this->getSetting('group') . '_id'; + + $function = $operator . 'ArithmeticAmount'; + + foreach ($items as $item) { + // Make groups extensible + $item = $this->applyGroups($item); + + $date = $this->getFormattedDate(Carbon::parse($item->$dateField)); + + if (!isset($item->$groupField)) { + continue; + } + + $group = $item->$groupField; + + if ( + !isset($this->rowValues[$table][$group]) + || !isset($this->rowValues[$table][$group][$date]) + || !isset($this->footerTotals[$table][$date]) + ) { + continue; + } + + $amount = isset($item->$amountField) ? $item->$amountField : 1; + + $this->$function($this->rowValues[$table][$group][$date], $amount); + $this->$function($this->footerTotals[$table][$date], $amount); + } + } + + /** + * Adds an arithmetic amount to a value. + * + * @param mixed $current + * @param mixed $amount + * @return void + */ + public function addArithmeticAmount(&$current, $amount) + { + $current = $current + $amount; + } + + /** + * Subtracts an arithmetic amount to a value. + * + * @param mixed $current + * @param mixed $amount + * @return void + */ + public function subArithmeticAmount(&$current, $amount) + { + $current = $current - $amount; + } + + /** + * Multplies an arithmetic amount to a value. + * + * @param mixed $current + * @param mixed $amount + * @return void + */ + public function mulArithmeticAmount(&$current, $amount) + { + $current = $current * $amount; + } + + /** + * Divides an arithmetic amount to a value. + * + * @param mixed $current + * @param mixed $amount + * @return void + */ + public function divArithmeticAmount(&$current, $amount) + { + $current = $current / $amount; + } + + /** + * Mod an arithmetic amount to a value. + * + * @param mixed $current + * @param mixed $amount + * @return void + */ + public function modArithmeticAmount(&$current, $amount) + { + $current = $current % $amount; + } + + /** + * Expo an arithmetic amount to a value. + * + * @param mixed $current + * @param mixed $amount + * @return void + */ + public function expArithmeticAmount(&$current, $amount) + { + $current = $current ** $amount; + } + + /** + * Apply filters. + * + * @return mixed + */ + abstract public function applyFilters($model, $args = []); + + /** + * Apply filters. + * + * @return mixed + */ + abstract public function applyGroups($model, $args = []); + + /** + * Gets the report's formatted date. + * + * @param \Carbon\Carbon $date + * @return null|string + */ + public function getFormattedDate($date) + { + $formattedDate = null; + + switch ($this->getSetting('period')) { + case 'yearly': + $financialYear = $this->getFinancialYear($this->year); + + if ($date->greaterThanOrEqualTo($financialYear->getStartDate()) && $date->lessThanOrEqualTo($financialYear->getEndDate())) { + if (auth()->user()->currentTeam->reportSettings()->financial_year == 'begins') { + $formattedDate = $financialYear->getStartDate()->copy()->format($this->getYearlyDateFormat()); + } else { + $formattedDate = $financialYear->getEndDate()->copy()->format($this->getYearlyDateFormat()); + } + } + + break; + case 'quarterly': + $quarters = $this->getFinancialQuarters($this->year); + + foreach ($quarters as $quarter) { + if ($date->lessThan($quarter->getStartDate()) || $date->greaterThan($quarter->getEndDate())) { + continue; + } + + $start = $quarter->getStartDate()->format($this->getQuarterlyDateFormat($this->year)); + $end = $quarter->getEndDate()->format($this->getQuarterlyDateFormat($this->year)); + + $formattedDate = $start . '-' . $end; + } + + break; + default: + $formattedDate = $date->copy()->format($this->getMonthlyDateFormat($this->year)); + + break; + } + + return $formattedDate; + } + + /** + * Gets the report's monthly date format. + * + * @param mixed $year + * @return string + */ + public function getMonthlyDateFormat($year = null) + { + $format = 'M Y'; + + return $format; + } + + /** + * Gets the report's quaterly date format. + * + * @param mixed $year + * @return string + */ + public function getQuarterlyDateFormat($year = null) + { + $format = 'M Y'; + + return $format; + } + + /** + * Gets the report's yearly date format. + * + * @param mixed $year + * @return string + */ + public function getYearlyDateFormat() + { + $format = 'Y'; + + return $format; + } + + /** + * Gets years. + * + * @return array + */ + public function getYears() + { + $now = now(); + + $years = []; + + $y = $now->addYears(2); + for ($i = 0; $i < 10; $i++) { + $years[$y->year] = $y->year; + $y->subYear(); + } + + return $years; + } + + /** + * Applies date filter. + * + * @param mixed $model + * @param array $args + * @return void + */ + public function applyDateFilter($model, $args = []) + { + $model->monthsOfYear($args['date_field']); + } + + /** + * Applies account group. + * + * @param mixed $model + * @return void + */ + public function applyAccountGroup($model) + { + if ($model->getTable() != 'documents') { + return; + } + + $filter = (array) request('account_id', []); + + $model->account_id = 0; + + foreach ($model->transactions as $transaction) { + if (!empty($filter) && !in_array($transaction->account_id, $filter)) { + continue; + } + + $model->account_id = $transaction->account_id; + + break; + } + } + + /** + * Applies customer group. + * + * @param mixed $model + * @param array $args + * @return void + */ + public function applyCustomerGroup($model, $args = []) + { + foreach (['customer'] as $type) { + $idField = $type . '_id'; + + $model->$idField = $model->contact_id; + } + } + + /** + * Applies vendor group. + * + * @param mixed $model + * @return void + */ + public function applyVendorGroup($model, $args = []) + { + foreach (['vendor'] as $type) { + $idField = $type . '_id'; + + $model->$idField = $model->contact_id; + } + } + + /** + * Set row names and values. + * + * @param array $rows + * @return void + */ + public function setRowNamesAndValues(array $rows) + { + foreach ($this->dates as $date) { + foreach ($this->tables as $table) { + foreach ($rows as $id => $name) { + $this->rowNames[$table][$id] = $name; + $this->rowValues[$table][$id][$date] = 0; + } + } + } + } +} \ No newline at end of file diff --git a/src/TeamHasInstruments.php b/src/TeamHasInstruments.php index 1521774..0419d49 100644 --- a/src/TeamHasInstruments.php +++ b/src/TeamHasInstruments.php @@ -63,4 +63,24 @@ public function transactions() { return $this->hasMany(Instrument::transactionModel(), 'team_id'); } + + /** + * Get the payment methods for the team. + * + * @return \Illuminate\Database\Eloquent\Relations\HasMany + */ + public function paymentMethods() + { + return $this->hasMany(Instrument::paymentMethodModel(), 'team_id'); + } + + /** + * Get the reports for the team. + * + * @return \Illuminate\Database\Eloquent\Relations\HasMany + */ + public function reports() + { + return $this->hasMany(Instrument::reportModel(), 'team_id'); + } } diff --git a/stubs/app/Models/Report.php b/stubs/app/Models/Report.php new file mode 100644 index 0000000..b903909 --- /dev/null +++ b/stubs/app/Models/Report.php @@ -0,0 +1,55 @@ + + */ + protected $casts = [ + 'settings' => 'array', + ]; + + /** + * The event map for the model. + * + * @var array + */ + protected $dispatchesEvents = [ + 'created' => ReportCreated::class, + 'updated' => ReportUpdated::class, + 'deleted' => ReportDeleted::class, + ]; +} \ No newline at end of file diff --git a/stubs/app/Providers/InstrumentServiceProvider.php b/stubs/app/Providers/InstrumentServiceProvider.php index 428c0a5..d86b996 100644 --- a/stubs/app/Providers/InstrumentServiceProvider.php +++ b/stubs/app/Providers/InstrumentServiceProvider.php @@ -8,6 +8,7 @@ use App\Models\Tax; use App\Models\Transaction; use App\Models\PaymentMethod; +use App\Models\Report; use Illuminate\Support\ServiceProvider; use Instrument\Instrument; @@ -41,5 +42,7 @@ public function boot() Instrument::useTransactionModel(Transaction::class); Instrument::usePaymentMethodModel(PaymentMethod::class); + + Instrument::useReportModel(Report::class); } } \ No newline at end of file diff --git a/tests/Feature/Actions/CreateReportTest.php b/tests/Feature/Actions/CreateReportTest.php new file mode 100644 index 0000000..e66772d --- /dev/null +++ b/tests/Feature/Actions/CreateReportTest.php @@ -0,0 +1,35 @@ +name)->toBe($reportFields['name']); + expect($report->type)->toBe($reportFields['type']); +}); diff --git a/tests/Feature/Actions/DeleteReportTest.php b/tests/Feature/Actions/DeleteReportTest.php new file mode 100644 index 0000000..8cd2555 --- /dev/null +++ b/tests/Feature/Actions/DeleteReportTest.php @@ -0,0 +1,30 @@ +create(); + + $deletesReports($user, $report); + + Event::assertDispatched(DeletingReport::class); + Event::assertDispatched(ReportDeleted::class); + + expect(Report::count())->toEqual(0); +}); diff --git a/tests/Feature/Actions/UpdateReportTest.php b/tests/Feature/Actions/UpdateReportTest.php new file mode 100644 index 0000000..41e1d41 --- /dev/null +++ b/tests/Feature/Actions/UpdateReportTest.php @@ -0,0 +1,36 @@ +create(); + + $fields = reportFields(); + + $report = $updatesReports( + $user, + $report, + $fields + ); + + Event::assertDispatched(UpdatingReport::class); + Event::assertDispatched(ReportUpdated::class); + + expect($report->name)->toBe($fields['name']); + expect($report->type)->toBe($fields['type']); +}); diff --git a/tests/Feature/ReportTest.php b/tests/Feature/ReportTest.php new file mode 100644 index 0000000..4ba674e --- /dev/null +++ b/tests/Feature/ReportTest.php @@ -0,0 +1,73 @@ +post(route('reports.store'), reportFields()); + + $response->assertRedirect('/'); + + Event::assertDispatched(CreatingReport::class); + Event::assertDispatched(ReportCreated::class); + + expect(Report::count())->toBe(1); +}); + +test('report can be updated', function () { + Event::fake(); + + $user = TestUser::first(); + + $report = Report::factory()->create(); + + $fields = reportFields(); + + $response = actingAs($user)->put(route('reports.update', $report), $fields); + + $response->assertRedirect('/'); + + Event::assertDispatched(UpdatingReport::class); + Event::assertDispatched(ReportUpdated::class); + + $this->assertDatabaseHas('reports', [ + 'name' => $fields['name'], + 'type' => $fields['type'], + ]); +}); + +test('report can be deleted', function () { + Event::fake(); + + $user = TestUser::first(); + + $report = Report::factory()->create(); + + $response = actingAs($user)->delete(route('reports.destroy', $report), [ + 'redirect' => '/redirect/path', + ]); + + $response->assertRedirect('/redirect/path'); + + Event::assertDispatched(DeletingReport::class); + Event::assertDispatched(ReportDeleted::class); + + expect(Report::count())->toEqual(0); +}); diff --git a/tests/Mocks/Report.php b/tests/Mocks/Report.php new file mode 100644 index 0000000..838e924 --- /dev/null +++ b/tests/Mocks/Report.php @@ -0,0 +1,63 @@ + + */ + protected $casts = [ + 'settings' => 'array', + ]; + + /** + * Create a new factory instance for the model. + * + * @return \Illuminate\Database\Eloquent\Factories\Factory + */ + protected static function newFactory() + { + return ReportFactory::new(); + } + + /** + * The event map for the model. + * + * @var array + */ + protected $dispatchesEvents = [ + 'created' => ReportCreated::class, + 'updated' => ReportUpdated::class, + 'deleted' => ReportDeleted::class, + ]; +} diff --git a/tests/Mocks/ReportFactory.php b/tests/Mocks/ReportFactory.php new file mode 100644 index 0000000..af1792f --- /dev/null +++ b/tests/Mocks/ReportFactory.php @@ -0,0 +1,24 @@ + 'expense', + 'name' => $this->faker->word, + 'description' => $this->faker->sentence, + 'settings' => [ + 'group' => 'category', + 'period' => 'monthly', + 'basis' => 'accrual', + ], + ]; + } +} diff --git a/tests/Pest.php b/tests/Pest.php index 04f0b0c..3483513 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -6,6 +6,7 @@ use Instrument\Tests\Mocks\Currency; use Instrument\Tests\Mocks\Document; use Instrument\Tests\Mocks\PaymentMethod; +use Instrument\Tests\Mocks\Report; use Instrument\Tests\Mocks\Tax; use Instrument\Tests\Mocks\Transaction; use Instrument\Tests\TestCase; @@ -91,3 +92,13 @@ function paymentMethodFields() { return PaymentMethod::factory()->raw(); } + +/** + * Returns report fields. + * + * @return array + */ +function reportFields() +{ + return Report::factory()->raw(); +}