|  | @@ -2,15 +2,23 @@
 | 
		
	
		
			
			| 2 | 2 |  
 | 
		
	
		
			
			| 3 | 3 |  namespace App\Listeners;
 | 
		
	
		
			
			| 4 | 4 |  
 | 
		
	
		
			
			|  | 5 | +use App\Enums\Accounting\AccountCategory;
 | 
		
	
		
			
			|  | 6 | +use App\Enums\Accounting\AccountType;
 | 
		
	
		
			
			| 5 | 7 |  use App\Events\StartTransactionImport;
 | 
		
	
		
			
			|  | 8 | +use App\Models\Accounting\Account;
 | 
		
	
		
			
			| 6 | 9 |  use App\Models\Accounting\AccountSubtype;
 | 
		
	
		
			
			|  | 10 | +use App\Models\Accounting\Transaction;
 | 
		
	
		
			
			| 7 | 11 |  use App\Models\Banking\BankAccount;
 | 
		
	
		
			
			| 8 | 12 |  use App\Models\Banking\ConnectedBankAccount;
 | 
		
	
		
			
			| 9 | 13 |  use App\Models\Company;
 | 
		
	
		
			
			|  | 14 | +use App\Models\Setting\Category;
 | 
		
	
		
			
			| 10 | 15 |  use App\Models\Setting\Currency;
 | 
		
	
		
			
			| 11 | 16 |  use App\Services\PlaidService;
 | 
		
	
		
			
			| 12 | 17 |  use App\Utilities\Currency\CurrencyAccessor;
 | 
		
	
		
			
			|  | 18 | +use Illuminate\Support\Carbon;
 | 
		
	
		
			
			| 13 | 19 |  use Illuminate\Support\Facades\DB;
 | 
		
	
		
			
			|  | 20 | +use Illuminate\Support\Facades\Log;
 | 
		
	
		
			
			|  | 21 | +use Illuminate\Support\Str;
 | 
		
	
		
			
			| 14 | 22 |  
 | 
		
	
		
			
			| 15 | 23 |  class HandleTransactionImport
 | 
		
	
		
			
			| 16 | 24 |  {
 | 
		
	
	
		
			
			|  | @@ -43,20 +51,184 @@ class HandleTransactionImport
 | 
		
	
		
			
			| 43 | 51 |  
 | 
		
	
		
			
			| 44 | 52 |          $accessToken = $connectedBankAccount->access_token;
 | 
		
	
		
			
			| 45 | 53 |  
 | 
		
	
		
			
			| 46 |  | -        if ($selectedBankAccountId === 'new') {
 | 
		
	
		
			
			| 47 |  | -            $bankAccount = $this->processNewBankAccount($company, $connectedBankAccount, $accessToken);
 | 
		
	
		
			
			| 48 |  | -        } else {
 | 
		
	
		
			
			| 49 |  | -            $bankAccount = BankAccount::find($selectedBankAccountId);
 | 
		
	
		
			
			|  | 54 | +        $bankAccount = $selectedBankAccountId === 'new'
 | 
		
	
		
			
			|  | 55 | +            ? $this->processNewBankAccount($company, $connectedBankAccount, $accessToken)
 | 
		
	
		
			
			|  | 56 | +            : BankAccount::find($selectedBankAccountId);
 | 
		
	
		
			
			|  | 57 | +
 | 
		
	
		
			
			|  | 58 | +        if ($bankAccount) {
 | 
		
	
		
			
			|  | 59 | +            $connectedBankAccount->update([
 | 
		
	
		
			
			|  | 60 | +                'bank_account_id' => $bankAccount->id,
 | 
		
	
		
			
			|  | 61 | +                'import_transactions' => true,
 | 
		
	
		
			
			|  | 62 | +            ]);
 | 
		
	
		
			
			|  | 63 | +
 | 
		
	
		
			
			|  | 64 | +            $account = $bankAccount->account;
 | 
		
	
		
			
			|  | 65 | +
 | 
		
	
		
			
			|  | 66 | +            $this->processTransactions($startDate, $company, $connectedBankAccount, $accessToken, $account);
 | 
		
	
		
			
			|  | 67 | +        }
 | 
		
	
		
			
			|  | 68 | +    }
 | 
		
	
		
			
			|  | 69 | +
 | 
		
	
		
			
			|  | 70 | +    public function processTransactions($startDate, Company $company, ConnectedBankAccount $connectedBankAccount, $accessToken, Account $account): void
 | 
		
	
		
			
			|  | 71 | +    {
 | 
		
	
		
			
			|  | 72 | +        $endDate = Carbon::now()->toDateString();
 | 
		
	
		
			
			|  | 73 | +        $startDate = Carbon::parse($startDate)->toDateString();
 | 
		
	
		
			
			|  | 74 | +
 | 
		
	
		
			
			|  | 75 | +        $transactionsResponse = $this->plaid->getTransactions($accessToken, $startDate, $endDate, [
 | 
		
	
		
			
			|  | 76 | +            'account_ids' => [$connectedBankAccount->external_account_id],
 | 
		
	
		
			
			|  | 77 | +        ]);
 | 
		
	
		
			
			| 50 | 78 |  
 | 
		
	
		
			
			| 51 |  | -            if ($bankAccount === null) {
 | 
		
	
		
			
			| 52 |  | -                return;
 | 
		
	
		
			
			|  | 79 | +        if (! empty($transactionsResponse->transactions)) {
 | 
		
	
		
			
			|  | 80 | +            foreach ($transactionsResponse->transactions as $transaction) {
 | 
		
	
		
			
			|  | 81 | +                $this->storeTransaction($company, $account, $connectedBankAccount, $transaction);
 | 
		
	
		
			
			| 53 | 82 |              }
 | 
		
	
		
			
			| 54 | 83 |          }
 | 
		
	
		
			
			|  | 84 | +    }
 | 
		
	
		
			
			|  | 85 | +
 | 
		
	
		
			
			|  | 86 | +    public function storeTransaction(Company $company, Account $account, ConnectedBankAccount $connectedBankAccount, $transaction): void
 | 
		
	
		
			
			|  | 87 | +    {
 | 
		
	
		
			
			|  | 88 | +        if ($account->category === AccountCategory::Asset) {
 | 
		
	
		
			
			|  | 89 | +            $transactionType = $transaction->amount < 0 ? 'income' : 'expense';
 | 
		
	
		
			
			|  | 90 | +        } else {
 | 
		
	
		
			
			|  | 91 | +            $transactionType = $transaction->amount < 0 ? 'expense' : 'income';
 | 
		
	
		
			
			|  | 92 | +        }
 | 
		
	
		
			
			|  | 93 | +
 | 
		
	
		
			
			|  | 94 | +        $method = $transactionType === 'income' ? 'deposit' : 'withdrawal';
 | 
		
	
		
			
			|  | 95 | +        $paymentChannel = $transaction->payment_channel ?? 'other';
 | 
		
	
		
			
			|  | 96 | +        $category = $this->getCategoryFromTransaction($company, $transaction, $transactionType);
 | 
		
	
		
			
			|  | 97 | +        $chartAccount = $category->account ?? $this->getChartFromTransaction($company, $transaction, $transactionType);
 | 
		
	
		
			
			|  | 98 | +
 | 
		
	
		
			
			|  | 99 | +        $postedAt = $transaction->datetime ?? Carbon::parse($transaction->date)->toDateTimeString();
 | 
		
	
		
			
			|  | 100 | +
 | 
		
	
		
			
			|  | 101 | +        $description = $transaction->original_description ?? $transaction->name;
 | 
		
	
		
			
			| 55 | 102 |  
 | 
		
	
		
			
			| 56 |  | -        $connectedBankAccount->update([
 | 
		
	
		
			
			| 57 |  | -            'bank_account_id' => $bankAccount->id,
 | 
		
	
		
			
			| 58 |  | -            'import_transactions' => true,
 | 
		
	
		
			
			|  | 103 | +        Log::info('Transaction description:', [
 | 
		
	
		
			
			|  | 104 | +            'name' => $transaction->name,
 | 
		
	
		
			
			|  | 105 | +            'description' => $description,
 | 
		
	
		
			
			|  | 106 | +            'amount' => $transaction->amount,
 | 
		
	
		
			
			|  | 107 | +            'detailedCategory' => $transaction->personal_finance_category->detailed,
 | 
		
	
		
			
			|  | 108 | +            'primaryCategory' => $transaction->personal_finance_category->primary,
 | 
		
	
		
			
			| 59 | 109 |          ]);
 | 
		
	
		
			
			|  | 110 | +
 | 
		
	
		
			
			|  | 111 | +        $transactionRecord = $account->transactions()->create([
 | 
		
	
		
			
			|  | 112 | +            'company_id' => $company->id,
 | 
		
	
		
			
			|  | 113 | +            'category_id' => $category->id,
 | 
		
	
		
			
			|  | 114 | +            'bank_account_id' => $connectedBankAccount->bank_account_id,
 | 
		
	
		
			
			|  | 115 | +            'type' => $transactionType,
 | 
		
	
		
			
			|  | 116 | +            'amount' => abs($transaction->amount),
 | 
		
	
		
			
			|  | 117 | +            'method' => $method,
 | 
		
	
		
			
			|  | 118 | +            'payment_channel' => $paymentChannel,
 | 
		
	
		
			
			|  | 119 | +            'posted_at' => $postedAt,
 | 
		
	
		
			
			|  | 120 | +            'description' => $description,
 | 
		
	
		
			
			|  | 121 | +            'pending' => $transaction->pending,
 | 
		
	
		
			
			|  | 122 | +            'reviewed' => false,
 | 
		
	
		
			
			|  | 123 | +        ]);
 | 
		
	
		
			
			|  | 124 | +
 | 
		
	
		
			
			|  | 125 | +        $this->createJournalEntries($company, $account, $transactionRecord, $chartAccount);
 | 
		
	
		
			
			|  | 126 | +    }
 | 
		
	
		
			
			|  | 127 | +
 | 
		
	
		
			
			|  | 128 | +    public function createJournalEntries(Company $company, Account $account, Transaction $transaction, Account $chartAccount): void
 | 
		
	
		
			
			|  | 129 | +    {
 | 
		
	
		
			
			|  | 130 | +        // For an expense (withdrawal) transaction, we need to credit the liability or asset account ($account), and debit the expense account ($chartAccount)
 | 
		
	
		
			
			|  | 131 | +        // For an income (deposit) transaction, we need to debit the liability or asset account ($account), and credit the revenue account ($chartAccount)
 | 
		
	
		
			
			|  | 132 | +        // Debiting an Asset account increases its balance. Crediting an Asset account decreases its balance.
 | 
		
	
		
			
			|  | 133 | +        // Crediting a Liability account increases its balance. Debiting a Liability account decreases its balance.
 | 
		
	
		
			
			|  | 134 | +        // Expense accounts should always be debited. Revenue accounts should always be credited.
 | 
		
	
		
			
			|  | 135 | +        $debitAccount = $transaction->type === 'expense' ? $chartAccount : $account;
 | 
		
	
		
			
			|  | 136 | +        $creditAccount = $transaction->type === 'expense' ? $account : $chartAccount;
 | 
		
	
		
			
			|  | 137 | +
 | 
		
	
		
			
			|  | 138 | +        $amount = $transaction->amount;
 | 
		
	
		
			
			|  | 139 | +
 | 
		
	
		
			
			|  | 140 | +        $debitAccount->journalEntries()->create([
 | 
		
	
		
			
			|  | 141 | +            'company_id' => $company->id,
 | 
		
	
		
			
			|  | 142 | +            'transaction_id' => $transaction->id,
 | 
		
	
		
			
			|  | 143 | +            'type' => 'debit',
 | 
		
	
		
			
			|  | 144 | +            'amount' => $amount,
 | 
		
	
		
			
			|  | 145 | +            'description' => $transaction->description,
 | 
		
	
		
			
			|  | 146 | +        ]);
 | 
		
	
		
			
			|  | 147 | +
 | 
		
	
		
			
			|  | 148 | +        $creditAccount->journalEntries()->create([
 | 
		
	
		
			
			|  | 149 | +            'company_id' => $company->id,
 | 
		
	
		
			
			|  | 150 | +            'transaction_id' => $transaction->id,
 | 
		
	
		
			
			|  | 151 | +            'type' => 'credit',
 | 
		
	
		
			
			|  | 152 | +            'amount' => $amount,
 | 
		
	
		
			
			|  | 153 | +            'description' => $transaction->description,
 | 
		
	
		
			
			|  | 154 | +        ]);
 | 
		
	
		
			
			|  | 155 | +    }
 | 
		
	
		
			
			|  | 156 | +
 | 
		
	
		
			
			|  | 157 | +    public function getCategoryFromTransaction(Company $company, $transaction, string $transactionType): Category
 | 
		
	
		
			
			|  | 158 | +    {
 | 
		
	
		
			
			|  | 159 | +        $acceptableConfidenceLevels = ['VERY_HIGH', 'HIGH'];
 | 
		
	
		
			
			|  | 160 | +
 | 
		
	
		
			
			|  | 161 | +        $userCategories = $company->categories()->get();
 | 
		
	
		
			
			|  | 162 | +        $plaidDetail = $transaction->personal_finance_category->detailed ?? null;
 | 
		
	
		
			
			|  | 163 | +        $plaidPrimary = $transaction->personal_finance_category->primary ?? null;
 | 
		
	
		
			
			|  | 164 | +
 | 
		
	
		
			
			|  | 165 | +        $category = null;
 | 
		
	
		
			
			|  | 166 | +
 | 
		
	
		
			
			|  | 167 | +        if ($plaidDetail !== null && in_array($transaction->personal_finance_category->confidence_level, $acceptableConfidenceLevels, true)) {
 | 
		
	
		
			
			|  | 168 | +            $category = $this->matchCategory($userCategories, $plaidDetail, $transactionType);
 | 
		
	
		
			
			|  | 169 | +        }
 | 
		
	
		
			
			|  | 170 | +
 | 
		
	
		
			
			|  | 171 | +        if ($plaidPrimary !== null && ($category === null || $this->isUncategorized($category))) {
 | 
		
	
		
			
			|  | 172 | +            $category = $this->matchCategory($userCategories, $plaidPrimary, $transactionType);
 | 
		
	
		
			
			|  | 173 | +        }
 | 
		
	
		
			
			|  | 174 | +
 | 
		
	
		
			
			|  | 175 | +        return $category ?? $this->getUncategorizedCategory($company, $transaction, $transactionType);
 | 
		
	
		
			
			|  | 176 | +    }
 | 
		
	
		
			
			|  | 177 | +
 | 
		
	
		
			
			|  | 178 | +    public function isUncategorized(Category $category): bool
 | 
		
	
		
			
			|  | 179 | +    {
 | 
		
	
		
			
			|  | 180 | +        return Str::contains(strtolower($category->name), 'other');
 | 
		
	
		
			
			|  | 181 | +    }
 | 
		
	
		
			
			|  | 182 | +
 | 
		
	
		
			
			|  | 183 | +    public function matchCategory($userCategories, $plaidCategory, string $transactionType): ?Category
 | 
		
	
		
			
			|  | 184 | +    {
 | 
		
	
		
			
			|  | 185 | +        $plaidWords = explode(' ', strtolower($plaidCategory));
 | 
		
	
		
			
			|  | 186 | +
 | 
		
	
		
			
			|  | 187 | +        $bestMatchCategory = null;
 | 
		
	
		
			
			|  | 188 | +        $bestMatchScore = 0; // Higher is better
 | 
		
	
		
			
			|  | 189 | +
 | 
		
	
		
			
			|  | 190 | +        foreach ($userCategories as $category) {
 | 
		
	
		
			
			|  | 191 | +            if (strtolower($category->type->value) !== strtolower($transactionType)) {
 | 
		
	
		
			
			|  | 192 | +                continue;
 | 
		
	
		
			
			|  | 193 | +            }
 | 
		
	
		
			
			|  | 194 | +
 | 
		
	
		
			
			|  | 195 | +            $categoryWords = explode(' ', strtolower($category->name));
 | 
		
	
		
			
			|  | 196 | +            $matchScore = count(array_intersect($plaidWords, $categoryWords));
 | 
		
	
		
			
			|  | 197 | +
 | 
		
	
		
			
			|  | 198 | +            if ($matchScore > $bestMatchScore) {
 | 
		
	
		
			
			|  | 199 | +                $bestMatchScore = $matchScore;
 | 
		
	
		
			
			|  | 200 | +                $bestMatchCategory = $category;
 | 
		
	
		
			
			|  | 201 | +            }
 | 
		
	
		
			
			|  | 202 | +        }
 | 
		
	
		
			
			|  | 203 | +
 | 
		
	
		
			
			|  | 204 | +        return $bestMatchCategory;
 | 
		
	
		
			
			|  | 205 | +    }
 | 
		
	
		
			
			|  | 206 | +
 | 
		
	
		
			
			|  | 207 | +    public function getUncategorizedCategory(Company $company, $transaction, string $transactionType): Category
 | 
		
	
		
			
			|  | 208 | +    {
 | 
		
	
		
			
			|  | 209 | +        $uncategorizedCategoryName = 'Other ' . ucfirst($transactionType);
 | 
		
	
		
			
			|  | 210 | +        $uncategorizedCategory = $company->categories()->where('type', $transactionType)->where('name', $uncategorizedCategoryName)->first();
 | 
		
	
		
			
			|  | 211 | +
 | 
		
	
		
			
			|  | 212 | +        if ($uncategorizedCategory === null) {
 | 
		
	
		
			
			|  | 213 | +            $uncategorizedCategory = $company->categories()->where('type', $transactionType)->where('name', 'Other')->first();
 | 
		
	
		
			
			|  | 214 | +
 | 
		
	
		
			
			|  | 215 | +            if ($uncategorizedCategory === null) {
 | 
		
	
		
			
			|  | 216 | +                $uncategorizedCategory = $company->categories()->where('name', 'Other')->first();
 | 
		
	
		
			
			|  | 217 | +            }
 | 
		
	
		
			
			|  | 218 | +        }
 | 
		
	
		
			
			|  | 219 | +
 | 
		
	
		
			
			|  | 220 | +        return $uncategorizedCategory;
 | 
		
	
		
			
			|  | 221 | +    }
 | 
		
	
		
			
			|  | 222 | +
 | 
		
	
		
			
			|  | 223 | +    public function getChartFromTransaction(Company $company, $transaction, string $transactionType): Account
 | 
		
	
		
			
			|  | 224 | +    {
 | 
		
	
		
			
			|  | 225 | +        if ($transactionType === 'income') {
 | 
		
	
		
			
			|  | 226 | +            $chart = $company->accounts()->where('type', AccountType::UncategorizedRevenue)->where('name', 'Uncategorized Income')->first();
 | 
		
	
		
			
			|  | 227 | +        } else {
 | 
		
	
		
			
			|  | 228 | +            $chart = $company->accounts()->where('type', AccountType::UncategorizedExpense)->where('name', 'Uncategorized Expense')->first();
 | 
		
	
		
			
			|  | 229 | +        }
 | 
		
	
		
			
			|  | 230 | +
 | 
		
	
		
			
			|  | 231 | +        return $chart;
 | 
		
	
		
			
			| 60 | 232 |      }
 | 
		
	
		
			
			| 61 | 233 |  
 | 
		
	
		
			
			| 62 | 234 |      public function processNewBankAccount(Company $company, ConnectedBankAccount $connectedBankAccount, $accessToken): BankAccount
 |