Du kan inte välja fler än 25 ämnen Ämnen måste starta med en bokstav eller siffra, kan innehålla bindestreck ('-') och vara max 35 tecken långa.

ReportService.php 43KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091
  1. <?php
  2. namespace App\Services;
  3. use App\Collections\Accounting\DocumentCollection;
  4. use App\Contracts\BalanceFormattable;
  5. use App\DTO\AccountBalanceDTO;
  6. use App\DTO\AccountCategoryDTO;
  7. use App\DTO\AccountDTO;
  8. use App\DTO\AccountTransactionDTO;
  9. use App\DTO\AccountTypeDTO;
  10. use App\DTO\AgingBucketDTO;
  11. use App\DTO\CashFlowOverviewDTO;
  12. use App\DTO\EntityBalanceDTO;
  13. use App\DTO\EntityReportDTO;
  14. use App\DTO\PaymentMetricsDTO;
  15. use App\DTO\ReportDTO;
  16. use App\Enums\Accounting\AccountCategory;
  17. use App\Enums\Accounting\AccountType;
  18. use App\Enums\Accounting\BillStatus;
  19. use App\Enums\Accounting\DocumentEntityType;
  20. use App\Enums\Accounting\InvoiceStatus;
  21. use App\Enums\Accounting\TransactionType;
  22. use App\Models\Accounting\Account;
  23. use App\Models\Accounting\Bill;
  24. use App\Models\Accounting\Invoice;
  25. use App\Models\Accounting\Transaction;
  26. use App\Support\Column;
  27. use App\Utilities\Currency\CurrencyAccessor;
  28. use App\Utilities\Currency\CurrencyConverter;
  29. use App\ValueObjects\Money;
  30. use Illuminate\Database\Eloquent\Builder;
  31. use Illuminate\Support\Carbon;
  32. use Illuminate\Support\Number;
  33. class ReportService
  34. {
  35. public function __construct(
  36. protected AccountService $accountService,
  37. ) {}
  38. /**
  39. * @param class-string<BalanceFormattable>|null $dtoClass
  40. */
  41. public function formatBalances(array $balances, ?string $dtoClass = null, bool $formatZeros = true): BalanceFormattable | array
  42. {
  43. $dtoClass ??= AccountBalanceDTO::class;
  44. $formattedBalances = array_map(static function ($balance) use ($formatZeros) {
  45. if (! $formatZeros && $balance === 0) {
  46. return '';
  47. }
  48. return CurrencyConverter::formatCentsToMoney($balance);
  49. }, $balances);
  50. if (! $dtoClass) {
  51. return $formattedBalances;
  52. }
  53. return $dtoClass::fromArray($formattedBalances);
  54. }
  55. public function buildAccountBalanceReport(string $startDate, string $endDate, array $columns = []): ReportDTO
  56. {
  57. $orderedCategories = AccountCategory::getOrderedCategories();
  58. $accounts = $this->accountService->getAccountBalances($startDate, $endDate)
  59. ->orderByRaw('LENGTH(code), code')
  60. ->get();
  61. $columnNameKeys = array_map(fn (Column $column) => $column->getName(), $columns);
  62. $accountCategories = [];
  63. $reportTotalBalances = [];
  64. foreach ($orderedCategories as $category) {
  65. $accountsInCategory = $accounts->where('category', $category);
  66. $relevantFields = array_intersect($category->getRelevantBalanceFields(), $columnNameKeys);
  67. $categorySummaryBalances = array_fill_keys($relevantFields, 0);
  68. $categoryAccounts = [];
  69. /** @var Account $account */
  70. foreach ($accountsInCategory as $account) {
  71. $accountBalances = $this->calculateAccountBalances($account);
  72. foreach ($relevantFields as $field) {
  73. $categorySummaryBalances[$field] += $accountBalances[$field];
  74. }
  75. $formattedAccountBalances = $this->formatBalances($accountBalances);
  76. $categoryAccounts[] = new AccountDTO(
  77. $account->name,
  78. $account->code,
  79. $account->id,
  80. $formattedAccountBalances,
  81. Carbon::parse($startDate)->toDateString(),
  82. Carbon::parse($endDate)->toDateString(),
  83. );
  84. }
  85. foreach ($relevantFields as $field) {
  86. $reportTotalBalances[$field] = ($reportTotalBalances[$field] ?? 0) + $categorySummaryBalances[$field];
  87. }
  88. $formattedCategorySummaryBalances = $this->formatBalances($categorySummaryBalances);
  89. $accountCategories[$category->getPluralLabel()] = new AccountCategoryDTO(
  90. accounts: $categoryAccounts,
  91. summary: $formattedCategorySummaryBalances,
  92. );
  93. }
  94. $formattedReportTotalBalances = $this->formatBalances($reportTotalBalances);
  95. return new ReportDTO(
  96. categories: $accountCategories,
  97. overallTotal: $formattedReportTotalBalances,
  98. fields: $columns,
  99. );
  100. }
  101. public function calculateAccountBalances(Account $account): array
  102. {
  103. $category = $account->category;
  104. $balances = [
  105. 'debit_balance' => $account->total_debit ?? 0,
  106. 'credit_balance' => $account->total_credit ?? 0,
  107. ];
  108. if ($category->isNormalDebitBalance()) {
  109. $balances['net_movement'] = $balances['debit_balance'] - $balances['credit_balance'];
  110. } else {
  111. $balances['net_movement'] = $balances['credit_balance'] - $balances['debit_balance'];
  112. }
  113. if ($category->isReal()) {
  114. $balances['starting_balance'] = $account->starting_balance ?? 0;
  115. $balances['ending_balance'] = $balances['starting_balance'] + $balances['net_movement'];
  116. }
  117. return $balances;
  118. }
  119. public function calculateRetainedEarnings(?string $startDate, string $endDate): Money
  120. {
  121. $startDate ??= Carbon::parse($this->accountService->getEarliestTransactionDate())->toDateTimeString();
  122. $revenueAccounts = $this->accountService->getAccountBalances($startDate, $endDate)->where('category', AccountCategory::Revenue)->get();
  123. $expenseAccounts = $this->accountService->getAccountBalances($startDate, $endDate)->where('category', AccountCategory::Expense)->get();
  124. $revenueTotal = 0;
  125. $expenseTotal = 0;
  126. foreach ($revenueAccounts as $account) {
  127. $revenueBalances = $this->calculateAccountBalances($account);
  128. $revenueTotal += $revenueBalances['net_movement'];
  129. }
  130. foreach ($expenseAccounts as $account) {
  131. $expenseBalances = $this->calculateAccountBalances($account);
  132. $expenseTotal += $expenseBalances['net_movement'];
  133. }
  134. $retainedEarnings = $revenueTotal - $expenseTotal;
  135. return new Money($retainedEarnings, CurrencyAccessor::getDefaultCurrency());
  136. }
  137. public function buildAccountTransactionsReport(string $startDate, string $endDate, ?array $columns = null, ?string $accountId = 'all', ?string $entityId = 'all'): ReportDTO
  138. {
  139. $columns ??= [];
  140. $defaultCurrency = CurrencyAccessor::getDefaultCurrency();
  141. $accountIds = $accountId !== 'all' ? [$accountId] : [];
  142. $entityId = $entityId !== 'all' ? $entityId : null;
  143. $query = $this->accountService->getAccountBalances($startDate, $endDate, $accountIds)
  144. ->orderByRaw('LENGTH(code), code');
  145. $accounts = $query->with(['journalEntries' => $this->accountService->getTransactionDetailsSubquery($startDate, $endDate, $entityId)])->get();
  146. $reportCategories = [];
  147. foreach ($accounts as $account) {
  148. /** @var Account $account */
  149. if ($account->journalEntries->isEmpty()) {
  150. continue;
  151. }
  152. $accountTransactions = [];
  153. $currentBalance = $account->starting_balance;
  154. $periodDebitTotal = 0;
  155. $periodCreditTotal = 0;
  156. $accountTransactions[] = new AccountTransactionDTO(
  157. id: null,
  158. date: 'Starting Balance',
  159. description: '',
  160. debit: '',
  161. credit: '',
  162. balance: money($currentBalance, $defaultCurrency)->format(),
  163. type: null,
  164. tableAction: null
  165. );
  166. foreach ($account->journalEntries as $journalEntry) {
  167. $transaction = $journalEntry->transaction;
  168. $signedAmount = $journalEntry->signed_amount;
  169. $amount = $journalEntry->getRawOriginal('amount');
  170. if ($journalEntry->type->isDebit()) {
  171. $periodDebitTotal += $amount;
  172. } else {
  173. $periodCreditTotal += $amount;
  174. }
  175. if ($account->category->isNormalDebitBalance()) {
  176. $currentBalance += $signedAmount;
  177. } else {
  178. $currentBalance -= $signedAmount;
  179. }
  180. $formattedAmount = money(abs($signedAmount), $defaultCurrency)->format();
  181. $accountTransactions[] = new AccountTransactionDTO(
  182. id: $transaction->id,
  183. date: $transaction->posted_at->toDefaultDateFormat(),
  184. description: $journalEntry->description ?: $transaction->description ?? 'Add a description',
  185. debit: $journalEntry->type->isDebit() ? $formattedAmount : '',
  186. credit: $journalEntry->type->isCredit() ? $formattedAmount : '',
  187. balance: money($currentBalance, $defaultCurrency)->format(),
  188. type: $transaction->type,
  189. tableAction: $this->determineTableAction($transaction),
  190. );
  191. }
  192. $balanceChange = $currentBalance - $account->starting_balance;
  193. $accountTransactions[] = new AccountTransactionDTO(
  194. id: null,
  195. date: 'Totals and Ending Balance',
  196. description: '',
  197. debit: money($periodDebitTotal, $defaultCurrency)->format(),
  198. credit: money($periodCreditTotal, $defaultCurrency)->format(),
  199. balance: money($currentBalance, $defaultCurrency)->format(),
  200. type: null,
  201. tableAction: null
  202. );
  203. $accountTransactions[] = new AccountTransactionDTO(
  204. id: null,
  205. date: 'Balance Change',
  206. description: '',
  207. debit: '',
  208. credit: '',
  209. balance: money($balanceChange, $defaultCurrency)->format(),
  210. type: null,
  211. tableAction: null
  212. );
  213. $reportCategories[] = [
  214. 'category' => $account->name,
  215. 'under' => $account->category->getLabel() . ' > ' . $account->subtype->name,
  216. 'transactions' => $accountTransactions,
  217. ];
  218. }
  219. return new ReportDTO(categories: $reportCategories, fields: $columns);
  220. }
  221. private function determineTableAction(Transaction $transaction): array
  222. {
  223. if ($transaction->transactionable_type === null || $transaction->is_payment) {
  224. return [
  225. 'type' => 'transaction',
  226. 'action' => match ($transaction->type) {
  227. TransactionType::Journal => 'editJournalTransaction',
  228. TransactionType::Transfer => 'editTransfer',
  229. default => 'editTransaction',
  230. },
  231. 'id' => $transaction->id,
  232. ];
  233. }
  234. return [
  235. 'type' => 'transactionable',
  236. 'model' => $transaction->transactionable_type,
  237. 'id' => $transaction->transactionable_id,
  238. ];
  239. }
  240. public function buildTrialBalanceReport(string $trialBalanceType, string $asOfDate, array $columns = []): ReportDTO
  241. {
  242. $asOfDateCarbon = Carbon::parse($asOfDate);
  243. $startDateCarbon = Carbon::parse($this->accountService->getEarliestTransactionDate());
  244. $orderedCategories = AccountCategory::getOrderedCategories();
  245. $isPostClosingTrialBalance = $trialBalanceType === 'postClosing';
  246. $accounts = $this->accountService->getAccountBalances($startDateCarbon->toDateTimeString(), $asOfDateCarbon->toDateTimeString())
  247. ->when($isPostClosingTrialBalance, fn (Builder $query) => $query->whereNotIn('category', [AccountCategory::Revenue, AccountCategory::Expense]))
  248. ->orderByRaw('LENGTH(code), code')
  249. ->get();
  250. $balanceFields = ['debit_balance', 'credit_balance'];
  251. $accountCategories = [];
  252. $reportTotalBalances = array_fill_keys($balanceFields, 0);
  253. foreach ($orderedCategories as $category) {
  254. $accountsInCategory = $accounts->where('category', $category);
  255. $categorySummaryBalances = array_fill_keys($balanceFields, 0);
  256. $categoryAccounts = [];
  257. /** @var Account $account */
  258. foreach ($accountsInCategory as $account) {
  259. $accountBalances = $this->calculateAccountBalances($account);
  260. $endingBalance = $accountBalances['ending_balance'] ?? $accountBalances['net_movement'];
  261. $trialBalance = $this->calculateTrialBalances($account->category, $endingBalance);
  262. foreach ($trialBalance as $balanceType => $balance) {
  263. $categorySummaryBalances[$balanceType] += $balance;
  264. }
  265. $formattedAccountBalances = $this->formatBalances($trialBalance);
  266. $categoryAccounts[] = new AccountDTO(
  267. $account->name,
  268. $account->code,
  269. $account->id,
  270. $formattedAccountBalances,
  271. startDate: $startDateCarbon->toDateString(),
  272. endDate: $asOfDateCarbon->toDateString(),
  273. );
  274. }
  275. if ($category === AccountCategory::Equity && $isPostClosingTrialBalance) {
  276. $retainedEarningsAmount = $this->calculateRetainedEarnings($startDateCarbon->toDateTimeString(), $asOfDateCarbon->toDateTimeString())->getAmount();
  277. $isCredit = $retainedEarningsAmount >= 0;
  278. $categorySummaryBalances[$isCredit ? 'credit_balance' : 'debit_balance'] += abs($retainedEarningsAmount);
  279. $categoryAccounts[] = new AccountDTO(
  280. 'Retained Earnings',
  281. 'RE',
  282. null,
  283. $this->formatBalances([
  284. 'debit_balance' => $isCredit ? 0 : abs($retainedEarningsAmount),
  285. 'credit_balance' => $isCredit ? $retainedEarningsAmount : 0,
  286. ]),
  287. startDate: $startDateCarbon->toDateString(),
  288. endDate: $asOfDateCarbon->toDateString(),
  289. );
  290. }
  291. foreach ($categorySummaryBalances as $balanceType => $balance) {
  292. $reportTotalBalances[$balanceType] += $balance;
  293. }
  294. $formattedCategorySummaryBalances = $this->formatBalances($categorySummaryBalances);
  295. $accountCategories[$category->getPluralLabel()] = new AccountCategoryDTO(
  296. accounts: $categoryAccounts,
  297. summary: $formattedCategorySummaryBalances,
  298. );
  299. }
  300. $formattedReportTotalBalances = $this->formatBalances($reportTotalBalances);
  301. return new ReportDTO(categories: $accountCategories, overallTotal: $formattedReportTotalBalances, fields: $columns, reportType: $trialBalanceType);
  302. }
  303. public function getRetainedEarningsBalances(string $startDate, string $endDate): BalanceFormattable | array
  304. {
  305. $retainedEarningsAmount = $this->calculateRetainedEarnings($startDate, $endDate)->getAmount();
  306. $isCredit = $retainedEarningsAmount >= 0;
  307. $retainedEarningsDebitAmount = $isCredit ? 0 : abs($retainedEarningsAmount);
  308. $retainedEarningsCreditAmount = $isCredit ? $retainedEarningsAmount : 0;
  309. return $this->formatBalances([
  310. 'debit_balance' => $retainedEarningsDebitAmount,
  311. 'credit_balance' => $retainedEarningsCreditAmount,
  312. ]);
  313. }
  314. public function calculateTrialBalances(AccountCategory $category, int $endingBalance): array
  315. {
  316. if ($category->isNormalDebitBalance()) {
  317. if ($endingBalance >= 0) {
  318. return ['debit_balance' => $endingBalance, 'credit_balance' => 0];
  319. }
  320. return ['debit_balance' => 0, 'credit_balance' => abs($endingBalance)];
  321. }
  322. if ($endingBalance >= 0) {
  323. return ['debit_balance' => 0, 'credit_balance' => $endingBalance];
  324. }
  325. return ['debit_balance' => abs($endingBalance), 'credit_balance' => 0];
  326. }
  327. public function buildIncomeStatementReport(string $startDate, string $endDate, array $columns = []): ReportDTO
  328. {
  329. // Query only relevant accounts and sort them at the query level
  330. $revenueAccounts = $this->accountService->getAccountBalances($startDate, $endDate)
  331. ->where('category', AccountCategory::Revenue)
  332. ->orderByRaw('LENGTH(code), code')
  333. ->get();
  334. $cogsAccounts = $this->accountService->getAccountBalances($startDate, $endDate)
  335. ->whereRelation('subtype', 'name', 'Cost of Goods Sold')
  336. ->orderByRaw('LENGTH(code), code')
  337. ->get();
  338. $expenseAccounts = $this->accountService->getAccountBalances($startDate, $endDate)
  339. ->where('category', AccountCategory::Expense)
  340. ->whereRelation('subtype', 'name', '!=', 'Cost of Goods Sold')
  341. ->orderByRaw('LENGTH(code), code')
  342. ->get();
  343. $accountCategories = [];
  344. $totalRevenue = 0;
  345. $totalCogs = 0;
  346. $totalExpenses = 0;
  347. // Define category groups
  348. $categoryGroups = [
  349. AccountCategory::Revenue->getPluralLabel() => [
  350. 'accounts' => $revenueAccounts,
  351. 'total' => &$totalRevenue,
  352. ],
  353. 'Cost of Goods Sold' => [
  354. 'accounts' => $cogsAccounts,
  355. 'total' => &$totalCogs,
  356. ],
  357. AccountCategory::Expense->getPluralLabel() => [
  358. 'accounts' => $expenseAccounts,
  359. 'total' => &$totalExpenses,
  360. ],
  361. ];
  362. // Process each category group
  363. foreach ($categoryGroups as $label => $group) {
  364. $categoryAccounts = [];
  365. $netMovement = 0;
  366. foreach ($group['accounts'] as $account) {
  367. // Use the category type based on label
  368. $category = match ($label) {
  369. AccountCategory::Revenue->getPluralLabel() => AccountCategory::Revenue,
  370. AccountCategory::Expense->getPluralLabel(), 'Cost of Goods Sold' => AccountCategory::Expense,
  371. default => null
  372. };
  373. if ($category !== null) {
  374. $accountBalances = $this->calculateAccountBalances($account);
  375. $movement = $accountBalances['net_movement'];
  376. $netMovement += $movement;
  377. $group['total'] += $movement;
  378. $categoryAccounts[] = new AccountDTO(
  379. $account->name,
  380. $account->code,
  381. $account->id,
  382. $this->formatBalances(['net_movement' => $movement]),
  383. Carbon::parse($startDate)->toDateString(),
  384. Carbon::parse($endDate)->toDateString(),
  385. );
  386. }
  387. }
  388. $accountCategories[$label] = new AccountCategoryDTO(
  389. accounts: $categoryAccounts,
  390. summary: $this->formatBalances(['net_movement' => $netMovement])
  391. );
  392. }
  393. // Calculate gross and net profit
  394. $grossProfit = $totalRevenue - $totalCogs;
  395. $netProfit = $grossProfit - $totalExpenses;
  396. $formattedReportTotalBalances = $this->formatBalances(['net_movement' => $netProfit]);
  397. return new ReportDTO(
  398. categories: $accountCategories,
  399. overallTotal: $formattedReportTotalBalances,
  400. fields: $columns,
  401. startDate: Carbon::parse($startDate),
  402. endDate: Carbon::parse($endDate),
  403. );
  404. }
  405. public function buildCashFlowStatementReport(string $startDate, string $endDate, array $columns = []): ReportDTO
  406. {
  407. $sections = [
  408. 'Operating Activities' => $this->buildOperatingActivities($startDate, $endDate),
  409. 'Investing Activities' => $this->buildInvestingActivities($startDate, $endDate),
  410. 'Financing Activities' => $this->buildFinancingActivities($startDate, $endDate),
  411. ];
  412. $totalCashFlows = $this->calculateTotalCashFlows($sections, $startDate);
  413. $overview = $this->buildCashFlowOverview($startDate, $endDate);
  414. return new ReportDTO(
  415. categories: $sections,
  416. overallTotal: $totalCashFlows,
  417. fields: $columns,
  418. overview: $overview,
  419. startDate: Carbon::parse($startDate),
  420. endDate: Carbon::parse($endDate),
  421. );
  422. }
  423. private function calculateTotalCashFlows(array $sections, string $startDate): BalanceFormattable | array
  424. {
  425. $totalInflow = 0;
  426. $totalOutflow = 0;
  427. $startingBalance = $this->accountService->getStartingBalanceForAllBankAccounts($startDate)->getAmount();
  428. foreach ($sections as $section) {
  429. $netMovement = $section->summary->netMovement ?? 0;
  430. $numericNetMovement = CurrencyConverter::convertToCents($netMovement);
  431. if ($numericNetMovement > 0) {
  432. $totalInflow += $numericNetMovement;
  433. } else {
  434. $totalOutflow += $numericNetMovement;
  435. }
  436. }
  437. $netCashChange = $totalInflow + $totalOutflow;
  438. $endingBalance = $startingBalance + $netCashChange;
  439. return $this->formatBalances([
  440. 'starting_balance' => $startingBalance,
  441. 'debit_balance' => $totalInflow,
  442. 'credit_balance' => abs($totalOutflow),
  443. 'net_movement' => $netCashChange,
  444. 'ending_balance' => $endingBalance,
  445. ]);
  446. }
  447. private function buildCashFlowOverview(string $startDate, string $endDate): CashFlowOverviewDTO
  448. {
  449. $accounts = $this->accountService->getBankAccountBalances($startDate, $endDate)->get();
  450. $startingBalanceAccounts = [];
  451. $endingBalanceAccounts = [];
  452. $startingBalanceTotal = 0;
  453. $endingBalanceTotal = 0;
  454. foreach ($accounts as $account) {
  455. $accountBalances = $this->calculateAccountBalances($account);
  456. $startingBalanceTotal += $accountBalances['starting_balance'];
  457. $endingBalanceTotal += $accountBalances['ending_balance'];
  458. $startingBalanceAccounts[] = new AccountDTO(
  459. accountName: $account->name,
  460. accountCode: $account->code,
  461. accountId: $account->id,
  462. balance: $this->formatBalances(['starting_balance' => $accountBalances['starting_balance']]),
  463. startDate: $startDate,
  464. endDate: $endDate,
  465. );
  466. $endingBalanceAccounts[] = new AccountDTO(
  467. accountName: $account->name,
  468. accountCode: $account->code,
  469. accountId: $account->id,
  470. balance: $this->formatBalances(['ending_balance' => $accountBalances['ending_balance']]),
  471. startDate: $startDate,
  472. endDate: $endDate,
  473. );
  474. }
  475. $startingBalanceSummary = $this->formatBalances(['starting_balance' => $startingBalanceTotal]);
  476. $endingBalanceSummary = $this->formatBalances(['ending_balance' => $endingBalanceTotal]);
  477. $overviewCategories = [
  478. 'Starting Balance' => new AccountCategoryDTO(
  479. accounts: $startingBalanceAccounts,
  480. summary: $startingBalanceSummary,
  481. ),
  482. 'Ending Balance' => new AccountCategoryDTO(
  483. accounts: $endingBalanceAccounts,
  484. summary: $endingBalanceSummary,
  485. ),
  486. ];
  487. return new CashFlowOverviewDTO($overviewCategories);
  488. }
  489. private function buildOperatingActivities(string $startDate, string $endDate): AccountCategoryDTO
  490. {
  491. $accounts = $this->accountService->getCashFlowAccountBalances($startDate, $endDate)
  492. ->whereIn('accounts.type', [
  493. AccountType::OperatingRevenue,
  494. AccountType::UncategorizedRevenue,
  495. AccountType::ContraRevenue,
  496. AccountType::OperatingExpense,
  497. AccountType::NonOperatingExpense,
  498. AccountType::UncategorizedExpense,
  499. AccountType::ContraExpense,
  500. AccountType::CurrentAsset,
  501. ])
  502. ->whereRelation('subtype', 'name', '!=', 'Cash and Cash Equivalents')
  503. ->orderByRaw('LENGTH(code), code')
  504. ->get();
  505. $adjustments = $this->accountService->getCashFlowAccountBalances($startDate, $endDate)
  506. ->whereIn('accounts.type', [
  507. AccountType::ContraAsset,
  508. AccountType::CurrentLiability,
  509. ])
  510. ->whereRelation('subtype', 'name', '!=', 'Short-Term Borrowings')
  511. ->orderByRaw('LENGTH(code), code')
  512. ->get();
  513. return $this->formatSectionAccounts($accounts, $adjustments, $startDate, $endDate);
  514. }
  515. private function buildInvestingActivities(string $startDate, string $endDate): AccountCategoryDTO
  516. {
  517. $accounts = $this->accountService->getCashFlowAccountBalances($startDate, $endDate)
  518. ->whereIn('accounts.type', [AccountType::NonCurrentAsset])
  519. ->orderByRaw('LENGTH(code), code')
  520. ->get();
  521. $adjustments = $this->accountService->getCashFlowAccountBalances($startDate, $endDate)
  522. ->whereIn('accounts.type', [AccountType::NonOperatingRevenue])
  523. ->orderByRaw('LENGTH(code), code')
  524. ->get();
  525. return $this->formatSectionAccounts($accounts, $adjustments, $startDate, $endDate);
  526. }
  527. private function buildFinancingActivities(string $startDate, string $endDate): AccountCategoryDTO
  528. {
  529. $accounts = $this->accountService->getCashFlowAccountBalances($startDate, $endDate)
  530. ->where(function (Builder $query) {
  531. $query->whereIn('accounts.type', [
  532. AccountType::Equity,
  533. AccountType::NonCurrentLiability,
  534. ])
  535. ->orWhere(function (Builder $subQuery) {
  536. $subQuery->where('accounts.type', AccountType::CurrentLiability)
  537. ->whereRelation('subtype', 'name', 'Short-Term Borrowings');
  538. });
  539. })
  540. ->orderByRaw('LENGTH(code), code')
  541. ->get();
  542. return $this->formatSectionAccounts($accounts, [], $startDate, $endDate);
  543. }
  544. private function formatSectionAccounts($accounts, $adjustments, string $startDate, string $endDate): AccountCategoryDTO
  545. {
  546. $categoryAccountsByType = [];
  547. $sectionTotal = 0;
  548. $subCategoryTotals = [];
  549. // Process accounts and adjustments
  550. /** @var Account[] $entries */
  551. foreach ([$accounts, $adjustments] as $entries) {
  552. foreach ($entries as $entry) {
  553. $accountCategory = $entry->type->getCategory();
  554. $accountBalances = $this->calculateAccountBalances($entry);
  555. $netCashFlow = $accountBalances['net_movement'] ?? 0;
  556. if ($entry->subtype->inverse_cash_flow) {
  557. $netCashFlow *= -1;
  558. }
  559. // Accumulate totals
  560. $sectionTotal += $netCashFlow;
  561. $accountTypeName = $entry->subtype->name;
  562. $subCategoryTotals[$accountTypeName] = ($subCategoryTotals[$accountTypeName] ?? 0) + $netCashFlow;
  563. // Create AccountDTO and group by account type
  564. $accountDTO = new AccountDTO(
  565. $entry->name,
  566. $entry->code,
  567. $entry->id,
  568. $this->formatBalances(['net_movement' => $netCashFlow]),
  569. $startDate,
  570. $endDate
  571. );
  572. $categoryAccountsByType[$accountTypeName][] = $accountDTO;
  573. }
  574. }
  575. // Prepare AccountTypeDTO for each account type with the accumulated totals
  576. $subCategories = [];
  577. foreach ($categoryAccountsByType as $typeName => $accountsInType) {
  578. $typeTotal = $subCategoryTotals[$typeName] ?? 0;
  579. $formattedTypeTotal = $this->formatBalances(['net_movement' => $typeTotal]);
  580. $subCategories[$typeName] = new AccountTypeDTO(
  581. accounts: $accountsInType,
  582. summary: $formattedTypeTotal
  583. );
  584. }
  585. // Format the overall section total as the section summary
  586. $formattedSectionTotal = $this->formatBalances(['net_movement' => $sectionTotal]);
  587. return new AccountCategoryDTO(
  588. accounts: [], // No direct accounts at the section level
  589. types: $subCategories, // Grouped by AccountTypeDTO
  590. summary: $formattedSectionTotal,
  591. );
  592. }
  593. public function buildBalanceSheetReport(string $asOfDate, array $columns = []): ReportDTO
  594. {
  595. $asOfDateCarbon = Carbon::parse($asOfDate);
  596. $startDateCarbon = Carbon::parse($this->accountService->getEarliestTransactionDate());
  597. $orderedCategories = array_filter(AccountCategory::getOrderedCategories(), fn (AccountCategory $category) => $category->isReal());
  598. $accounts = $this->accountService->getAccountBalances($startDateCarbon->toDateTimeString(), $asOfDateCarbon->toDateTimeString())
  599. ->whereIn('category', $orderedCategories)
  600. ->orderByRaw('LENGTH(code), code')
  601. ->get();
  602. $accountCategories = [];
  603. $reportTotalBalances = [
  604. 'assets' => 0,
  605. 'liabilities' => 0,
  606. 'equity' => 0,
  607. ];
  608. foreach ($orderedCategories as $category) {
  609. $categorySummaryBalances = ['ending_balance' => 0];
  610. $categoryAccountsByType = [];
  611. $categoryAccounts = [];
  612. $subCategoryTotals = [];
  613. /** @var Account $account */
  614. foreach ($accounts as $account) {
  615. if ($account->type->getCategory() === $category) {
  616. $accountBalances = $this->calculateAccountBalances($account);
  617. $endingBalance = $accountBalances['ending_balance'] ?? $accountBalances['net_movement'];
  618. $categorySummaryBalances['ending_balance'] += $endingBalance;
  619. $formattedAccountBalances = $this->formatBalances($accountBalances);
  620. $accountDTO = new AccountDTO(
  621. $account->name,
  622. $account->code,
  623. $account->id,
  624. $formattedAccountBalances,
  625. startDate: $startDateCarbon->toDateString(),
  626. endDate: $asOfDateCarbon->toDateString(),
  627. );
  628. if ($category === AccountCategory::Equity && $account->type === AccountType::Equity) {
  629. $categoryAccounts[] = $accountDTO;
  630. } else {
  631. $accountType = $account->type->getPluralLabel();
  632. $categoryAccountsByType[$accountType][] = $accountDTO;
  633. $subCategoryTotals[$accountType] = ($subCategoryTotals[$accountType] ?? 0) + $endingBalance;
  634. }
  635. }
  636. }
  637. if ($category === AccountCategory::Equity) {
  638. $retainedEarningsAmount = $this->calculateRetainedEarnings($startDateCarbon->toDateTimeString(), $asOfDateCarbon->toDateTimeString())->getAmount();
  639. $categorySummaryBalances['ending_balance'] += $retainedEarningsAmount;
  640. $retainedEarningsDTO = new AccountDTO(
  641. 'Retained Earnings',
  642. 'RE',
  643. null,
  644. $this->formatBalances(['ending_balance' => $retainedEarningsAmount]),
  645. startDate: $startDateCarbon->toDateString(),
  646. endDate: $asOfDateCarbon->toDateString(),
  647. );
  648. $categoryAccounts[] = $retainedEarningsDTO;
  649. }
  650. $subCategories = [];
  651. foreach ($categoryAccountsByType as $accountType => $accountsInType) {
  652. $subCategorySummary = $this->formatBalances([
  653. 'ending_balance' => $subCategoryTotals[$accountType] ?? 0,
  654. ]);
  655. $subCategories[$accountType] = new AccountTypeDTO(
  656. accounts: $accountsInType,
  657. summary: $subCategorySummary
  658. );
  659. }
  660. $reportTotalBalances[match ($category) {
  661. AccountCategory::Asset => 'assets',
  662. AccountCategory::Liability => 'liabilities',
  663. AccountCategory::Equity => 'equity',
  664. }] += $categorySummaryBalances['ending_balance'];
  665. $accountCategories[$category->getPluralLabel()] = new AccountCategoryDTO(
  666. accounts: $categoryAccounts,
  667. types: $subCategories,
  668. summary: $this->formatBalances($categorySummaryBalances),
  669. );
  670. }
  671. $netAssets = $reportTotalBalances['assets'] - $reportTotalBalances['liabilities'];
  672. $formattedReportTotalBalances = $this->formatBalances(['ending_balance' => $netAssets]);
  673. return new ReportDTO(
  674. categories: $accountCategories,
  675. overallTotal: $formattedReportTotalBalances,
  676. fields: $columns,
  677. startDate: $startDateCarbon,
  678. endDate: $asOfDateCarbon,
  679. );
  680. }
  681. public function buildAgingReport(
  682. string $asOfDate,
  683. DocumentEntityType $entityType,
  684. array $columns = [],
  685. int $daysPerPeriod = 30,
  686. int $numberOfPeriods = 4
  687. ): ReportDTO {
  688. $asOfDateCarbon = Carbon::parse($asOfDate);
  689. $documents = $entityType === DocumentEntityType::Client
  690. ? $this->accountService->getUnpaidClientInvoices($asOfDate)->with(['client:id,name'])->get()->groupBy('client_id')
  691. : $this->accountService->getUnpaidVendorBills($asOfDate)->with(['vendor:id,name'])->get()->groupBy('vendor_id');
  692. $categories = [];
  693. $totalAging = [
  694. 'current' => 0,
  695. ];
  696. for ($i = 1; $i <= $numberOfPeriods; $i++) {
  697. $totalAging["period_{$i}"] = 0;
  698. }
  699. $totalAging['over_periods'] = 0;
  700. $totalAging['total'] = 0;
  701. /** @var DocumentCollection<int,Invoice|Bill> $entityDocuments */
  702. foreach ($documents as $entityId => $entityDocuments) {
  703. $aging = [
  704. 'current' => $entityDocuments
  705. ->filter(static fn ($doc) => ($doc->days_overdue ?? 0) <= 0)
  706. ->sumMoneyInDefaultCurrency('amount_due'),
  707. ];
  708. for ($i = 1; $i <= $numberOfPeriods; $i++) {
  709. $min = ($i - 1) * $daysPerPeriod;
  710. $max = $i * $daysPerPeriod;
  711. $aging["period_{$i}"] = $entityDocuments
  712. ->filter(static function ($doc) use ($min, $max) {
  713. $days = $doc->days_overdue ?? 0;
  714. return $days > $min && $days <= $max;
  715. })
  716. ->sumMoneyInDefaultCurrency('amount_due');
  717. }
  718. $aging['over_periods'] = $entityDocuments
  719. ->filter(static fn ($doc) => ($doc->days_overdue ?? 0) > ($numberOfPeriods * $daysPerPeriod))
  720. ->sumMoneyInDefaultCurrency('amount_due');
  721. $aging['total'] = array_sum($aging);
  722. foreach ($aging as $bucket => $amount) {
  723. $totalAging[$bucket] += $amount;
  724. }
  725. $entity = $entityDocuments->first()->{$entityType->value};
  726. $categories[] = new EntityReportDTO(
  727. name: $entity->name,
  728. id: $entityId,
  729. aging: $this->formatBalances($aging, AgingBucketDTO::class, false),
  730. );
  731. }
  732. $totalAging['total'] = array_sum($totalAging);
  733. return new ReportDTO(
  734. categories: ['Entities' => $categories],
  735. agingSummary: $this->formatBalances($totalAging, AgingBucketDTO::class),
  736. fields: $columns,
  737. endDate: $asOfDateCarbon,
  738. );
  739. }
  740. public function buildEntityBalanceSummaryReport(string $startDate, string $endDate, DocumentEntityType $entityType, array $columns = []): ReportDTO
  741. {
  742. $documents = match ($entityType) {
  743. DocumentEntityType::Client => Invoice::query()
  744. ->whereBetween('date', [$startDate, $endDate])
  745. ->whereNotIn('status', [
  746. InvoiceStatus::Draft,
  747. InvoiceStatus::Void,
  748. ])
  749. ->whereNotNull('approved_at')
  750. ->with(['client:id,name'])
  751. ->get()
  752. ->groupBy('client_id'),
  753. DocumentEntityType::Vendor => Bill::query()
  754. ->whereBetween('date', [$startDate, $endDate])
  755. ->whereNot('status', BillStatus::Void)
  756. ->with(['vendor:id,name'])
  757. ->get()
  758. ->groupBy('vendor_id'),
  759. };
  760. $entities = [];
  761. $totalBalance = 0;
  762. $totalPaidBalance = 0;
  763. $totalUnpaidBalance = 0;
  764. /** @var DocumentCollection<int,Invoice|Bill> $entityDocuments */
  765. foreach ($documents as $entityDocuments) {
  766. $entityTotalBalance = $entityDocuments->sumMoneyInDefaultCurrency('total');
  767. $entityPaidBalance = $entityDocuments->sumMoneyInDefaultCurrency('amount_paid');
  768. $entityUnpaidBalance = match ($entityType) {
  769. DocumentEntityType::Client => $entityDocuments->whereNot('status', InvoiceStatus::Overpaid)
  770. ->sumMoneyInDefaultCurrency('amount_due'),
  771. DocumentEntityType::Vendor => $entityDocuments->whereIn('status', [BillStatus::Open, BillStatus::Partial, BillStatus::Overdue])
  772. ->sumMoneyInDefaultCurrency('amount_due'),
  773. };
  774. $totalBalance += $entityTotalBalance;
  775. $totalPaidBalance += $entityPaidBalance;
  776. $totalUnpaidBalance += $entityUnpaidBalance;
  777. $formattedBalances = $this->formatBalances([
  778. 'total_balance' => $entityTotalBalance,
  779. 'paid_balance' => $entityPaidBalance,
  780. 'unpaid_balance' => $entityUnpaidBalance,
  781. ], EntityBalanceDTO::class);
  782. $entity = $entityDocuments->first()->{$entityType->value};
  783. $entities[] = new EntityReportDTO(
  784. name: $entity->name,
  785. id: $entity->id,
  786. balance: $formattedBalances,
  787. );
  788. }
  789. $entityBalanceTotal = $this->formatBalances([
  790. 'total_balance' => $totalBalance,
  791. 'paid_balance' => $totalPaidBalance,
  792. 'unpaid_balance' => $totalUnpaidBalance,
  793. ], EntityBalanceDTO::class);
  794. return new ReportDTO(
  795. categories: ['Entities' => $entities],
  796. entityBalanceTotal: $entityBalanceTotal,
  797. fields: $columns,
  798. startDate: Carbon::parse($startDate),
  799. endDate: Carbon::parse($endDate),
  800. );
  801. }
  802. public function buildEntityPaymentPerformanceReport(
  803. string $startDate,
  804. string $endDate,
  805. DocumentEntityType $entityType,
  806. array $columns = []
  807. ): ReportDTO {
  808. $documents = match ($entityType) {
  809. DocumentEntityType::Client => Invoice::query()
  810. ->whereBetween('date', [$startDate, $endDate])
  811. ->whereNotIn('status', [InvoiceStatus::Draft, InvoiceStatus::Void])
  812. ->whereNotNull('approved_at')
  813. ->whereNotNull('paid_at')
  814. ->with(['client:id,name'])
  815. ->get()
  816. ->groupBy('client_id'),
  817. DocumentEntityType::Vendor => Bill::query()
  818. ->whereBetween('date', [$startDate, $endDate])
  819. ->whereNotIn('status', [BillStatus::Void])
  820. ->whereNotNull('paid_at')
  821. ->with(['vendor:id,name'])
  822. ->get()
  823. ->groupBy('vendor_id'),
  824. };
  825. $categories = [];
  826. $totalDocs = 0;
  827. $totalOnTime = 0;
  828. $totalLate = 0;
  829. $allPaymentDays = [];
  830. $allLateDays = [];
  831. /** @var DocumentCollection<int,Invoice|Bill> $entityDocuments */
  832. foreach ($documents as $entityId => $entityDocuments) {
  833. $entity = $entityDocuments->first()->{$entityType->value};
  834. $onTimeDocs = $entityDocuments->filter(fn (Invoice | Bill $doc) => $doc->paid_at->lte($doc->due_date));
  835. $onTimeCount = $onTimeDocs->count();
  836. $lateDocs = $entityDocuments->filter(fn (Invoice | Bill $doc) => $doc->paid_at->gt($doc->due_date));
  837. $lateCount = $lateDocs->count();
  838. $avgDaysToPay = $entityDocuments->avg(
  839. fn (Invoice | Bill $doc) => $doc instanceof Invoice
  840. ? $doc->approved_at->diffInDays($doc->paid_at)
  841. : $doc->date->diffInDays($doc->paid_at)
  842. ) ?? 0;
  843. $avgDaysLate = $lateDocs->avg(fn (Invoice | Bill $doc) => $doc->due_date->diffInDays($doc->paid_at)) ?? 0;
  844. $onTimeRate = $entityDocuments->isNotEmpty()
  845. ? ($onTimeCount / $entityDocuments->count() * 100)
  846. : 0;
  847. $totalDocs += $entityDocuments->count();
  848. $totalOnTime += $onTimeCount;
  849. $totalLate += $lateCount;
  850. $entityDocuments->each(function (Invoice | Bill $doc) use (&$allPaymentDays, &$allLateDays) {
  851. $allPaymentDays[] = $doc instanceof Invoice
  852. ? $doc->approved_at->diffInDays($doc->paid_at)
  853. : $doc->date->diffInDays($doc->paid_at);
  854. if ($doc->paid_at->gt($doc->due_date)) {
  855. $allLateDays[] = $doc->due_date->diffInDays($doc->paid_at);
  856. }
  857. });
  858. $categories[] = new EntityReportDTO(
  859. name: $entity->name,
  860. id: $entityId,
  861. paymentMetrics: new PaymentMetricsDTO(
  862. totalDocuments: $entityDocuments->count(),
  863. onTimeCount: $onTimeCount ?: null,
  864. lateCount: $lateCount ?: null,
  865. avgDaysToPay: $avgDaysToPay ? round($avgDaysToPay) : null,
  866. avgDaysLate: $avgDaysLate ? round($avgDaysLate) : null,
  867. onTimePaymentRate: Number::percentage($onTimeRate, maxPrecision: 2),
  868. ),
  869. );
  870. }
  871. $categories = collect($categories)
  872. ->sortByDesc(static fn (EntityReportDTO $category) => $category->paymentMetrics->onTimePaymentRate, SORT_NATURAL)
  873. ->values()
  874. ->all();
  875. $overallMetrics = new PaymentMetricsDTO(
  876. totalDocuments: $totalDocs,
  877. onTimeCount: $totalOnTime,
  878. lateCount: $totalLate,
  879. avgDaysToPay: round(collect($allPaymentDays)->avg() ?? 0),
  880. avgDaysLate: round(collect($allLateDays)->avg() ?? 0),
  881. onTimePaymentRate: Number::percentage(
  882. $totalDocs > 0 ? ($totalOnTime / $totalDocs * 100) : 0,
  883. maxPrecision: 2
  884. ),
  885. );
  886. return new ReportDTO(
  887. categories: ['Entities' => $categories],
  888. overallPaymentMetrics: $overallMetrics,
  889. fields: $columns,
  890. startDate: Carbon::parse($startDate),
  891. endDate: Carbon::parse($endDate),
  892. );
  893. }
  894. }