You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

TransactionService.php 8.7KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210
  1. <?php
  2. namespace App\Services;
  3. use App\Enums\Accounting\AccountCategory;
  4. use App\Enums\Accounting\AccountType;
  5. use App\Models\Accounting\Account;
  6. use App\Models\Accounting\Transaction;
  7. use App\Models\Banking\ConnectedBankAccount;
  8. use App\Models\Company;
  9. use App\Models\Setting\Category;
  10. use App\Scopes\CurrentCompanyScope;
  11. use Illuminate\Support\Carbon;
  12. class TransactionService
  13. {
  14. public function createStartingBalanceIfNeeded(Company $company, Account $account, ConnectedBankAccount $connectedBankAccount, array $transactions, float $currentBalance, string $startDate): void
  15. {
  16. if ($account->transactions()->withoutGlobalScope(CurrentCompanyScope::class)->doesntExist()) {
  17. $accountSign = $account->category === AccountCategory::Asset ? 1 : -1;
  18. $sumOfTransactions = collect($transactions)->reduce(static function ($carry, $transaction) {
  19. return bcadd($carry, (string) -$transaction->amount, 2);
  20. }, '0.00');
  21. $adjustedBalance = (string) ($currentBalance * $accountSign);
  22. $startingBalance = bcsub($adjustedBalance, $sumOfTransactions, 2);
  23. $this->createStartingBalanceTransaction($company, $account, $connectedBankAccount, (float) $startingBalance, $startDate);
  24. }
  25. }
  26. public function storeTransactions(Company $company, Account $account, ConnectedBankAccount $connectedBankAccount, array $transactions): void
  27. {
  28. foreach ($transactions as $transaction) {
  29. $this->storeTransaction($company, $account, $connectedBankAccount, $transaction);
  30. }
  31. }
  32. public function createStartingBalanceTransaction(Company $company, Account $account, ConnectedBankAccount $connectedBankAccount, float $startingBalance, string $startDate): void
  33. {
  34. [$transactionType, $method] = $startingBalance >= 0 ? ['income', 'deposit'] : ['expense', 'withdrawal'];
  35. $category = $this->getUncategorizedCategory($company, $transactionType);
  36. $chartAccount = $account->where('category', AccountCategory::Equity)->where('name', 'Owner\'s Equity')->first();
  37. $transactionRecord = $account->transactions()->create([
  38. 'company_id' => $company->id,
  39. 'category_id' => $category->id,
  40. 'bank_account_id' => $connectedBankAccount->bank_account_id,
  41. 'type' => $transactionType,
  42. 'amount' => abs($startingBalance),
  43. 'method' => $method,
  44. 'payment_channel' => 'other',
  45. 'posted_at' => $startDate,
  46. 'description' => 'Starting Balance',
  47. 'pending' => false,
  48. 'reviewed' => true,
  49. ]);
  50. $this->createJournalEntries($company, $account, $transactionRecord, $chartAccount);
  51. }
  52. public function storeTransaction(Company $company, Account $account, ConnectedBankAccount $connectedBankAccount, object $transaction): void
  53. {
  54. [$transactionType, $method] = $transaction->amount < 0 ? ['income', 'deposit'] : ['expense', 'withdrawal'];
  55. $paymentChannel = $transaction->payment_channel;
  56. $category = $this->getCategoryFromTransaction($company, $transaction, $transactionType);
  57. $chartAccount = $category->account ?? $this->getChartFromTransaction($company, $transactionType);
  58. $postedAt = $transaction->datetime ?? Carbon::parse($transaction->date)->toDateTimeString();
  59. $description = $transaction->name;
  60. $transactionRecord = $account->transactions()->create([
  61. 'company_id' => $company->id,
  62. 'category_id' => $category->id,
  63. 'bank_account_id' => $connectedBankAccount->bank_account_id,
  64. 'type' => $transactionType,
  65. 'amount' => abs($transaction->amount),
  66. 'method' => $method,
  67. 'payment_channel' => $paymentChannel,
  68. 'posted_at' => $postedAt,
  69. 'description' => $description,
  70. 'pending' => $transaction->pending,
  71. 'reviewed' => false,
  72. ]);
  73. $this->createJournalEntries($company, $account, $transactionRecord, $chartAccount);
  74. }
  75. public function createJournalEntries(Company $company, Account $account, Transaction $transaction, Account $chartAccount): void
  76. {
  77. $debitAccount = $transaction->type === 'expense' ? $chartAccount : $account;
  78. $creditAccount = $transaction->type === 'expense' ? $account : $chartAccount;
  79. $amount = $transaction->amount;
  80. $debitAccount->journalEntries()->create([
  81. 'company_id' => $company->id,
  82. 'transaction_id' => $transaction->id,
  83. 'type' => 'debit',
  84. 'amount' => $amount,
  85. 'description' => $transaction->description,
  86. ]);
  87. $creditAccount->journalEntries()->create([
  88. 'company_id' => $company->id,
  89. 'transaction_id' => $transaction->id,
  90. 'type' => 'credit',
  91. 'amount' => $amount,
  92. 'description' => $transaction->description,
  93. ]);
  94. }
  95. public function getCategoryFromTransaction(Company $company, object $transaction, string $transactionType): Category
  96. {
  97. $companyCategories = $company->categories()
  98. ->where('type', $transactionType)
  99. ->whereNotIn('name', ['Other Income', 'Other Expense'])
  100. ->get();
  101. $bestMatchName = $this->findBestCategoryMatch($transaction, $companyCategories->pluck('name')->toArray());
  102. if ($bestMatchName === null) {
  103. return $this->getUncategorizedCategory($company, $transactionType);
  104. }
  105. $category = $companyCategories->firstWhere('name', $bestMatchName);
  106. return $category ?: $this->getUncategorizedCategory($company, $transactionType);
  107. }
  108. private function findBestCategoryMatch(object $transaction, array $userCategories): ?string
  109. {
  110. $acceptableConfidenceLevels = ['VERY_HIGH', 'HIGH'];
  111. $similarityThreshold = 0.7;
  112. $plaidDetail = $transaction->personal_finance_category->detailed ?? null;
  113. $plaidPrimary = $transaction->personal_finance_category->primary ?? null;
  114. $bestMatchName = null;
  115. $bestMatchPercent = 0.0;
  116. foreach ([$plaidDetail, $plaidPrimary] as $plaidCategory) {
  117. if ($plaidCategory !== null && in_array($transaction->personal_finance_category->confidence_level, $acceptableConfidenceLevels, true)) {
  118. $currentMatchPercent = 0.0;
  119. $matchedName = $this->closestCategory($plaidCategory, $userCategories, $currentMatchPercent);
  120. if ($currentMatchPercent >= $similarityThreshold && $currentMatchPercent > $bestMatchPercent) {
  121. $bestMatchPercent = $currentMatchPercent;
  122. $bestMatchName = $matchedName;
  123. }
  124. }
  125. }
  126. return $bestMatchName;
  127. }
  128. public function closestCategory(string $input, array $categories, ?float &$percent): ?string
  129. {
  130. $inputNormalized = strtolower(str_replace('_', ' ', $input));
  131. $originalToNormalized = [];
  132. foreach ($categories as $originalCategory) {
  133. $normalizedCategory = strtolower(str_replace('_', ' ', $originalCategory));
  134. $originalToNormalized[$normalizedCategory] = $originalCategory;
  135. }
  136. $shortest = -1;
  137. $closestNormalized = null;
  138. foreach ($originalToNormalized as $normalizedCategory => $originalCategory) {
  139. $lev = levenshtein($inputNormalized, $normalizedCategory);
  140. if ($lev === 0 || $lev < $shortest || $shortest < 0) {
  141. $closestNormalized = $normalizedCategory;
  142. $shortest = $lev;
  143. }
  144. }
  145. if ($closestNormalized !== null) {
  146. $percent = 1.0 - ($shortest / max(strlen($inputNormalized), strlen($closestNormalized)));
  147. return $originalToNormalized[$closestNormalized]; // return the original category name
  148. }
  149. $percent = 0.0;
  150. return null;
  151. }
  152. public function getUncategorizedCategory(Company $company, string $transactionType): Category
  153. {
  154. $name = match ($transactionType) {
  155. 'income' => 'Other Income',
  156. 'expense' => 'Other Expense',
  157. };
  158. return $company->categories()
  159. ->where('type', $transactionType)
  160. ->where('name', $name)
  161. ->firstOrFail();
  162. }
  163. public function getChartFromTransaction(Company $company, string $transactionType): Account
  164. {
  165. [$type, $name] = match ($transactionType) {
  166. 'income' => [AccountType::UncategorizedRevenue, 'Uncategorized Income'],
  167. 'expense' => [AccountType::UncategorizedExpense, 'Uncategorized Expense'],
  168. };
  169. return $company->accounts()
  170. ->where('type', $type)
  171. ->where('name', $name)
  172. ->firstOrFail();
  173. }
  174. }